🤲 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:
Danny Avila 2024-08-16 15:09:03 -04:00 committed by GitHub
parent 6ad65ff065
commit 3826af5909
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 85 additions and 70 deletions

View file

@ -112,10 +112,12 @@ export interface NavProps {
defaultActive?: string; defaultActive?: string;
} }
interface ColumnMeta { export interface DataColumnMeta {
meta: { meta:
size: number | string; | {
}; size: number | string;
}
| undefined;
} }
export enum Panel { export enum Panel {
@ -157,7 +159,7 @@ export type AssistantPanelProps = {
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>; 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; export type TSetOption = SetOption;

View file

@ -43,7 +43,7 @@ interface DataTableProps<TData, TValue> {
const contextMap = { const contextMap = {
[FileContext.filename]: 'com_ui_name', [FileContext.filename]: 'com_ui_name',
[FileContext.updatedAt]: 'com_ui_date', [FileContext.updatedAt]: 'com_ui_date',
[FileContext.source]: 'com_ui_storage', [FileContext.filterSource]: 'com_ui_storage',
[FileContext.context]: 'com_ui_context', [FileContext.context]: 'com_ui_context',
[FileContext.bytes]: 'com_ui_size', [FileContext.bytes]: 'com_ui_size',
}; };
@ -108,13 +108,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
</Button> </Button>
<Input <Input
placeholder={localize('com_files_filter')} 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)} 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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto"> <Button variant="outline" className="ml-auto border border-border-medium">
<ListFilter className="h-4 w-4" /> <ListFilter className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -132,7 +132,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
key={column.id} key={column.id}
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800" className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
> >
{localize(contextMap[column.id])} {localize(contextMap[column.id])}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
@ -184,7 +184,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
> >
{row.getVisibleCells().map((cell, index) => { {row.getVisibleCells().map((cell, index) => {
const maxWidth = const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ?? (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ??
'auto'; 'auto';
const style: Style = {}; const style: Style = {};
@ -225,7 +225,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
)} )}
</div> </div>
<Button <Button
className="select-none dark:border-gray-500 dark:hover:bg-gray-600" className="select-none border-border-medium"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
@ -234,7 +234,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{localize('com_ui_prev')} {localize('com_ui_prev')}
</Button> </Button>
<Button <Button
className="select-none dark:border-gray-500 dark:hover:bg-gray-600" className="select-none border-border-medium"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => table.nextPage()} onClick={() => table.nextPage()}

View file

@ -45,7 +45,7 @@ interface DataTableProps<TData, TValue> {
const contextMap = { const contextMap = {
[FileContext.filename]: 'com_ui_name', [FileContext.filename]: 'com_ui_name',
[FileContext.updatedAt]: 'com_ui_date', [FileContext.updatedAt]: 'com_ui_date',
[FileContext.source]: 'com_ui_storage', [FileContext.filterSource]: 'com_ui_storage',
[FileContext.context]: 'com_ui_context', [FileContext.context]: 'com_ui_context',
[FileContext.bytes]: 'com_ui_size', [FileContext.bytes]: 'com_ui_size',
}; };
@ -121,7 +121,7 @@ export default function DataTableFile<TData, TValue>({
{' '} {' '}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="ml-auto"> <Button variant="outline" className="ml-auto border border-border-medium">
<ListFilter className="h-4 w-4" /> <ListFilter className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -138,7 +138,7 @@ export default function DataTableFile<TData, TValue>({
key={column.id} key={column.id}
className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800" className="cursor-pointer capitalize dark:text-white dark:hover:bg-gray-800"
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) => column.toggleVisibility(Boolean(value))}
> >
{localize(contextMap[column.id])} {localize(contextMap[column.id])}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
@ -148,15 +148,11 @@ export default function DataTableFile<TData, TValue>({
</DropdownMenu> </DropdownMenu>
<Input <Input
placeholder={localize('com_files_filter')} 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)} 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"
/>
<UploadFileButton
onClick={() => {
console.log('click');
}}
/> />
<UploadFileButton onClick={() => console.log('click')} />
</div> </div>
</div> </div>
</div> </div>
@ -213,7 +209,7 @@ export default function DataTableFile<TData, TValue>({
> >
{row.getVisibleCells().map((cell, index) => { {row.getVisibleCells().map((cell, index) => {
const maxWidth = const maxWidth =
(cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta.size ?? (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta?.size ??
'auto'; 'auto';
const style: Style = {}; const style: Style = {};

View file

@ -15,7 +15,7 @@ import Logout from './Logout';
import { cn } from '~/utils/'; import { cn } from '~/utils/';
import store from '~/store'; import store from '~/store';
function NavLinks() { function AccountSettings() {
const localize = useLocalize(); const localize = useLocalize();
const { user, isAuthenticated } = useAuthContext(); const { user, isAuthenticated } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
@ -26,6 +26,7 @@ function NavLinks() {
const [showFiles, setShowFiles] = useRecoilState(store.showFiles); const [showFiles, setShowFiles] = useRecoilState(store.showFiles);
const avatarSrc = useAvatar(user); const avatarSrc = useAvatar(user);
const name = user?.avatar ?? user?.username ?? '';
return ( return (
<> <>
@ -33,15 +34,16 @@ function NavLinks() {
{({ open }) => ( {({ open }) => (
<> <>
<MenuButton <MenuButton
aria-label={localize('com_nav_account_settings')}
className={cn( 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', '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-gray-100 dark:bg-gray-800' : '', open ? 'bg-surface-secondary' : '',
)} )}
data-testid="nav-user" data-testid="nav-user"
> >
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0"> <div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex"> <div className="relative flex">
{!user?.avatar && !user?.username ? ( {name.length === 0 ? (
<div <div
style={{ style={{
backgroundColor: 'rgb(121, 137, 255)', backgroundColor: 'rgb(121, 137, 255)',
@ -49,20 +51,20 @@ function NavLinks() {
height: '32px', height: '32px',
boxShadow: 'rgba(240, 246, 252, 0.1) 0px 0px 0px 1px', 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 /> <UserIcon />
</div> </div>
) : ( ) : (
<img className="rounded-full" src={user.avatar || avatarSrc} alt="avatar" /> <img className="rounded-full" src={user?.avatar ?? avatarSrc} alt="avatar" />
)} )}
</div> </div>
</div> </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' }} style={{ marginTop: '0', marginLeft: '0' }}
> >
{user?.name || user?.username || localize('com_nav_user')} {user?.name ?? user?.username ?? localize('com_nav_user')}
</div> </div>
</MenuButton> </MenuButton>
@ -75,47 +77,56 @@ function NavLinks() {
leaveFrom="translate-y-0 opacity-100" leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-2 opacity-0" 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"> <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>
<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" />
{startupConfig?.checkBalance && {startupConfig?.checkBalance === true &&
balanceQuery.data && balanceQuery.data != null &&
!isNaN(parseFloat(balanceQuery.data)) && ( !isNaN(parseFloat(balanceQuery.data)) && (
<> <>
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm"> <div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm">
{`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`} {`Balance: ${parseFloat(balanceQuery.data).toFixed(2)}`}
</div> </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"> <MenuItem>
<NavLink {({ focus }) => (
svg={() => <FileText className="icon-md" />} <NavLink
text={localize('com_nav_my_files')} className={focus ? 'bg-surface-hover' : ''}
clickHandler={() => setShowFiles(true)} svg={() => <FileText className="icon-md" />}
/> text={localize('com_nav_my_files')}
clickHandler={() => setShowFiles(true)}
/>
)}
</MenuItem> </MenuItem>
{startupConfig?.helpAndFaqURL !== '/' && ( {startupConfig?.helpAndFaqURL !== '/' && (
<MenuItem as="div"> <MenuItem>
<NavLink {({ focus }) => (
svg={() => <LinkIcon />} <NavLink
text={localize('com_nav_help_faq')} className={focus ? 'bg-surface-hover' : ''}
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')} svg={() => <LinkIcon />}
/> text={localize('com_nav_help_faq')}
clickHandler={() => window.open(startupConfig?.helpAndFaqURL, '_blank')}
/>
)}
</MenuItem> </MenuItem>
)} )}
<MenuItem as="div"> <MenuItem>
<NavLink {({ focus }) => (
svg={() => <GearIcon className="icon-md" />} <NavLink
text={localize('com_nav_settings')} className={focus ? 'bg-surface-hover' : ''}
clickHandler={() => setShowSettings(true)} svg={() => <GearIcon className="icon-md" />}
/> text={localize('com_nav_settings')}
clickHandler={() => setShowSettings(true)}
/>
)}
</MenuItem> </MenuItem>
<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"> <MenuItem>
<Logout /> {({ focus }) => <Logout className={focus ? 'bg-surface-hover' : ''} />}
</MenuItem> </MenuItem>
</MenuItems> </MenuItems>
</Transition> </Transition>
@ -128,4 +139,4 @@ function NavLinks() {
); );
} }
export default memo(NavLinks); export default memo(AccountSettings);

View file

@ -1,15 +1,20 @@
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { LogOutIcon } from '../svg';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
import { LogOutIcon } from '~/components/svg';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
const Logout = forwardRef(() => { const Logout = forwardRef<HTMLButtonElement, { className?: string }>((props, ref) => {
const { logout } = useAuthContext(); const { logout } = useAuthContext();
const localize = useLocalize(); const localize = useLocalize();
return ( return (
<button <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()} onClick={() => logout()}
> >
<LogOutIcon /> <LogOutIcon />

View file

@ -15,11 +15,11 @@ import { useConversationsInfiniteQuery } from '~/data-provider';
import { TooltipProvider, Tooltip } from '~/components/ui'; import { TooltipProvider, Tooltip } from '~/components/ui';
import { Conversations } from '~/components/Conversations'; import { Conversations } from '~/components/Conversations';
import BookmarkNav from './Bookmarks/BookmarkNav'; import BookmarkNav from './Bookmarks/BookmarkNav';
import AccountSettings from './AccountSettings';
import { useSearchContext } from '~/Providers'; import { useSearchContext } from '~/Providers';
import { Spinner } from '~/components/svg'; import { Spinner } from '~/components/svg';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import NavToggle from './NavToggle'; import NavToggle from './NavToggle';
import NavLinks from './NavLinks';
import NewChat from './NewChat'; import NewChat from './NewChat';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store'; 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')} /> <Spinner className={cn('m-1 mx-auto mb-4 h-4 w-4 text-text-primary')} />
)} )}
</div> </div>
<NavLinks /> <AccountSettings />
</nav> </nav>
</div> </div>
</div> </div>

View file

@ -9,14 +9,14 @@ interface Props {
disabled?: boolean; 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 { svg, text, clickHandler, disabled, className = '' } = props;
const defaultProps: { const defaultProps: {
className: string; className: string;
onClick?: () => void; onClick?: () => void;
} = { } = {
className: cn( 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, className,
{ {
'opacity-50 pointer-events-none': disabled, 'opacity-50 pointer-events-none': disabled,
@ -29,10 +29,10 @@ const NavLink: FC<Props> = forwardRef<HTMLAnchorElement, Props>((props, ref) =>
} }
return ( return (
<a {...defaultProps} ref={ref}> <button {...defaultProps} ref={ref}>
{svg()} {svg()}
{text} {text}
</a> </button>
); );
}); });

View file

@ -5,7 +5,6 @@ export { default as Logout } from './Logout';
export { default as MobileNav } from './MobileNav'; export { default as MobileNav } from './MobileNav';
export { default as Nav } from './Nav'; export { default as Nav } from './Nav';
export { default as NavLink } from './NavLink'; export { default as NavLink } from './NavLink';
export { default as NavLinks } from './NavLinks';
export { default as NewChat } from './NewChat'; export { default as NewChat } from './NewChat';
export { default as SearchBar } from './SearchBar'; export { default as SearchBar } from './SearchBar';
export { default as Settings } from './Settings'; export { default as Settings } from './Settings';

View file

@ -556,6 +556,7 @@ export default {
com_endpoint_config_key_google_service_account: 'Create a Service Account', com_endpoint_config_key_google_service_account: 'Create a Service Account',
com_endpoint_config_key_google_vertex_api_role: 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.', '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: 'Message Font Size',
com_nav_font_size_xs: 'Extra Small', com_nav_font_size_xs: 'Extra Small',
com_nav_font_size_sm: 'Small', com_nav_font_size_sm: 'Small',

View file

@ -22,6 +22,7 @@ export enum FileContext {
filename = 'filename', filename = 'filename',
updatedAt = 'updatedAt', updatedAt = 'updatedAt',
source = 'source', source = 'source',
filterSource = 'filterSource',
context = 'context', context = 'context',
bytes = 'bytes', bytes = 'bytes',
} }