diff --git a/bun.lock b/bun.lock index 08d047a6..ec864745 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "tanstack-start-app", "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", @@ -160,7 +160,7 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "7.27.1", "@babel/helper-validator-identifier": "7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@clickhouse/click-ui": ["@clickhouse/click-ui@0.2.0-rc.4", "", { "dependencies": { "@h6s/calendar": "2.0.1", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-WcPjvS7W/InvHP/JVeMIHA/30jl3Ks6CN+zch7kfqogjcbTc4byOzSUSkOJlYblzLxfdins0z0oeBF+jVsBMDA=="], + "@clickhouse/click-ui": ["@clickhouse/click-ui@0.9.1", "", { "dependencies": { "@h6s/calendar": "2.2.0", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-avatar": "1.1.1", "@radix-ui/react-checkbox": "1.1.2", "@radix-ui/react-context-menu": "2.2.2", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.1", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-hover-card": "1.1.2", "@radix-ui/react-popover": "1.1.2", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-radio-group": "1.2.1", "@radix-ui/react-separator": "1.1.1", "@radix-ui/react-switch": "1.1.1", "@radix-ui/react-tabs": "1.1.1", "@radix-ui/react-toast": "1.2.2", "@radix-ui/react-tooltip": "1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", "lodash-es": "^4.17.23", "react-sortablejs": "^6.1.4", "react-syntax-highlighter": "^16.1.0", "react-virtualized-auto-sizer": "^1.0.20", "react-window": "^1.8.9", "sortablejs": "^1.15.0", "styled-components": "^6.1.11" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-gAyIlzIDYqnXcmHM2rOWNpGAoC0UAeacT5yJpDgu09I+TjmuOdzh9tYaJlS938SmUyLsg2U6t8oMA7do108Hgg=="], "@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="], @@ -188,8 +188,6 @@ "@emotion/memoize": ["@emotion/memoize@0.9.0", "", {}, "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="], - "@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -270,7 +268,7 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], - "@h6s/calendar": ["@h6s/calendar@2.0.1", "", { "peerDependencies": { "date-fns": ">= 2", "react": ">= 18" } }, "sha512-9q5ksdnUsLDeuSm5arXzkxHyS22u7yi5TtVr4DZgW514AjdL+76kx7d+ScX9sKusasRQVxrhM2LTVmd8A/u42Q=="], + "@h6s/calendar": ["@h6s/calendar@2.2.0", "", { "peerDependencies": { "react": ">= 18" } }, "sha512-o1fb4RlMWDRIOiMEX65vN5qDTiD/OyC++EkJzmoz9l1vUOkN89gTE+wNE83XZBi3hbYqMV3CNhgKf51n/0hcBw=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], @@ -644,8 +642,6 @@ "@types/sortablejs": ["@types/sortablejs@1.15.9", "", {}, "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="], - "@types/stylis": ["@types/stylis@4.2.7", "", {}, "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA=="], - "@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="], "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], @@ -824,6 +820,8 @@ "ci-info": ["ci-info@3.9.0", "", {}, "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + "classnames": ["classnames@2.3.1", "", {}, "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="], "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1454,8 +1452,6 @@ "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - "shallowequal": ["shallowequal@1.1.0", "", {}, "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], @@ -1654,10 +1650,6 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@clickhouse/click-ui/dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], - - "@clickhouse/click-ui/styled-components": ["styled-components@6.3.11", "", { "dependencies": { "@emotion/is-prop-valid": "1.4.0", "@emotion/unitless": "0.10.0", "@types/stylis": "4.2.7", "css-to-react-native": "3.2.0", "csstype": "3.2.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.6", "tslib": "2.8.1" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" }, "optionalPeers": ["react-dom"] }, "sha512-opzgceGlQ5rdZdGwf9ddLW7EM2F4L7tgsgLn6fFzQ2JgE5EVQ4HZwNkcgB1p8WfOBx1GEZP3fa66ajJmtXhSrA=="], - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], @@ -2020,8 +2012,6 @@ "xmlbuilder2/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@clickhouse/click-ui/styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "@eslint/eslintrc/espree/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], @@ -2136,8 +2126,6 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "@clickhouse/click-ui/styled-components/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "@humanwhocodes/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], diff --git a/package.json b/package.json index 78d3e020..f46add31 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "format:check": "prettier --check 'src/**/*.{ts,tsx,css,json}'" }, "dependencies": { - "@clickhouse/click-ui": "0.2.0-rc.4", + "@clickhouse/click-ui": "0.9.1", "@librechat/data-schemas": "^0.0.56", "@radix-ui/react-dialog": "1.1.15", "@tailwindcss/vite": "^4.3.1", diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index e14d01dc..c7003752 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -1,7 +1,7 @@ import { Dialog } from '@clickhouse/click-ui'; import type * as t from '@/types'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { useTheme } from '@/contexts/ThemeContext'; -import { useLocalize } from '@/hooks'; import { cn } from '@/utils'; const THEME_OPTIONS: t.ThemeOption[] = ['system', 'light', 'dark']; @@ -11,8 +11,9 @@ const THEME_LABEL_KEYS: Record = { dark: 'com_nav_theme_dark', }; -export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { +export function SettingsDialog({ open, fallbackRef, onClose }: t.SettingsDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open, fallbackRef); const { theme, setTheme } = useTheme(); return ( @@ -26,6 +27,7 @@ export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { title={localize('com_ui_settings')} showClose onClose={onClose} + onCloseAutoFocus={returnFocus} className="modal-frost" >
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ce99c1e2..98f71714 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -88,7 +88,11 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) { >
- {localize('com_a11y_logo_alt')} + {localize('com_a11y_logo_alt')} {localize('com_auth_title')} @@ -192,7 +196,11 @@ export function Sidebar({ user, collapsed, onToggle }: t.SidebarProps) { - setSettingsOpen(false)} /> + setSettingsOpen(false)} + /> ); } diff --git a/src/components/__tests__/focus.test.tsx b/src/components/__tests__/focus.test.tsx new file mode 100644 index 00000000..c13b9e03 --- /dev/null +++ b/src/components/__tests__/focus.test.tsx @@ -0,0 +1,408 @@ +import { useRef, useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { PrincipalType } from 'librechat-data-provider'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ReactNode, RefObject } from 'react'; +import type * as t from '@/types'; +import { DeleteProfileValueModal } from '../configuration/DeleteProfileValueModal'; +import { ResetBaseConfigDialog } from '../configuration/ResetBaseConfigDialog'; +import { EditCapabilitiesDialog } from '../grants/EditCapabilitiesDialog'; +import { ProfileValueModal } from '../configuration/ProfileValueModal'; +import { GrantManagementTab } from '../grants/GrantManagementTab'; +import { ConfirmSaveDialog } from '../configuration/ConfirmSaveDialog'; +import { ImportYamlDialog } from '../configuration/ImportYamlDialog'; +import { CreateGroupDialog } from '../access/CreateGroupDialog'; +import { CreateRoleDialog } from '../access/CreateRoleDialog'; +import { ThemeProvider } from '@/contexts/ThemeContext'; +import { EditGroupDialog } from '../access/EditGroupDialog'; +import { EditRoleDialog } from '../access/EditRoleDialog'; +import { defaultPermissions } from '@/constants'; +import { SettingsDialog } from '../SettingsDialog'; +import { FormDialog } from '../shared/FormDialog'; + +vi.mock('@/hooks/useLocalize', () => ({ + default: () => (key: string) => key, + useLocalize: () => (key: string) => key, +})); + +vi.mock('@/server', async () => { + const { defaultPermissions: permissions } = await import('@/constants'); + return { + MEMBERS_PAGE_SIZE: 25, + availableScopesOptions: { queryKey: ['availableScopes'], queryFn: async () => [] }, + allGrantsQueryOptions: { queryKey: ['allGrants'], queryFn: async () => [] }, + allRolesQueryOptions: { + queryKey: ['allRoles'], + queryFn: async () => [ + { + id: 'role-1', + name: 'Admins', + description: '', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: permissions(), + }, + ], + }, + roleQueryOptions: (id: string) => ({ + queryKey: ['role', id], + queryFn: async () => ({ + id, + name: 'Admins', + description: '', + permissions: permissions(), + }), + }), + roleMembersQueryOptions: (id: string, page: number) => ({ + queryKey: ['roleMembers', id, page], + queryFn: async () => ({ members: [], total: 0 }), + }), + groupMembersQueryOptions: (id: string, page: number) => ({ + queryKey: ['groupMembers', id, page], + queryFn: async () => ({ members: [], total: 0 }), + }), + principalGrantsQueryOptions: (principalType: string, principalId: string) => ({ + queryKey: ['systemGrants', principalType, principalId], + queryFn: async () => [], + }), + addGroupMemberFn: vi.fn(), + addRoleMemberFn: vi.fn(), + createGroupFn: vi.fn(), + createRoleFn: vi.fn(), + grantCapabilityFn: vi.fn(), + parseImportedYaml: vi.fn(), + removeGroupMemberFn: vi.fn(), + removeRoleMemberFn: vi.fn(), + revokeCapabilityFn: vi.fn(), + searchUsersFn: vi.fn(), + updateGroupFn: vi.fn(), + updateRoleFn: vi.fn(), + updateRolePermissionsFn: vi.fn(), + }; +}); + +class ResizeObserverStub implements ResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} +vi.stubGlobal('ResizeObserver', ResizeObserverStub); + +vi.stubGlobal('matchMedia', (query: string): MediaQueryList => { + return { + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + } as MediaQueryList; +}); + +const noop = () => {}; + +const testRole: t.Role = { + id: 'role-1', + name: 'Admins', + description: '', + isSystemRole: false, + isActive: true, + userCount: 0, + permissions: defaultPermissions(), +}; + +const testGroup = { + id: 'group-1', + name: 'Engineers', + description: '', + memberCount: 0, + topMembers: [], + isActive: true, +}; + +const testScope: t.ConfigScope = { + principalType: PrincipalType.ROLE, + principalId: 'role-1', + name: 'Admins', + priority: 100, + isActive: true, +}; + +interface FocusCase { + name: string; + renderDialog: (open: boolean, close: () => void) => ReactNode; +} + +const cases: FocusCase[] = [ + { + name: 'ImportYamlDialog', + renderDialog: (open, close) => ( + {}} + /> + ), + }, + { + name: 'ConfirmSaveDialog', + renderDialog: (open, close) => ( + + ), + }, + { + name: 'EditRoleDialog', + renderDialog: (open, close) => ( + + ), + }, + { + name: 'CreateRoleDialog', + renderDialog: (open, close) => , + }, + { + name: 'EditGroupDialog', + renderDialog: (open, close) => ( + + ), + }, + { + name: 'CreateGroupDialog', + renderDialog: (open, close) => , + }, + { + name: 'FormDialog', + renderDialog: (open, close) => ( + +
+ + ), + }, + { + name: 'EditCapabilitiesDialog', + renderDialog: (open, close) => ( + + ), + }, + { + name: 'SettingsDialog', + renderDialog: (open, close) => , + }, + { + name: 'ResetBaseConfigDialog', + renderDialog: (open, close) => ( + + ), + }, + { + name: 'ProfileValueModal', + renderDialog: (open, close) => ( + + ), + }, + { + name: 'DeleteProfileValueModal', + renderDialog: (open, close) => ( + + ), + }, +]; + +function CaseHarness({ renderDialog }: { renderDialog: FocusCase['renderDialog'] }) { + const [open, setOpen] = useState(false); + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return ( + + + + {renderDialog(open, () => setOpen(false))} + + + ); +} + +describe('dialog focus return', () => { + it.each(cases)('$name returns focus to the trigger on close', async ({ renderDialog }) => { + render(); + const trigger = screen.getByRole('button', { name: 'case trigger' }); + trigger.focus(); + fireEvent.click(trigger); + const dialog = await screen.findByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(trigger).toHaveFocus()); + }); +}); + +interface FallbackCase { + name: string; + renderDialog: ( + open: boolean, + close: () => void, + fallbackRef: RefObject, + ) => ReactNode; +} + +const fallbackCases: FallbackCase[] = [ + { + name: 'SettingsDialog', + renderDialog: (open, close, fallbackRef) => ( + + ), + }, + { + name: 'EditRoleDialog', + renderDialog: (open, close, fallbackRef) => ( + + ), + }, + { + name: 'EditGroupDialog', + renderDialog: (open, close, fallbackRef) => ( + + ), + }, + { + name: 'ProfileValueModal', + renderDialog: (open, close, fallbackRef) => ( + + ), + }, + { + name: 'DeleteProfileValueModal', + renderDialog: (open, close, fallbackRef) => ( + + ), + }, +]; + +function FallbackHarness({ + renderDialog, + showTrigger, +}: { + renderDialog: FallbackCase['renderDialog']; + showTrigger: boolean; +}) { + const [open, setOpen] = useState(false); + const fallbackRef = useRef(null); + const [client] = useState( + () => new QueryClient({ defaultOptions: { queries: { retry: false } } }), + ); + return ( + + + {showTrigger && ( + + )} +
+ {renderDialog(open, () => setOpen(false), fallbackRef)} + + + ); +} + +describe('dialog focus fallback', () => { + it.each(fallbackCases)( + '$name focuses the fallback when the trigger unmounts before close', + async ({ renderDialog }) => { + const { rerender } = render(); + const trigger = screen.getByRole('button', { name: 'case trigger' }); + trigger.focus(); + fireEvent.click(trigger); + const dialog = await screen.findByRole('dialog'); + rerender(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('fallback')).toHaveFocus()); + }, + ); +}); + +describe('grant table focus return', () => { + it('restores focus to the edited row even when activation did not focus it', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + const row = await screen.findByRole('button', { name: 'com_cap_edit_title' }); + const search = screen.getByPlaceholderText('com_ui_search'); + search.focus(); + fireEvent.click(row); + const dialog = await screen.findByRole('dialog'); + fireEvent.keyDown(dialog, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(row).toHaveFocus()); + expect(search).not.toHaveFocus(); + }); +}); diff --git a/src/components/access/CreateGroupDialog.tsx b/src/components/access/CreateGroupDialog.tsx index f383777a..f7d4d912 100644 --- a/src/components/access/CreateGroupDialog.tsx +++ b/src/components/access/CreateGroupDialog.tsx @@ -6,10 +6,11 @@ import type * as t from '@/types'; import { SelectedMemberList, UserSearchInline } from '@/components/shared'; import { addGroupMemberFn, createGroupFn } from '@/server'; import { cn, notifySuccess, notifyError } from '@/utils'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(''); @@ -84,6 +85,7 @@ export function CreateGroupDialog({ open, onClose }: t.CreateGroupDialogProps) { title={localize('com_access_create_group')} showClose onClose={resetAndClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-2xl!" >
diff --git a/src/components/access/CreateRoleDialog.tsx b/src/components/access/CreateRoleDialog.tsx index 836f3534..ead38a0e 100644 --- a/src/components/access/CreateRoleDialog.tsx +++ b/src/components/access/CreateRoleDialog.tsx @@ -8,10 +8,11 @@ import { SelectedMemberList, UserSearchInline } from '@/components/shared'; import { RolePermissionsPanel } from './RolePermissionsPanel'; import { cn, notifySuccess, notifyError } from '@/utils'; import { defaultPermissions } from '@/constants'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(''); @@ -87,6 +88,7 @@ export function CreateRoleDialog({ open, onClose }: t.CreateRoleDialogProps) { title={localize('com_access_create_role')} showClose onClose={resetAndClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-2xl!" > diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index 8c0d6cfb..dedb4083 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -19,12 +19,18 @@ import { UserSearchInline, } from '@/components/shared'; import { cn, notifySuccess, notifyError } from '@/utils'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; type EditGroupTab = 'details' | 'members'; -export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialogProps) { +export function EditGroupDialog({ + group, + canManage, + fallbackRef, + onClose, +}: t.EditGroupDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!group, fallbackRef); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(group?.name ?? ''); @@ -134,6 +140,7 @@ export function EditGroupDialog({ group, canManage, onClose }: t.EditGroupDialog title={localize('com_access_edit_group')} showClose onClose={onClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-2xl!" > diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index 891c1ac2..cb62a4a8 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -22,12 +22,13 @@ import { } from '@/components/shared'; import { RolePermissionsPanel } from './RolePermissionsPanel'; import { cn, notifySuccess, notifyError } from '@/utils'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; type EditRoleTab = 'details' | 'permissions' | 'members'; -export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogProps) { +export function EditRoleDialog({ role, canManage, fallbackRef, onClose }: t.EditRoleDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!role, fallbackRef); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(role?.name ?? ''); @@ -180,6 +181,7 @@ export function EditRoleDialog({ role, canManage, onClose }: t.EditRoleDialogPro title={localize('com_access_edit_role')} showClose onClose={onClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-2xl!" > diff --git a/src/components/access/GroupsTab.tsx b/src/components/access/GroupsTab.tsx index 2819f3c5..8edc8009 100644 --- a/src/components/access/GroupsTab.tsx +++ b/src/components/access/GroupsTab.tsx @@ -28,6 +28,8 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(1); const debounceRef = useRef>(undefined); + /** Stable focus target for the edit dialog: a rename can drop the group from the searched list and unmount the row button that opened it. */ + const listFallbackRef = useRef(null); useEffect(() => { return () => clearTimeout(debounceRef.current); @@ -80,7 +82,11 @@ export function GroupsTab({ onCreateGroup }: t.GroupsTabProps) { } return ( -
+
setEditTarget(null)} /> diff --git a/src/components/access/RolesTab.tsx b/src/components/access/RolesTab.tsx index 50d53a17..4be113cd 100644 --- a/src/components/access/RolesTab.tsx +++ b/src/components/access/RolesTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { Button } from '@clickhouse/click-ui'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import type * as t from '@/types'; @@ -25,6 +25,8 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { const [deleteTarget, setDeleteTarget] = useState(null); const [search, setSearch] = useState(''); const [page, setPage] = useState(1); + /** Stable focus target for the edit dialog: a rename can drop the role from the filtered list and unmount the row button that opened it. */ + const listFallbackRef = useRef(null); const { data: allRoles = [], isLoading, isError } = useQuery(allRolesQueryOptions); @@ -72,7 +74,11 @@ export function RolesTab({ onCreateRole }: t.RolesTabProps) { } return ( -
+
setEditTarget(null)} /> diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6d30330d..b48efc21 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -457,6 +457,8 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi const [confirmSaveOpen, setConfirmSaveOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + /** After a successful save the StickyActionBar (the Save trigger) unmounts, so the dialog needs an always-mounted focus target to return to. */ + const saveFallbackRef = useRef(null); const handleDiscard = useCallback(() => { setEditedValues({}); @@ -934,7 +936,11 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi })(); return ( -
+
{banner &&
{banner}
} setConfirmSaveOpen(false)} /> @@ -1052,6 +1059,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setImportOpen(false)} onImport={handleImport} onImportAsProfile={handleImportAsProfile} @@ -1061,6 +1069,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi open={resetBaseOpen} resetting={resettingBase} error={resetBaseError} + fallbackRef={saveFallbackRef} onConfirm={handleResetBaseConfig} onCancel={() => { if (resettingBase) return; diff --git a/src/components/configuration/ConfirmSaveDialog.tsx b/src/components/configuration/ConfirmSaveDialog.tsx index 6946b968..9e808167 100644 --- a/src/components/configuration/ConfirmSaveDialog.tsx +++ b/src/components/configuration/ConfirmSaveDialog.tsx @@ -2,7 +2,7 @@ import yaml from 'js-yaml'; import { Badge, Button, Dialog } from '@clickhouse/click-ui'; import type { ReactNode } from 'react'; import type * as t from '@/types'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; export function ConfirmSaveDialog({ open, @@ -10,10 +10,12 @@ export function ConfirmSaveDialog({ originalValues, saving, error, + fallbackRef, onConfirm, onCancel, }: t.ConfirmSaveDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open, fallbackRef); const entries = Object.entries(editedValues).sort(([a], [b]) => a.localeCompare(b)); const count = entries.length; const countLabel = @@ -32,6 +34,7 @@ export function ConfirmSaveDialog({ title={localize('com_config_confirm_save_title')} showClose onClose={onCancel} + onCloseAutoFocus={returnFocus} className="modal-frost" >
@@ -70,9 +73,10 @@ function resolvePathLabel(path: string, newValue: t.ConfigValue, oldValue: t.Con const match = /^(.+)\.(\d+)$/.exec(path); if (!match) return path; const val = (newValue ?? oldValue) as Record | undefined; - const name = val && typeof val === 'object' && !Array.isArray(val) - ? (val as Record).name - : undefined; + const name = + val && typeof val === 'object' && !Array.isArray(val) + ? (val as Record).name + : undefined; return name ? `${match[1]}[${match[2]}] (${name})` : `${match[1]}[${match[2]}]`; } diff --git a/src/components/configuration/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx index f67ce196..95f511f2 100644 --- a/src/components/configuration/DeleteProfileValueModal.tsx +++ b/src/components/configuration/DeleteProfileValueModal.tsx @@ -1,16 +1,18 @@ import { Icon, Button, Dialog } from '@clickhouse/click-ui'; import type * as t from '@/types'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { getScopeTypeConfig } from '@/constants'; -import { useLocalize } from '@/hooks'; export function DeleteProfileValueModal({ scope, + fallbackRef, fieldLabel, saving, onConfirm, onCancel, }: t.DeleteProfileValueModalProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!scope, fallbackRef); const scopeConfig = scope ? getScopeTypeConfig(scope.principalType) : null; return ( @@ -24,6 +26,7 @@ export function DeleteProfileValueModal({ title={localize('com_scope_confirm_remove')} showClose onClose={onCancel} + onCloseAutoFocus={returnFocus} className="modal-frost" > {scope && ( diff --git a/src/components/configuration/FieldProfilePopover.tsx b/src/components/configuration/FieldProfilePopover.tsx index f495d964..5d3d930a 100644 --- a/src/components/configuration/FieldProfilePopover.tsx +++ b/src/components/configuration/FieldProfilePopover.tsx @@ -1,6 +1,6 @@ import { Icon } from '@clickhouse/click-ui'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import type * as t from '@/types'; import { ProfileValueModal, getDefaultValue } from './ProfileValueModal'; import { DeleteProfileValueModal } from './DeleteProfileValueModal'; @@ -25,6 +25,8 @@ export function FieldProfilePopover({ const [adding, setAdding] = useState(false); const [selectedAddScope, setSelectedAddScope] = useState(null); const [deleteScope, setDeleteScope] = useState(null); + /** Stable focus target for the modals: their openers (scope-selection buttons, per-row edit/delete buttons) unmount when a save or removal succeeds. */ + const listRef = useRef(null); const { data: allScopes = [] } = useQuery(availableScopesOptions); @@ -118,7 +120,11 @@ export function FieldProfilePopover({ return ( <> -
+
{ handleRemove(scope); setDeleteScope(null); diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 0cdd5ae9..425145c9 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -5,16 +5,18 @@ import { Icon, Button, Dialog, Tabs } from '@clickhouse/click-ui'; import type * as t from '@/types'; import { availableScopesOptions, createGroupFn, createRoleFn, parseImportedYaml } from '@/server'; import { getScopeTypeConfig } from '@/constants'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { cn } from '@/utils'; export function ImportYamlDialog({ open, + fallbackRef, onClose, onImport, onImportAsProfile, }: t.ImportYamlDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open, fallbackRef); const fileInputRef = useRef(null); const targetRef = useRef(null); @@ -202,6 +204,7 @@ export function ImportYamlDialog({ title={localize('com_config_import_yaml_title')} showClose onClose={handleClose} + onCloseAutoFocus={returnFocus} className="modal-frost" >
diff --git a/src/components/configuration/ProfileIndicator.tsx b/src/components/configuration/ProfileIndicator.tsx index c7a499a9..f5fe2fdd 100644 --- a/src/components/configuration/ProfileIndicator.tsx +++ b/src/components/configuration/ProfileIndicator.tsx @@ -5,8 +5,8 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import type * as t from '@/types'; import { FieldProfilePopover } from './FieldProfilePopover'; import { fieldProfileValuesOptions } from '@/server'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { getScopeTypeConfig } from '@/constants'; -import { useLocalize } from '@/hooks'; export function ProfileIndicator({ fieldPath, @@ -21,6 +21,7 @@ export function ProfileIndicator({ const localize = useLocalize(); const queryClient = useQueryClient(); const [dialogOpen, setDialogOpen] = useState(false); + const returnFocus = useReturnFocus(dialogOpen); const hasProfiles = profileTypes && profileTypes.length > 0; @@ -89,6 +90,7 @@ export function ProfileIndicator({ title={localize('com_scope_cascade_title')} showClose onClose={handleClose} + onCloseAutoFocus={returnFocus} className="modal-frost" > - +
{scopeConfig && ( diff --git a/src/components/configuration/ResetBaseConfigDialog.tsx b/src/components/configuration/ResetBaseConfigDialog.tsx index c53dca8f..2b0e306d 100644 --- a/src/components/configuration/ResetBaseConfigDialog.tsx +++ b/src/components/configuration/ResetBaseConfigDialog.tsx @@ -1,15 +1,17 @@ import { Button, Dialog } from '@clickhouse/click-ui'; import type * as t from '@/types'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; export function ResetBaseConfigDialog({ open, resetting, error, + fallbackRef, onConfirm, onCancel, }: t.ResetBaseConfigDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open, fallbackRef); return (
diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx index 9c42eea2..ceb3d579 100644 --- a/src/components/grants/AuditLogDetailDrawer.tsx +++ b/src/components/grants/AuditLogDetailDrawer.tsx @@ -2,7 +2,7 @@ import * as Dialog from '@radix-ui/react-dialog'; import { PrincipalType } from 'librechat-data-provider'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Badge, Button, Icon, IconButton } from '@clickhouse/click-ui'; -import type { ReactElement } from 'react'; +import type { ReactElement, RefObject } from 'react'; import type * as t from '@/types'; import { ACTION_BADGE_STATE, @@ -13,7 +13,7 @@ import { } from './auditLogUtils'; import { LoadingState } from '@/components/shared'; import { getScopeTypeConfig } from '@/constants'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { cn } from '@/utils'; interface AuditLogDetailDrawerProps { @@ -41,6 +41,8 @@ interface AuditLogDetailDrawerProps { * caller is responsible for distinguishing this from `notFound` (which is * the 404 case where the request itself succeeded but the entry is gone). */ loadError?: boolean; + /** Restore target for the no-opener path (cold `?entryId=` permalink), where the capture at open time is only `document.body`. */ + fallbackRef?: RefObject; } function CopyableMono({ @@ -133,8 +135,10 @@ export function AuditLogDetailDrawer({ notFound = false, loading = false, loadError = false, + fallbackRef, }: AuditLogDetailDrawerProps): ReactElement | null { const localize = useLocalize(); + const returnFocus = useReturnFocus(open, fallbackRef); // Keep the last non-null entry so the close animation has content to render // while Radix Dialog slides the panel out. Without this, unmounting on @@ -225,6 +229,7 @@ export function AuditLogDetailDrawer({ onClose()} + onCloseAutoFocus={returnFocus} className={cn( 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl sm:w-120', 'border-l border-(--cui-color-stroke-default)', @@ -272,6 +277,7 @@ export function AuditLogDetailDrawer({ onClose()} + onCloseAutoFocus={returnFocus} className={cn( 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl sm:w-120', 'border-l border-(--cui-color-stroke-default)', @@ -324,6 +330,7 @@ export function AuditLogDetailDrawer({ onClose()} + onCloseAutoFocus={returnFocus} className={cn( 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl sm:w-120', 'border-l border-(--cui-color-stroke-default)', @@ -389,6 +396,7 @@ export function AuditLogDetailDrawer({ onClose()} + onCloseAutoFocus={returnFocus} className={cn( 'fixed top-0 right-0 z-(--z-overlay) flex h-full w-full flex-col bg-(--cui-color-background-panel) shadow-xl sm:w-120', 'border-l border-(--cui-color-stroke-default)', diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index ce2021e5..e8b703fc 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -113,6 +113,8 @@ export function AuditLogTab() { const [currentPage, setCurrentPage] = useState(1); const { message: announcement, announce } = useAnnouncement(); + const rowRefs = useRef>(new Map()); + const tabFallbackRef = useRef(null); const resetToFirstPage = useCallback(() => setCurrentPage(1), []); const searchFilter = useDebouncedFilter('', resetToFirstPage); @@ -287,8 +289,10 @@ export function AuditLogTab() { !entryOnPage && (!isAuditEntryId(entryId) || (entryFetch.isSuccess && entryFetch.data?.entry === null)); + /** Focus the row before opening so the drawer's useReturnFocus captures it even when pointer activation left focus elsewhere (e.g. a filter input). */ const openEntry = useCallback( (id: string) => { + rowRefs.current.get(id)?.focus(); void navigate({ search: (prev: Record) => ({ ...prev, entryId: id }) }); }, [navigate], @@ -314,7 +318,11 @@ export function AuditLogTab() { announce(localize('com_a11y_copy_failed')); return false; } - const url = buildEntryPermalink(id, window.location.origin, import.meta.env.VITE_BASE_PATH || ''); + const url = buildEntryPermalink( + id, + window.location.origin, + import.meta.env.VITE_BASE_PATH || '', + ); try { await navigator.clipboard.writeText(url); return true; @@ -385,7 +393,11 @@ export function AuditLogTab() { const exportLabel = localize('com_audit_export_server'); return ( -
+
openEntry(entry.id)} onKeyDown={(e) => handleRowKeyDown(e, entry.id)} + rowRef={(el) => { + if (el) rowRefs.current.set(entry.id, el); + else rowRefs.current.delete(entry.id); + }} localize={localize} /> ))} @@ -622,6 +638,7 @@ export function AuditLogTab() { void; onKeyDown: (e: React.KeyboardEvent) => void; + rowRef: (el: HTMLTableRowElement | null) => void; localize: ReturnType; }) { const targetConfig = getScopeTypeConfig(entry.target.type as PrincipalType); const capability = auditCapability(entry); return ( { const record: Record = {}; @@ -26,11 +26,13 @@ export function EditCapabilitiesDialog({ principalType, principalId, principalName, + fallbackRef, onClose, }: t.EditCapabilitiesDialogProps) { const localize = useLocalize(); const queryClient = useQueryClient(); const open = principalType != null && principalId != null; + const returnFocus = useReturnFocus(open, fallbackRef); const { data: grants = [], isLoading } = useQuery({ ...principalGrantsQueryOptions(principalType ?? PrincipalType.ROLE, principalId ?? ''), @@ -104,6 +106,7 @@ export function EditCapabilitiesDialog({ title={dialogTitle} showClose onClose={onClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-2xl!" > {isLoading ? ( diff --git a/src/components/grants/GrantManagementTab.tsx b/src/components/grants/GrantManagementTab.tsx index 64ed216f..a53b861d 100644 --- a/src/components/grants/GrantManagementTab.tsx +++ b/src/components/grants/GrantManagementTab.tsx @@ -23,6 +23,7 @@ export function GrantManagementTab() { const [editTarget, setEditTarget] = useState(null); const { message: announcement, announce } = useAnnouncement(); const rowRefs = useRef>(new Map()); + const activeRowRef = useRef(null); const { data: grants = [], isLoading: grantsLoading } = useQuery(allGrantsQueryOptions); const { data: roles = [] } = useQuery(allRolesQueryOptions); @@ -46,12 +47,12 @@ export function GrantManagementTab() { announce(localize('com_a11y_cap_filter_changed', { count })); }; - const handleDialogClose = () => { - const key = editTarget ? `${editTarget.principalType}:${editTarget.principalId}` : null; - setEditTarget(null); - if (key) { - requestAnimationFrame(() => rowRefs.current.get(key)?.focus()); - } + /** Focus the row before opening so useReturnFocus captures it even when pointer activation left focus elsewhere (e.g. the search box). */ + const openEditor = (row: t.PrincipalRow, key: string) => { + const rowEl = rowRefs.current.get(key) ?? null; + rowEl?.focus(); + activeRowRef.current = rowEl; + setEditTarget(row); }; if (grantsLoading) { @@ -96,15 +97,16 @@ export function GrantManagementTab() { key={key} row={row} isLast={i === paged.length - 1} - onClick={() => setEditTarget(row)} + onClick={() => openEditor(row, key)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setEditTarget(row); + openEditor(row, key); } }} rowRef={(el) => { if (el) rowRefs.current.set(key, el); + else rowRefs.current.delete(key); }} /> ); @@ -126,7 +128,8 @@ export function GrantManagementTab() { principalType={editTarget?.principalType ?? null} principalId={editTarget?.principalId ?? null} principalName={editTarget?.name ?? ''} - onClose={handleDialogClose} + fallbackRef={activeRowRef} + onClose={() => setEditTarget(null)} /> diff --git a/src/components/shared/FormDialog.tsx b/src/components/shared/FormDialog.tsx index 23d2a50d..3c18d0b2 100644 --- a/src/components/shared/FormDialog.tsx +++ b/src/components/shared/FormDialog.tsx @@ -1,6 +1,6 @@ import { Button, Dialog } from '@clickhouse/click-ui'; import type * as t from '@/types'; -import { useLocalize } from '@/hooks'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { cn } from '@/utils'; const sizeClasses: Record = { @@ -21,6 +21,7 @@ export function FormDialog({ children, }: t.FormDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -38,6 +39,7 @@ export function FormDialog({ title={title} showClose onClose={onClose} + onCloseAutoFocus={returnFocus} className={cn('modal-frost', size && sizeClasses[size])} > @@ -54,11 +56,7 @@ export function FormDialog({ onClick={onClose} disabled={saving} /> -
diff --git a/src/components/users/UserDetailDialog.tsx b/src/components/users/UserDetailDialog.tsx index c218491b..6ebef4b1 100644 --- a/src/components/users/UserDetailDialog.tsx +++ b/src/components/users/UserDetailDialog.tsx @@ -19,8 +19,8 @@ import { } from '@/server'; import { Avatar, TrashButton } from '@/components/shared'; import { cn, notifySuccess, notifyError } from '@/utils'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { ConfirmDialog } from '@/components/access'; -import { useLocalize } from '@/hooks'; const CONFIRM_TITLE_KEYS: Record = { role: 'com_users_remove_role_title', @@ -38,12 +38,14 @@ const CONFIRM_TITLE_KEYS: Record = { */ export function UserDetailDialog({ user, + fallbackRef, onClose, canManageRoles = false, canManageGroups = false, canAssignConfigs = false, }: t.UserDetailDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!user, fallbackRef); const queryClient = useQueryClient(); const [view, setView] = useState<'main' | 'add'>('main'); @@ -212,6 +214,7 @@ export function UserDetailDialog({ title={dialogTitle} showClose onClose={handleClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-lg!" > {user && view === 'main' && ( diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx index fa1158fc..ba863a23 100644 --- a/src/components/users/UsersPage.tsx +++ b/src/components/users/UsersPage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react'; +import { useState, useMemo, useRef } from 'react'; import { Icon } from '@clickhouse/click-ui'; import { PrincipalType, SystemRoles } from 'librechat-data-provider'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -46,6 +46,8 @@ export function UsersPage() { const [deleteTarget, setDeleteTarget] = useState(null); const [detailUser, setDetailUser] = useState(null); const { message: announcement, announce } = useAnnouncement(); + const rowRefs = useRef>(new Map()); + const activeRowRef = useRef(null); const { data: users = [], isLoading } = useQuery(usersQueryOptions); const { data: roleAssignments = {} } = useQuery(roleAssignmentsQueryOptions); @@ -94,6 +96,14 @@ export function UsersPage() { announce(localize('com_a11y_results_found', { count })); }; + /** Focus the row before opening so useReturnFocus captures it: clicking the row does not move focus (the tr is only programmatically focusable) and the kebab menu item unmounts on select. */ + const openDetails = (user: TUser) => { + const rowEl = rowRefs.current.get(user.id) ?? null; + rowEl?.focus(); + activeRowRef.current = rowEl; + setDetailUser(user); + }; + const handleRoleFilter = (role: t.RoleFilter) => { setRoleFilter(role); const count = applyFilters(users, search, role).length; @@ -184,9 +194,13 @@ export function UsersPage() { groups={groupAssignments[user.id] ?? []} hasUserProfile={userProfileSet.has(user.id)} isLast={i === filtered.length - 1} - onViewDetails={() => setDetailUser(user)} + onViewDetails={() => openDetails(user)} onDelete={() => setDeleteTarget(user)} canManage={canManage} + rowRef={(el) => { + if (el) rowRefs.current.set(user.id, el); + else rowRefs.current.delete(user.id); + }} /> ))} {filtered.length === 0 && ( @@ -210,6 +224,7 @@ export function UsersPage() { setDetailUser(null)} canManageRoles={canManageRoles} canManageGroups={canManageGroups} @@ -242,6 +257,7 @@ function UserRow({ onViewDetails, onDelete, canManage, + rowRef, }: t.UserRowProps) { const localize = useLocalize(); @@ -261,8 +277,10 @@ function UserRow({ return ( (null); + const returnFocus = useReturnFocus(open, fallbackRef); + return ( +
+ {showTrigger && ( + + )} +
+ + setOpen(false)} + onCloseAutoFocus={returnFocus} + > + + + +
+ ); +} + +async function openDialog(trigger: HTMLElement): Promise { + trigger.focus(); + fireEvent.click(trigger); + return screen.findByRole('dialog'); +} + +describe('useReturnFocus', () => { + it('returns focus to the trigger when the dialog closes via Escape', async () => { + render(); + const trigger = screen.getByRole('button', { name: 'open dialog' }); + const dialog = await openDialog(trigger); + fireEvent.keyDown(dialog, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('returns focus to the trigger when the dialog closes via the close button', async () => { + render(); + const trigger = screen.getByRole('button', { name: 'open dialog' }); + const dialog = await openDialog(trigger); + const closeButton = within(dialog) + .getAllByRole('button') + .find((button) => button.textContent !== 'inside'); + expect(closeButton).toBeDefined(); + fireEvent.click(closeButton!); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('focuses the fallback when the trigger unmounted before close', async () => { + const { rerender } = render(); + const trigger = screen.getByRole('button', { name: 'open dialog' }); + const dialog = await openDialog(trigger); + rerender(); + fireEvent.keyDown(dialog, { key: 'Escape' }); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + await waitFor(() => expect(screen.getByTestId('fallback')).toHaveFocus()); + expect(document.activeElement).not.toBe(document.body); + }); +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 8991119a..7b32da4e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -7,5 +7,6 @@ export * from './useHighlightRef'; export * from './useLocalize'; export * from './useProfileMutations'; export * from './useReorderVoiceover'; +export * from './useReturnFocus'; export * from './useSearchIndex'; export * from './useStripAriaExpanded'; diff --git a/src/hooks/useReturnFocus.ts b/src/hooks/useReturnFocus.ts new file mode 100644 index 00000000..b0088003 --- /dev/null +++ b/src/hooks/useReturnFocus.ts @@ -0,0 +1,35 @@ +import { useCallback, useRef } from 'react'; +import type { RefObject } from 'react'; + +/** + * Restores focus to the element that was focused when a controlled dialog + * opened. Our dialogs render no Radix `Dialog.Trigger`, so Radix's default + * `onCloseAutoFocus` finds a null `triggerRef` and focus falls to + * `document.body`. Pass the returned callback as `onCloseAutoFocus` on + * `Dialog.Content`. + */ +export function useReturnFocus( + open: boolean, + fallbackRef?: RefObject, +): (event: Event) => void { + const capturedRef = useRef(null); + const wasOpenRef = useRef(false); + /** Captured during render: by effect time Radix's FocusScope has already moved focus into the dialog. */ + if (open && !wasOpenRef.current && typeof document !== 'undefined') { + capturedRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + wasOpenRef.current = open; + return useCallback( + (event: Event) => { + event.preventDefault(); + const captured = capturedRef.current; + if (captured && captured !== document.body && document.contains(captured)) { + captured.focus(); + return; + } + fallbackRef?.current?.focus(); + }, + [fallbackRef], + ); +} diff --git a/src/types/access.ts b/src/types/access.ts index fdb90874..1ce0aac8 100644 --- a/src/types/access.ts +++ b/src/types/access.ts @@ -1,7 +1,7 @@ +import type { RefObject } from 'react'; import type { AdminGroup } from '@librechat/data-schemas'; import type { Role, RolePermissions } from './role'; - export interface AccessPageProps { activeTab: 'groups' | 'roles'; onTabChange: (tab: string) => void; @@ -9,7 +9,6 @@ export interface AccessPageProps { canReadGroups: boolean; } - export interface ConfirmDialogProps { open: boolean; title: string; @@ -39,12 +38,14 @@ export interface CreateRoleDialogProps { export interface EditGroupDialogProps { group: AdminGroup | null; canManage: boolean; + fallbackRef?: RefObject; onClose: () => void; } export interface EditRoleDialogProps { role: Role | null; canManage: boolean; + fallbackRef?: RefObject; onClose: () => void; } @@ -52,7 +53,6 @@ export interface GroupsTabProps { onCreateGroup: () => void; } - export interface RolePermissionsPanelProps { permissions: RolePermissions; onChange: (permissions: RolePermissions) => void; diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index acf9de13..7ab96300 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import type { ConfigScope, IconName, @@ -141,6 +141,7 @@ export interface ConfirmSaveDialogProps { originalValues: FlatConfigMap; saving: boolean; error?: string | null; + fallbackRef?: RefObject; onConfirm: () => void; onCancel: () => void; } @@ -156,6 +157,7 @@ export interface DeleteProfileValueModalProps { scope: ConfigScope | null; fieldLabel: string; saving: boolean; + fallbackRef?: RefObject; onConfirm: (scope: ConfigScope) => void; onCancel: () => void; } @@ -164,6 +166,7 @@ export interface ResetBaseConfigDialogProps { open: boolean; resetting: boolean; error?: string | null; + fallbackRef?: RefObject; onConfirm: () => void; onCancel: () => void; } @@ -272,6 +275,7 @@ export interface FieldRendererProps { export interface ImportYamlDialogProps { open: boolean; + fallbackRef?: RefObject; onClose: () => void; onImport: (appConfig: Record) => void; onImportAsProfile: (appConfig: Record, scope: ConfigScope) => Promise; @@ -326,6 +330,7 @@ export interface ProfileValueModalProps { scopeName: string; scopeType: string; mode: 'edit' | 'add'; + fallbackRef?: RefObject; } export interface ModalValueControlProps { diff --git a/src/types/grant.ts b/src/types/grant.ts index be43f91d..f8409d93 100644 --- a/src/types/grant.ts +++ b/src/types/grant.ts @@ -1,6 +1,6 @@ import type { AdminAuditLogEntry } from '@librechat/data-schemas'; import type { PrincipalType } from 'librechat-data-provider'; -import type { KeyboardEvent } from 'react'; +import type { KeyboardEvent, RefObject } from 'react'; export interface AuditLogEntryWithDiff extends AdminAuditLogEntry { before?: readonly string[]; @@ -25,6 +25,7 @@ export interface EditCapabilitiesDialogProps { principalType: PrincipalType | null; principalId: string | null; principalName: string; + fallbackRef?: RefObject; onClose: () => void; } diff --git a/src/types/layout.ts b/src/types/layout.ts index 7563e578..0c121f37 100644 --- a/src/types/layout.ts +++ b/src/types/layout.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from 'react'; +import type { ReactNode, RefObject } from 'react'; import type { IconName } from './scope'; export interface SidebarProps { @@ -37,5 +37,6 @@ export interface CommandItemProps { export interface SettingsDialogProps { open: boolean; + fallbackRef?: RefObject; onClose: () => void; } diff --git a/src/types/user.ts b/src/types/user.ts index 79362732..9c59f65d 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -1,3 +1,4 @@ +import type { RefObject } from 'react'; import type { TUser } from 'librechat-data-provider'; import type { ConfigScope, IconName } from './scope'; @@ -27,6 +28,7 @@ export interface UserRowProps { onViewDetails: () => void; onDelete: () => void; canManage: boolean; + rowRef: (el: HTMLTableRowElement | null) => void; } export type RemoveTarget = @@ -36,6 +38,7 @@ export type RemoveTarget = export interface UserDetailDialogProps { user: TUser | null; + fallbackRef?: RefObject; onClose: () => void; canManageRoles?: boolean; canManageGroups?: boolean; diff --git a/vitest.config.ts b/vitest.config.ts index c7e8e233..25a36ac3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,11 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], - exclude: ['e2e/**', 'node_modules/**', 'tools/**'], + exclude: ['e2e/**', 'node_modules/**', 'tools/**', '.claude/**'], + server: { + deps: { + inline: ['@clickhouse/click-ui'], + }, + }, }, })