From 4fafa855e87cf4effc3dee9366e04b9fe04d618d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:41:29 -0700 Subject: [PATCH 1/8] chore: bump @clickhouse/click-ui to 0.9.1 Inline @clickhouse/click-ui in vitest server deps and exclude .claude/** from test discovery. click-ui 0.9.1 ships ESM that imports .css files, which Node's loader cannot parse, so Vitest must process the package through Vite. --- bun.lock | 22 +++++----------------- package.json | 2 +- vitest.config.ts | 7 ++++++- 3 files changed, 12 insertions(+), 19 deletions(-) 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/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'], + }, + }, }, }) From 9cb4349cb2f469214b412f04e1c6e8013ce019db Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:23:26 -0700 Subject: [PATCH 2/8] fix: return focus to trigger when dialogs close Every app dialog is a controlled Radix modal rendered without a Dialog.Trigger, so Radix's default onCloseAutoFocus prevents default and tries to focus a triggerRef that only DialogTrigger ever sets. With the ref null, focus restoration is suppressed and focus falls to document.body whenever a dialog closes. A new useReturnFocus hook captures document.activeElement during the render where open flips true (before Radix's FocusScope moves focus into the dialog) and returns an onCloseAutoFocus handler that prevents Radix's default and refocuses the captured element. It is wired into ImportYamlDialog, ConfirmSaveDialog, EditRoleDialog, CreateRoleDialog, EditGroupDialog, CreateGroupDialog, EditCapabilitiesDialog, and the shared FormDialog. ConfirmSaveDialog needs a fallback because a successful save clears the dirty state and unmounts the StickyActionBar that held the Save trigger. ConfigPage now passes a ref to its always-mounted root container (tabIndex -1) so focus has somewhere sensible to land when the captured element is gone. The hook also treats a captured document.body as nothing captured so the fallback applies in browsers that do not focus clicked elements. GrantManagementTab's hand-rolled requestAnimationFrame focus-return workaround covered the same Radix behavior and is migrated to the hook, with the clicked row passed as the fallback target so focus still returns to the row deterministically. --- src/components/access/CreateGroupDialog.tsx | 4 +- src/components/access/CreateRoleDialog.tsx | 4 +- src/components/access/EditGroupDialog.tsx | 4 +- src/components/access/EditRoleDialog.tsx | 4 +- src/components/configuration/ConfigPage.tsx | 9 +- .../configuration/ConfirmSaveDialog.tsx | 12 +- .../configuration/ImportYamlDialog.tsx | 4 +- src/components/focus.test.tsx | 190 ++++++++++++++++++ .../grants/EditCapabilitiesDialog.tsx | 5 +- src/components/grants/GrantManagementTab.tsx | 17 +- src/components/shared/FormDialog.tsx | 10 +- src/hooks/index.ts | 1 + src/hooks/useReturnFocus.test.tsx | 72 +++++++ src/hooks/useReturnFocus.ts | 35 ++++ src/types/config-ui.ts | 3 +- src/types/grant.ts | 3 +- 16 files changed, 349 insertions(+), 28 deletions(-) create mode 100644 src/components/focus.test.tsx create mode 100644 src/hooks/useReturnFocus.test.tsx create mode 100644 src/hooks/useReturnFocus.ts 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..bb4d18e6 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -19,12 +19,13 @@ 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) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!group); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(group?.name ?? ''); @@ -134,6 +135,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..c74f5628 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) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!role); 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/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 6d30330d..4b598558 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)} /> 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/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 0cdd5ae9..2436a37a 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -5,7 +5,7 @@ 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({ @@ -15,6 +15,7 @@ export function ImportYamlDialog({ onImportAsProfile, }: t.ImportYamlDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open); const fileInputRef = useRef(null); const targetRef = useRef(null); @@ -202,6 +203,7 @@ export function ImportYamlDialog({ title={localize('com_config_import_yaml_title')} showClose onClose={handleClose} + onCloseAutoFocus={returnFocus} className="modal-frost" >
diff --git a/src/components/focus.test.tsx b/src/components/focus.test.tsx new file mode 100644 index 00000000..3584932f --- /dev/null +++ b/src/components/focus.test.tsx @@ -0,0 +1,190 @@ +import { 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 } from 'react'; +import type * as t from '@/types'; +import { EditCapabilitiesDialog } from './grants/EditCapabilitiesDialog'; +import { ConfirmSaveDialog } from './configuration/ConfirmSaveDialog'; +import { ImportYamlDialog } from './configuration/ImportYamlDialog'; +import { CreateGroupDialog } from './access/CreateGroupDialog'; +import { CreateRoleDialog } from './access/CreateRoleDialog'; +import { EditGroupDialog } from './access/EditGroupDialog'; +import { EditRoleDialog } from './access/EditRoleDialog'; +import { defaultPermissions } from '@/constants'; +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 () => [] }, + 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); + +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, +}; + +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) => ( + + ), + }, +]; + +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()); + }); +}); diff --git a/src/components/grants/EditCapabilitiesDialog.tsx b/src/components/grants/EditCapabilitiesDialog.tsx index 42662e1c..a33cc0c8 100644 --- a/src/components/grants/EditCapabilitiesDialog.tsx +++ b/src/components/grants/EditCapabilitiesDialog.tsx @@ -7,9 +7,9 @@ import type * as t from '@/types'; import { grantCapabilityFn, principalGrantsQueryOptions, revokeCapabilityFn } from '@/server'; import { getScopeTypeConfig, SystemCapabilities } from '@/constants'; import { cn, notifySuccess, notifyError } from '@/utils'; +import { useLocalize, useReturnFocus } from '@/hooks'; import { CapabilityPanel } from './CapabilityPanel'; import { LoadingState } from '@/components/shared'; -import { useLocalize } from '@/hooks'; function grantsToRecord(grants: AdminSystemGrant[]): Record { 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..eb2b7517 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,9 @@ 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()); - } + const openEditor = (row: t.PrincipalRow, key: string) => { + activeRowRef.current = rowRefs.current.get(key) ?? null; + setEditTarget(row); }; if (grantsLoading) { @@ -96,11 +94,11 @@ 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) => { @@ -126,7 +124,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/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.test.tsx b/src/hooks/useReturnFocus.test.tsx new file mode 100644 index 00000000..bf7a8360 --- /dev/null +++ b/src/hooks/useReturnFocus.test.tsx @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { useRef, useState } from 'react'; +import { Dialog } from '@clickhouse/click-ui'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { useReturnFocus } from './useReturnFocus'; + +function Harness({ showTrigger }: { showTrigger: boolean }) { + const [open, setOpen] = useState(false); + const fallbackRef = useRef(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/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/config-ui.ts b/src/types/config-ui.ts index acf9de13..f740f3b9 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; } 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; } From 386dd1b464a4dd392f792a46288c8755d3dbdbf1 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:33:23 -0700 Subject: [PATCH 3/8] fix: restore focus for remaining dialogs and the edited grant row Pointer activation does not always move focus to the grant table row, so useReturnFocus could capture whatever element was focused before (for example the search box) and restore that instead of the row, regressing the previous close handler which always refocused the edited row. Focusing the row in openEditor before opening makes the hook capture the row itself, so close restores it in every activation mode. SettingsDialog, UserDetailDialog, ResetBaseConfigDialog, ProfileValueModal, DeleteProfileValueModal and ProfileIndicator are controlled dialogs with no Radix trigger, so closing them still dropped focus to document.body. Wire them with the same useReturnFocus onCloseAutoFocus pattern and extend the focus tests to cover the new dialogs and the grant row scenario. --- src/components/SettingsDialog.tsx | 4 +- .../configuration/DeleteProfileValueModal.tsx | 4 +- .../configuration/ProfileIndicator.tsx | 4 +- .../configuration/ProfileValueModal.tsx | 11 +- .../configuration/ResetBaseConfigDialog.tsx | 4 +- src/components/focus.test.tsx | 111 +++++++++++++++++- src/components/grants/GrantManagementTab.tsx | 5 +- src/components/users/UserDetailDialog.tsx | 4 +- 8 files changed, 135 insertions(+), 12 deletions(-) diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index e14d01dc..96d02d8b 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']; @@ -13,6 +13,7 @@ const THEME_LABEL_KEYS: Record = { export function SettingsDialog({ open, onClose }: t.SettingsDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open); 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/configuration/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx index f67ce196..2cdd4026 100644 --- a/src/components/configuration/DeleteProfileValueModal.tsx +++ b/src/components/configuration/DeleteProfileValueModal.tsx @@ -1,7 +1,7 @@ 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, @@ -11,6 +11,7 @@ export function DeleteProfileValueModal({ onCancel, }: t.DeleteProfileValueModalProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!scope); const scopeConfig = scope ? getScopeTypeConfig(scope.principalType) : null; return ( @@ -24,6 +25,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/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..c08b9806 100644 --- a/src/components/configuration/ResetBaseConfigDialog.tsx +++ b/src/components/configuration/ResetBaseConfigDialog.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'; export function ResetBaseConfigDialog({ open, @@ -10,6 +10,7 @@ export function ResetBaseConfigDialog({ onCancel, }: t.ResetBaseConfigDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(open); return (
diff --git a/src/components/focus.test.tsx b/src/components/focus.test.tsx index 3584932f..6a61ac33 100644 --- a/src/components/focus.test.tsx +++ b/src/components/focus.test.tsx @@ -5,14 +5,20 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } 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', () => ({ @@ -25,6 +31,21 @@ vi.mock('@/server', async () => { 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 () => ({ @@ -69,6 +90,19 @@ class ResizeObserverStub implements ResizeObserver { } 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 = { @@ -90,6 +124,14 @@ const testGroup = { 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; @@ -159,6 +201,45 @@ const cases: FocusCase[] = [ /> ), }, + { + 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'] }) { @@ -168,10 +249,12 @@ function CaseHarness({ renderDialog }: { renderDialog: FocusCase['renderDialog'] ); return ( - - {renderDialog(open, () => setOpen(false))} + + + {renderDialog(open, () => setOpen(false))} + ); } @@ -188,3 +271,23 @@ describe('dialog focus return', () => { await waitFor(() => expect(trigger).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/grants/GrantManagementTab.tsx b/src/components/grants/GrantManagementTab.tsx index eb2b7517..efd32677 100644 --- a/src/components/grants/GrantManagementTab.tsx +++ b/src/components/grants/GrantManagementTab.tsx @@ -47,8 +47,11 @@ export function GrantManagementTab() { announce(localize('com_a11y_cap_filter_changed', { count })); }; + /** 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) => { - activeRowRef.current = rowRefs.current.get(key) ?? null; + const rowEl = rowRefs.current.get(key) ?? null; + rowEl?.focus(); + activeRowRef.current = rowEl; setEditTarget(row); }; diff --git a/src/components/users/UserDetailDialog.tsx b/src/components/users/UserDetailDialog.tsx index c218491b..05212e10 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', @@ -44,6 +44,7 @@ export function UserDetailDialog({ canAssignConfigs = false, }: t.UserDetailDialogProps) { const localize = useLocalize(); + const returnFocus = useReturnFocus(!!user); const queryClient = useQueryClient(); const [view, setView] = useState<'main' | 'add'>('main'); @@ -212,6 +213,7 @@ export function UserDetailDialog({ title={dialogTitle} showClose onClose={handleClose} + onCloseAutoFocus={returnFocus} className="modal-frost max-w-lg!" > {user && view === 'main' && ( From ffde702cfff9f7846d578f48a5487ba597b23819 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:35:01 -0700 Subject: [PATCH 4/8] test: move the focus return test under __tests__ Frontend component tests belong in an adjacent __tests__ directory per the repo convention in AGENTS.md, and this file was added directly under src/components in this branch. --- src/components/{ => __tests__}/focus.test.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) rename src/components/{ => __tests__}/focus.test.tsx (90%) diff --git a/src/components/focus.test.tsx b/src/components/__tests__/focus.test.tsx similarity index 90% rename from src/components/focus.test.tsx rename to src/components/__tests__/focus.test.tsx index 6a61ac33..274181e9 100644 --- a/src/components/focus.test.tsx +++ b/src/components/__tests__/focus.test.tsx @@ -5,21 +5,21 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ReactNode } 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 { 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 { EditGroupDialog } from '../access/EditGroupDialog'; +import { EditRoleDialog } from '../access/EditRoleDialog'; import { defaultPermissions } from '@/constants'; -import { SettingsDialog } from './SettingsDialog'; -import { FormDialog } from './shared/FormDialog'; +import { SettingsDialog } from '../SettingsDialog'; +import { FormDialog } from '../shared/FormDialog'; vi.mock('@/hooks/useLocalize', () => ({ default: () => (key: string) => key, From 34867c109a7c99b39ca8d70d260f990ac33a6c6e Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 08:56:01 -0700 Subject: [PATCH 5/8] fix: restore focus for the audit drawer and the reset dialog after its trigger unmounts A successful base reset clears the last database override, which unmounts the header Reset button before the dialog closes, so the reset dialog now receives the same page container fallback ConfirmSaveDialog already uses. The audit detail drawer's four raw Radix content branches had neither a trigger nor onCloseAutoFocus and now share one useReturnFocus wiring, and the hook test moves under __tests__ per the repo convention. --- src/components/configuration/ConfigPage.tsx | 1 + src/components/configuration/ResetBaseConfigDialog.tsx | 3 ++- src/components/grants/AuditLogDetailDrawer.tsx | 7 ++++++- src/hooks/{ => __tests__}/useReturnFocus.test.tsx | 2 +- src/types/config-ui.ts | 1 + 5 files changed, 11 insertions(+), 3 deletions(-) rename src/hooks/{ => __tests__}/useReturnFocus.test.tsx (98%) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index 4b598558..eaaf7988 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -1068,6 +1068,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/ResetBaseConfigDialog.tsx b/src/components/configuration/ResetBaseConfigDialog.tsx index c08b9806..2b0e306d 100644 --- a/src/components/configuration/ResetBaseConfigDialog.tsx +++ b/src/components/configuration/ResetBaseConfigDialog.tsx @@ -6,11 +6,12 @@ export function ResetBaseConfigDialog({ open, resetting, error, + fallbackRef, onConfirm, onCancel, }: t.ResetBaseConfigDialogProps) { const localize = useLocalize(); - const returnFocus = useReturnFocus(open); + const returnFocus = useReturnFocus(open, fallbackRef); return ( 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 +274,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 +327,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 +393,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/hooks/useReturnFocus.test.tsx b/src/hooks/__tests__/useReturnFocus.test.tsx similarity index 98% rename from src/hooks/useReturnFocus.test.tsx rename to src/hooks/__tests__/useReturnFocus.test.tsx index bf7a8360..f57ad183 100644 --- a/src/hooks/useReturnFocus.test.tsx +++ b/src/hooks/__tests__/useReturnFocus.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { useRef, useState } from 'react'; import { Dialog } from '@clickhouse/click-ui'; import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { useReturnFocus } from './useReturnFocus'; +import { useReturnFocus } from '../useReturnFocus'; function Harness({ showTrigger }: { showTrigger: boolean }) { const [open, setOpen] = useState(false); diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index f740f3b9..1b97385b 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -165,6 +165,7 @@ export interface ResetBaseConfigDialogProps { open: boolean; resetting: boolean; error?: string | null; + fallbackRef?: RefObject; onConfirm: () => void; onCancel: () => void; } From 49ec02eb73042beacc80a37d6afaa6a22c219051 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:24:07 -0700 Subject: [PATCH 6/8] fix: give unmounting dialog openers stable focus fallbacks Codex round 3 flagged five dialogs whose captured opener either unmounts before close or never receives focus from pointer activation, so restore landed on document.body or an unrelated filter input. The settings dialog now falls back to the sidebar user menu trigger, the profile value and delete modals fall back to the cascade list container (their scope-selection and per-row buttons unmount on a successful save or removal), and the role and group edit dialogs fall back to their tab containers since a rename can drop the row out of the filtered list. The users page and audit log now mirror GrantManagementTab by focusing the activated row before opening, so the detail dialog and drawer capture the row instead of whatever was focused before, and the user rows also thread that row as the dialog fallback for the kebab menu path. A parametrized fallback test covers the new prop threading. --- src/components/SettingsDialog.tsx | 4 +- src/components/Sidebar.tsx | 12 +- src/components/__tests__/focus.test.tsx | 119 +++++++++++++++++- src/components/access/EditGroupDialog.tsx | 9 +- src/components/access/EditRoleDialog.tsx | 4 +- src/components/access/GroupsTab.tsx | 9 +- src/components/access/RolesTab.tsx | 11 +- .../configuration/DeleteProfileValueModal.tsx | 3 +- .../configuration/FieldProfilePopover.tsx | 12 +- .../configuration/ProfileValueModal.tsx | 3 +- src/components/grants/AuditLogTab.tsx | 15 ++- src/components/users/UserDetailDialog.tsx | 3 +- src/components/users/UsersPage.tsx | 23 +++- src/types/access.ts | 6 +- src/types/config-ui.ts | 2 + src/types/layout.ts | 3 +- src/types/user.ts | 3 + 17 files changed, 215 insertions(+), 26 deletions(-) diff --git a/src/components/SettingsDialog.tsx b/src/components/SettingsDialog.tsx index 96d02d8b..c7003752 100644 --- a/src/components/SettingsDialog.tsx +++ b/src/components/SettingsDialog.tsx @@ -11,9 +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); + const returnFocus = useReturnFocus(open, fallbackRef); const { theme, setTheme } = useTheme(); return ( 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 index 274181e9..c13b9e03 100644 --- a/src/components/__tests__/focus.test.tsx +++ b/src/components/__tests__/focus.test.tsx @@ -1,9 +1,9 @@ -import { useState } from 'react'; +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 } from 'react'; +import type { ReactNode, RefObject } from 'react'; import type * as t from '@/types'; import { DeleteProfileValueModal } from '../configuration/DeleteProfileValueModal'; import { ResetBaseConfigDialog } from '../configuration/ResetBaseConfigDialog'; @@ -272,6 +272,121 @@ describe('dialog focus return', () => { }); }); +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 } } }); diff --git a/src/components/access/EditGroupDialog.tsx b/src/components/access/EditGroupDialog.tsx index bb4d18e6..dedb4083 100644 --- a/src/components/access/EditGroupDialog.tsx +++ b/src/components/access/EditGroupDialog.tsx @@ -23,9 +23,14 @@ 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); + const returnFocus = useReturnFocus(!!group, fallbackRef); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(group?.name ?? ''); diff --git a/src/components/access/EditRoleDialog.tsx b/src/components/access/EditRoleDialog.tsx index c74f5628..cb62a4a8 100644 --- a/src/components/access/EditRoleDialog.tsx +++ b/src/components/access/EditRoleDialog.tsx @@ -26,9 +26,9 @@ 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); + const returnFocus = useReturnFocus(!!role, fallbackRef); const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState('details'); const [name, setName] = useState(role?.name ?? ''); 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/DeleteProfileValueModal.tsx b/src/components/configuration/DeleteProfileValueModal.tsx index 2cdd4026..95f511f2 100644 --- a/src/components/configuration/DeleteProfileValueModal.tsx +++ b/src/components/configuration/DeleteProfileValueModal.tsx @@ -5,13 +5,14 @@ import { getScopeTypeConfig } from '@/constants'; export function DeleteProfileValueModal({ scope, + fallbackRef, fieldLabel, saving, onConfirm, onCancel, }: t.DeleteProfileValueModalProps) { const localize = useLocalize(); - const returnFocus = useReturnFocus(!!scope); + const returnFocus = useReturnFocus(!!scope, fallbackRef); const scopeConfig = scope ? getScopeTypeConfig(scope.principalType) : null; return ( 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/ProfileValueModal.tsx b/src/components/configuration/ProfileValueModal.tsx index e0debeef..9ef4d56e 100644 --- a/src/components/configuration/ProfileValueModal.tsx +++ b/src/components/configuration/ProfileValueModal.tsx @@ -21,9 +21,10 @@ export function ProfileValueModal({ scopeName, scopeType, mode, + fallbackRef, }: t.ProfileValueModalProps) { const localize = useLocalize(); - const returnFocus = useReturnFocus(open); + const returnFocus = useReturnFocus(open, fallbackRef); const scopeConfig = getScopeTypeConfig(scopeType as PrincipalType | 'BASE'); const title = diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index ce2021e5..66f9b357 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -113,6 +113,7 @@ export function AuditLogTab() { const [currentPage, setCurrentPage] = useState(1); const { message: announcement, announce } = useAnnouncement(); + const rowRefs = useRef>(new Map()); const resetToFirstPage = useCallback(() => setCurrentPage(1), []); const searchFilter = useDebouncedFilter('', resetToFirstPage); @@ -287,8 +288,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 +317,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; @@ -579,6 +586,9 @@ export function AuditLogTab() { isLast={i === pageEntries.length - 1} onActivate={() => openEntry(entry.id)} onKeyDown={(e) => handleRowKeyDown(e, entry.id)} + rowRef={(el) => { + if (el) rowRefs.current.set(entry.id, el); + }} localize={localize} /> ))} @@ -655,18 +665,21 @@ function AuditLogTableRow({ isLast, onActivate, onKeyDown, + rowRef, localize, }: { entry: t.AuditLogEntryWithDiff; isLast: boolean; onActivate: () => 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 ( = { */ export function UserDetailDialog({ user, + fallbackRef, onClose, canManageRoles = false, canManageGroups = false, canAssignConfigs = false, }: t.UserDetailDialogProps) { const localize = useLocalize(); - const returnFocus = useReturnFocus(!!user); + const returnFocus = useReturnFocus(!!user, fallbackRef); const queryClient = useQueryClient(); const [view, setView] = useState<'main' | 'add'>('main'); diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx index fa1158fc..6a8c8ee0 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,12 @@ 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); + }} /> ))} {filtered.length === 0 && ( @@ -210,6 +223,7 @@ export function UsersPage() { setDetailUser(null)} canManageRoles={canManageRoles} canManageGroups={canManageGroups} @@ -242,6 +256,7 @@ function UserRow({ onViewDetails, onDelete, canManage, + rowRef, }: t.UserRowProps) { const localize = useLocalize(); @@ -261,8 +276,10 @@ function UserRow({ return ( 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 1b97385b..bb5104f2 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -157,6 +157,7 @@ export interface DeleteProfileValueModalProps { scope: ConfigScope | null; fieldLabel: string; saving: boolean; + fallbackRef?: RefObject; onConfirm: (scope: ConfigScope) => void; onCancel: () => void; } @@ -328,6 +329,7 @@ export interface ProfileValueModalProps { scopeName: string; scopeType: string; mode: 'edit' | 'add'; + fallbackRef?: RefObject; } export interface ModalValueControlProps { 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; From 04c9d92938a02e88954fbba2ceaaee1f4faf6cf6 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:39:19 -0700 Subject: [PATCH 7/8] fix: clean up row ref maps, show restored row focus, and cover cold permalinks The row ref callbacks now delete entries when rows unmount so paginated audit and user browsing does not accumulate detached table rows. The users row gets the same focus-visible treatment as the grant and audit rows so restored keyboard focus is visible, and the audit drawer takes the tab container as a fallback for cold entryId permalinks where no opener was ever focused. --- src/components/grants/AuditLogDetailDrawer.tsx | 7 +++++-- src/components/grants/AuditLogTab.tsx | 9 ++++++++- src/components/grants/GrantManagementTab.tsx | 1 + src/components/users/UsersPage.tsx | 3 ++- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/grants/AuditLogDetailDrawer.tsx b/src/components/grants/AuditLogDetailDrawer.tsx index 226606a1..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, @@ -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,9 +135,10 @@ export function AuditLogDetailDrawer({ notFound = false, loading = false, loadError = false, + fallbackRef, }: AuditLogDetailDrawerProps): ReactElement | null { const localize = useLocalize(); - const returnFocus = useReturnFocus(open); + 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 diff --git a/src/components/grants/AuditLogTab.tsx b/src/components/grants/AuditLogTab.tsx index 66f9b357..e8b703fc 100644 --- a/src/components/grants/AuditLogTab.tsx +++ b/src/components/grants/AuditLogTab.tsx @@ -114,6 +114,7 @@ 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); @@ -392,7 +393,11 @@ export function AuditLogTab() { const exportLabel = localize('com_audit_export_server'); return ( -
+
handleRowKeyDown(e, entry.id)} rowRef={(el) => { if (el) rowRefs.current.set(entry.id, el); + else rowRefs.current.delete(entry.id); }} localize={localize} /> @@ -632,6 +638,7 @@ export function AuditLogTab() { { if (el) rowRefs.current.set(key, el); + else rowRefs.current.delete(key); }} /> ); diff --git a/src/components/users/UsersPage.tsx b/src/components/users/UsersPage.tsx index 6a8c8ee0..ba863a23 100644 --- a/src/components/users/UsersPage.tsx +++ b/src/components/users/UsersPage.tsx @@ -199,6 +199,7 @@ export function UsersPage() { canManage={canManage} rowRef={(el) => { if (el) rowRefs.current.set(user.id, el); + else rowRefs.current.delete(user.id); }} /> ))} @@ -279,7 +280,7 @@ function UserRow({ ref={rowRef} tabIndex={-1} className={cn( - 'cursor-pointer bg-(--cui-color-background-panel) transition-colors outline-none hover:bg-(--cui-color-background-hover)', + 'cursor-pointer bg-(--cui-color-background-panel) transition-colors outline-none hover:bg-(--cui-color-background-hover) focus-visible:bg-(--cui-color-background-hover) focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-(--cui-color-outline)', !isLast && 'border-b border-(--cui-color-stroke-default)', )} onClick={onViewDetails} From 69ccc5ebcb0a8ccbf005482ce36784e6ca47af96 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 14 Aug 2026 09:40:36 -0700 Subject: [PATCH 8/8] fix: give the import dialog the page container fallback In browsers where pointer activation does not focus the Import button the hook captures document.body, which it rejects on close, so without a fallback focus stayed on the body. The import dialog now takes the same page container fallback the save and reset dialogs use. --- src/components/configuration/ConfigPage.tsx | 1 + src/components/configuration/ImportYamlDialog.tsx | 3 ++- src/types/config-ui.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/configuration/ConfigPage.tsx b/src/components/configuration/ConfigPage.tsx index eaaf7988..b48efc21 100644 --- a/src/components/configuration/ConfigPage.tsx +++ b/src/components/configuration/ConfigPage.tsx @@ -1059,6 +1059,7 @@ export function ConfigPage({ initialTab, highlightField, initialScope }: t.Confi setImportOpen(false)} onImport={handleImport} onImportAsProfile={handleImportAsProfile} diff --git a/src/components/configuration/ImportYamlDialog.tsx b/src/components/configuration/ImportYamlDialog.tsx index 2436a37a..425145c9 100644 --- a/src/components/configuration/ImportYamlDialog.tsx +++ b/src/components/configuration/ImportYamlDialog.tsx @@ -10,12 +10,13 @@ import { cn } from '@/utils'; export function ImportYamlDialog({ open, + fallbackRef, onClose, onImport, onImportAsProfile, }: t.ImportYamlDialogProps) { const localize = useLocalize(); - const returnFocus = useReturnFocus(open); + const returnFocus = useReturnFocus(open, fallbackRef); const fileInputRef = useRef(null); const targetRef = useRef(null); diff --git a/src/types/config-ui.ts b/src/types/config-ui.ts index bb5104f2..7ab96300 100644 --- a/src/types/config-ui.ts +++ b/src/types/config-ui.ts @@ -275,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;