mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🤲 a11y: Keyboard Accessibility for Account Settings (#3666)
* refactor(NavLinks -> AccountSettings): add theming, fix minor type issues, and rename component * fix: closes #3499 - Account settings menu is not keyboard accessible * fix(DataTable): type issue causing application crash, update contextMap key for storage
This commit is contained in:
parent
6ad65ff065
commit
3826af5909
10 changed files with 85 additions and 70 deletions
|
|
@ -112,10 +112,12 @@ export interface NavProps {
|
|||
defaultActive?: string;
|
||||
}
|
||||
|
||||
interface ColumnMeta {
|
||||
meta: {
|
||||
size: number | string;
|
||||
};
|
||||
export interface DataColumnMeta {
|
||||
meta:
|
||||
| {
|
||||
size: number | string;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export enum Panel {
|
||||
|
|
@ -157,7 +159,7 @@ export type AssistantPanelProps = {
|
|||
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
|
||||
};
|
||||
|
||||
export type AugmentedColumnDef<TData, TValue> = ColumnDef<TData, TValue> & ColumnMeta;
|
||||
export type AugmentedColumnDef<TData, TValue> = ColumnDef<TData, TValue> & DataColumnMeta;
|
||||
|
||||
export type TSetOption = SetOption;
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ interface DataTableProps<TData, TValue> {
|
|||
const contextMap = {
|
||||
[FileContext.filename]: 'com_ui_name',
|
||||
[FileContext.updatedAt]: 'com_ui_date',
|
||||
[FileContext.source]: 'com_ui_storage',
|
||||
[FileContext.filterSource]: 'com_ui_storage',
|
||||
[FileContext.context]: 'com_ui_context',
|
||||
[FileContext.bytes]: 'com_ui_size',
|
||||
};
|
||||
|
|
@ -108,13 +108,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
</Button>
|
||||
<Input
|
||||
placeholder={localize('com_files_filter')}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm border-border-light placeholder:text-text-secondary"
|
||||
className="max-w-sm border-border-medium placeholder:text-text-secondary"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto">
|
||||
<Button variant="outline" className="ml-auto border border-border-medium">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -132,7 +132,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
key={column.id}
|
||||
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
|
||||
>
|
||||
{localize(contextMap[column.id])}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
|
@ -184,7 +184,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const maxWidth =
|
||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ??
|
||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ??
|
||||
'auto';
|
||||
|
||||
const style: Style = {};
|
||||
|
|
@ -225,7 +225,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
)}
|
||||
</div>
|
||||
<Button
|
||||
className="select-none dark:border-gray-500 dark:hover:bg-gray-600"
|
||||
className="select-none border-border-medium"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
|
|
@ -234,7 +234,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<Button
|
||||
className="select-none dark:border-gray-500 dark:hover:bg-gray-600"
|
||||
className="select-none border-border-medium"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ interface DataTableProps<TData, TValue> {
|
|||
const contextMap = {
|
||||
[FileContext.filename]: 'com_ui_name',
|
||||
[FileContext.updatedAt]: 'com_ui_date',
|
||||
[FileContext.source]: 'com_ui_storage',
|
||||
[FileContext.filterSource]: 'com_ui_storage',
|
||||
[FileContext.context]: 'com_ui_context',
|
||||
[FileContext.bytes]: 'com_ui_size',
|
||||
};
|
||||
|
|
@ -121,7 +121,7 @@ export default function DataTableFile<TData, TValue>({
|
|||
{' '}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="ml-auto">
|
||||
<Button variant="outline" className="ml-auto border border-border-medium">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -138,7 +138,7 @@ export default function DataTableFile<TData, TValue>({
|
|||
key={column.id}
|
||||
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
|
||||
>
|
||||
{localize(contextMap[column.id])}
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
|
@ -148,15 +148,11 @@ export default function DataTableFile<TData, TValue>({
|
|||
</DropdownMenu>
|
||||
<Input
|
||||
placeholder={localize('com_files_filter')}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
|
||||
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
|
||||
className="max-w-sm border-border-light placeholder:text-text-secondary"
|
||||
/>
|
||||
<UploadFileButton
|
||||
onClick={() => {
|
||||
console.log('click');
|
||||
}}
|
||||
className="max-w-sm border-border-medium placeholder:text-text-secondary"
|
||||
/>
|
||||
<UploadFileButton onClick={() => console.log('click')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -213,7 +209,7 @@ export default function DataTableFile<TData, TValue>({
|
|||
>
|
||||
{row.getVisibleCells().map((cell, index) => {
|
||||
const maxWidth =
|
||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ??
|
||||
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ??
|
||||
'auto';
|
||||
|
||||
const style: Style = {};
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import Logout from './Logout';
|
|||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
function NavLinks() {
|
||||
function AccountSettings() {
|
||||
const localize = useLocalize();
|
||||
const { user, isAuthenticated } = useAuthContext();
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
|
|
@ -26,6 +26,7 @@ function NavLinks() {
|
|||
const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
|
||||
|
||||
const avatarSrc = useAvatar(user);
|
||||
const name = user?.avatar ?? user?.username ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -33,15 +34,16 @@ function NavLinks() {
|
|||
{({ open }) => (
|
||||
<>
|
||||
<MenuButton
|
||||
aria-label={localize('com_nav_account_settings')}
|
||||
className={cn(
|
||||
'group-ui-open:bg-gray-100 dark:group-ui-open:bg-gray-700 duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
open ? 'bg-gray-100 dark:bg-gray-800' : '',
|
||||
'group-ui-open:bg-surface-tertiary duration-350 mt-text-sm flex h-auto w-full items-center gap-2 rounded-lg p-2 text-sm transition-colors hover:bg-surface-secondary',
|
||||
open ? 'bg-surface-secondary' : '',
|
||||
)}
|
||||
data-testid="nav-user"
|
||||
>
|
||||
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
|
||||
<div className="relative flex">
|
||||
{!user?.avatar && !user?.username ? (
|
||||
{name.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'rgb(121, 137, 255)',
|
||||
|
|
@ -49,20 +51,20 @@ function NavLinks() {
|
|||
height: '32px',
|
||||
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px',
|
||||
}}
|
||||
className="relative flex items-center justify-center rounded-full p-1 text-white"
|
||||
className="relative flex items-center justify-center rounded-full p-1 text-text-primary"
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
) : (
|
||||
<img className="rounded-full" src={user.avatar || avatarSrc} alt="avatar" />
|
||||
<img className="rounded-full" src={user?.avatar ?? avatarSrc} alt="avatar" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-black dark:text-gray-100"
|
||||
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary"
|
||||
style={{ marginTop: '0', marginLeft: '0' }}
|
||||
>
|
||||
{user?.name || user?.username || localize('com_nav_user')}
|
||||
{user?.name ?? user?.username ?? localize('com_nav_user')}
|
||||
</div>
|
||||
</MenuButton>
|
||||
|
||||
|
|
@ -75,47 +77,56 @@ function NavLinks() {
|
|||
leaveFrom="translate-y-0 opacity-100"
|
||||
leaveTo="translate-y-2 opacity-0"
|
||||
>
|
||||
<MenuItems className="absolute bottom-full left-0 z-[100] mb-1 mt-1 w-full translate-y-0 overflow-hidden rounded-lg border border-gray-300 bg-white p-1.5 opacity-100 shadow-lg outline-none dark:border-gray-600 dark:bg-gray-700">
|
||||
<MenuItems className="absolute bottom-full left-0 z-[100] mb-1 mt-1 w-full translate-y-0 overflow-hidden rounded-lg border border-border-medium bg-header-primary p-1.5 opacity-100 shadow-lg outline-none">
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="none">
|
||||
{user?.email || localize('com_nav_user')}
|
||||
{user?.email ?? localize('com_nav_user')}
|
||||
</div>
|
||||
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
|
||||
{startupConfig?.checkBalance &&
|
||||
balanceQuery.data &&
|
||||
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
|
||||
{startupConfig?.checkBalance === true &&
|
||||
balanceQuery.data != null &&
|
||||
!isNaN(parseFloat(balanceQuery.data)) && (
|
||||
<>
|
||||
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm">
|
||||
{`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`}
|
||||
</div>
|
||||
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
|
||||
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
|
||||
</>
|
||||
)}
|
||||
<MenuItem as="div">
|
||||
<NavLink
|
||||
svg={() => <FileText className="icon-md" />}
|
||||
text={localize('com_nav_my_files')}
|
||||
clickHandler={() => setShowFiles(true)}
|
||||
/>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<NavLink
|
||||
className={focus ? 'bg-surface-hover' : ''}
|
||||
svg={() => <FileText className="icon-md" />}
|
||||
text={localize('com_nav_my_files')}
|
||||
clickHandler={() => setShowFiles(true)}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
{startupConfig?.helpAndFaqURL !== '/' && (
|
||||
<MenuItem as="div">
|
||||
<NavLink
|
||||
svg={() => <LinkIcon />}
|
||||
text={localize('com_nav_help_faq')}
|
||||
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
|
||||
/>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<NavLink
|
||||
className={focus ? 'bg-surface-hover' : ''}
|
||||
svg={() => <LinkIcon />}
|
||||
text={localize('com_nav_help_faq')}
|
||||
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem as="div">
|
||||
<NavLink
|
||||
svg={() => <GearIcon className="icon-md" />}
|
||||
text={localize('com_nav_settings')}
|
||||
clickHandler={() => setShowSettings(true)}
|
||||
/>
|
||||
<MenuItem>
|
||||
{({ focus }) => (
|
||||
<NavLink
|
||||
className={focus ? 'bg-surface-hover' : ''}
|
||||
svg={() => <GearIcon className="icon-md" />}
|
||||
text={localize('com_nav_settings')}
|
||||
clickHandler={() => setShowSettings(true)}
|
||||
/>
|
||||
)}
|
||||
</MenuItem>
|
||||
<div className="my-1.5 h-px bg-black/10 dark:bg-white/10" role="none" />
|
||||
<MenuItem as="div">
|
||||
<Logout />
|
||||
<div className="my-1.5 h-px border-b border-border-medium" role="none" />
|
||||
<MenuItem>
|
||||
{({ focus }) => <Logout className={focus ? 'bg-surface-hover' : ''} />}
|
||||
</MenuItem>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
|
|
@ -128,4 +139,4 @@ function NavLinks() {
|
|||
);
|
||||
}
|
||||
|
||||
export default memo(NavLinks);
|
||||
export default memo(AccountSettings);
|
||||
|
|
@ -1,15 +1,20 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { LogOutIcon } from '../svg';
|
||||
import { useAuthContext } from '~/hooks/AuthContext';
|
||||
import { LogOutIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Logout = forwardRef(() => {
|
||||
const Logout = forwardRef<HTMLButtonElement, { className?: string }>((props, ref) => {
|
||||
const { logout } = useAuthContext();
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="group group flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm transition-colors duration-200 hover:bg-gray-500/10 focus:ring-0 dark:text-white dark:hover:bg-gray-600"
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group group flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm text-text-primary transition-colors duration-200 hover:bg-surface-hover data-[focus]:bg-surface-hover',
|
||||
props.className ?? '',
|
||||
)}
|
||||
onClick={() => logout()}
|
||||
>
|
||||
<LogOutIcon />
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ import { useConversationsInfiniteQuery } from '~/data-provider';
|
|||
import { TooltipProvider, Tooltip } from '~/components/ui';
|
||||
import { Conversations } from '~/components/Conversations';
|
||||
import BookmarkNav from './Bookmarks/BookmarkNav';
|
||||
import AccountSettings from './AccountSettings';
|
||||
import { useSearchContext } from '~/Providers';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import SearchBar from './SearchBar';
|
||||
import NavToggle from './NavToggle';
|
||||
import NavLinks from './NavLinks';
|
||||
import NewChat from './NewChat';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
|
@ -196,7 +196,7 @@ const Nav = ({
|
|||
<Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-text-primary')} />
|
||||
)}
|
||||
</div>
|
||||
<NavLinks />
|
||||
<AccountSettings />
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ interface Props {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const NavLink: FC<Props> = forwardRef<HTMLAnchorElement, Props>((props, ref) => {
|
||||
const NavLink: FC<Props> = forwardRef<HTMLButtonElement, Props>((props, ref) => {
|
||||
const { svg, text, clickHandler, disabled, className = '' } = props;
|
||||
const defaultProps: {
|
||||
className: string;
|
||||
onClick?: () => void;
|
||||
} = {
|
||||
className: cn(
|
||||
'flex gap-2 rounded p-2.5 text-sm cursor-pointer focus:ring-0 group items-center transition-colors duration-200 hover:bg-gray-500/10 dark:text-white dark:hover:bg-gray-600',
|
||||
'w-full flex gap-2 rounded p-2.5 text-sm cursor-pointer group items-center transition-colors duration-200 text-text-primary hover:bg-surface-hover',
|
||||
className,
|
||||
{
|
||||
'opacity-50 pointer-events-none': disabled,
|
||||
|
|
@ -29,10 +29,10 @@ const NavLink: FC<Props> = forwardRef<HTMLAnchorElement, Props>((props, ref) =>
|
|||
}
|
||||
|
||||
return (
|
||||
<a {...defaultProps} ref={ref}>
|
||||
<button {...defaultProps} ref={ref}>
|
||||
{svg()}
|
||||
{text}
|
||||
</a>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ export { default as Logout } from './Logout';
|
|||
export { default as MobileNav } from './MobileNav';
|
||||
export { default as Nav } from './Nav';
|
||||
export { default as NavLink } from './NavLink';
|
||||
export { default as NavLinks } from './NavLinks';
|
||||
export { default as NewChat } from './NewChat';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
export { default as Settings } from './Settings';
|
||||
|
|
|
|||
|
|
@ -556,6 +556,7 @@ export default {
|
|||
com_endpoint_config_key_google_service_account: 'Create a Service Account',
|
||||
com_endpoint_config_key_google_vertex_api_role:
|
||||
'Make sure to click \'Create and Continue\' to give at least the \'Vertex AI User\' role. Lastly, create a JSON key to import here.',
|
||||
com_nav_account_settings: 'Account Settings',
|
||||
com_nav_font_size: 'Message Font Size',
|
||||
com_nav_font_size_xs: 'Extra Small',
|
||||
com_nav_font_size_sm: 'Small',
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export enum FileContext {
|
|||
filename = 'filename',
|
||||
updatedAt = 'updatedAt',
|
||||
source = 'source',
|
||||
filterSource = 'filterSource',
|
||||
context = 'context',
|
||||
bytes = 'bytes',
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue