diff --git a/src/components/LoginModal.tsx b/src/components/LoginModal.tsx index 14bad9dc8..97f6f3af3 100644 --- a/src/components/LoginModal.tsx +++ b/src/components/LoginModal.tsx @@ -7,17 +7,20 @@ import { DialogContent, DialogHeader, } from '~/components/ds/ui' +import type { RestoreFocusRef } from '~/components/ds/ui/Dialog' interface LoginModalProps { open: boolean description?: string onOpenChange: (open: boolean) => void + restoreFocusRef?: RestoreFocusRef } export function LoginModal({ open, description, onOpenChange, + restoreFocusRef, }: LoginModalProps) { const openSocialPopup = (provider: 'github' | 'google') => { const popup = authClient.signIn.socialPopup({ provider }) @@ -29,7 +32,7 @@ export function LoginModal({ return ( - +
diff --git a/src/components/ds/ui/Dialog.tsx b/src/components/ds/ui/Dialog.tsx index 88e86077f..bc8e133dd 100644 --- a/src/components/ds/ui/Dialog.tsx +++ b/src/components/ds/ui/Dialog.tsx @@ -23,6 +23,31 @@ import { twMerge } from 'tailwind-merge' * * *
+ * + * FOCUS RESTORATION CONTRACT + * + * On close, focus must return to the control that opened the dialog. + * + * 1. One colocated opener -> wrap it in . + * Radix registers it and restores focus itself. + * + * 2. External or dynamic opener (a parent component, a table row, …) + * -> pass `restoreFocusRef` to . Without a + * DialogTrigger, Radix's internal trigger ref is null and focus would + * fall to . + * + * // Parent-owned opener + * const openerRef = React.useRef(null) + * + * + * + * + * + * // One opener per table row: record whichever row was clicked + * const openerRef = React.useRef(null) + * */ export const Dialog = DialogPrimitive.Root @@ -39,19 +64,74 @@ const sizeStyles: Record = { xl: 'max-w-xl', } +/* ------------------------------------------------------- focus restoration -- */ + +// Type-only export: erased at build time, so it is safe for Fast Refresh. +export type RestoreFocusRef = React.RefObject + +/** + * Builds an `onCloseAutoFocus` handler for Radix modal content. + * + * Radix's own close handler calls `preventDefault()` and then focuses + * `context.triggerRef.current`, which is null when no was + * rendered. Radix composes the caller's handler first and skips its own when + * the event is default-prevented, so we take over only when we hold a live + * element to restore to. Otherwise Radix's trigger logic still runs. + * + * NOT exported on purpose: exporting a plain function from a component file + * breaks React Fast Refresh. + */ +function createCloseAutoFocus( + restoreFocusRef: RestoreFocusRef | undefined, + userHandler?: (event: Event) => void, +) { + return (event: Event) => { + userHandler?.(event) + if (event.defaultPrevented) return + + const el = restoreFocusRef?.current + // `isConnected` guards against a stale node (e.g. a table row removed by + // the action the dialog just confirmed). + if (el && el.isConnected) { + event.preventDefault() // skip Radix's null-trigger focus + el.focus() + } + } +} + +/* ------------------------------------------------------------ DialogContent -- */ + type DialogContentProps = { children: React.ReactNode size?: DialogSize className?: string /** Escape hatch for content that manages its own dismissal (e.g. a wizard mid-submit). */ onInteractOutside?: DialogPrimitive.DialogContentProps['onInteractOutside'] + /** + * Element to focus when the dialog closes. Required for controlled dialogs + * that have no (external or dynamic openers). Not needed + * when a colocated opens the dialog. + */ + restoreFocusRef?: RestoreFocusRef + /** + * Runs before focus restoration. Call `event.preventDefault()` to take full + * control of where focus goes; restoreFocusRef is then ignored. + */ + onCloseAutoFocus?: DialogPrimitive.DialogContentProps['onCloseAutoFocus'] } export const DialogContent = React.forwardRef< HTMLDivElement, DialogContentProps >(function DialogContent( - { children, size = 'sm', className, onInteractOutside }, + { + children, + size = 'sm', + className, + onInteractOutside, + restoreFocusRef, + onCloseAutoFocus, + }, ref, ) { return ( @@ -64,6 +144,10 @@ export const DialogContent = React.forwardRef< ref={ref} data-ds-dialog-panel="" onInteractOutside={onInteractOutside} + onCloseAutoFocus={createCloseAutoFocus( + restoreFocusRef, + onCloseAutoFocus, + )} className={twMerge( // Centring uses the independent `translate` property (that is what // Tailwind v4 compiles these to), which leaves `transform` free for @@ -89,6 +173,8 @@ export const DialogContent = React.forwardRef< ) }) +/* ------------------------------------------------------------ DialogHeader -- */ + type DialogHeaderProps = { title: React.ReactNode /** diff --git a/src/components/npm-stats/BaselineSection.tsx b/src/components/npm-stats/BaselineSection.tsx index b2132ce3e..25a6f2dbc 100644 --- a/src/components/npm-stats/BaselineSection.tsx +++ b/src/components/npm-stats/BaselineSection.tsx @@ -21,6 +21,7 @@ import { DialogBody, DialogContent, DialogHeader, + DialogTrigger, } from '~/components/ds/ui' import { PackageSearch } from './PackageSearch' import { getBaselineDisplayName, type PackageGroup } from './shared' @@ -142,14 +143,16 @@ export function BaselineSection({ const addButton = ( - + > + + Add + + ) @@ -240,7 +243,7 @@ export function BaselineSection({ ) return ( - <> +
{labelButton} @@ -256,29 +259,27 @@ export function BaselineSection({
- - - - - Add baseline package - - } + + + + Add baseline package + + } + /> + + { + onAddBaseline(pkg) + setShowSearch(false) + }} + placeholder="Search for baseline package..." + // eslint-disable-next-line jsx-a11y/no-autofocus + autoFocus={true} /> - - { - onAddBaseline(pkg) - setShowSearch(false) - }} - placeholder="Search for baseline package..." - // eslint-disable-next-line jsx-a11y/no-autofocus - autoFocus={true} - /> - - - - + +
+ ) } diff --git a/src/contexts/LoginModalContext.tsx b/src/contexts/LoginModalContext.tsx index 2280c6484..6a76516ff 100644 --- a/src/contexts/LoginModalContext.tsx +++ b/src/contexts/LoginModalContext.tsx @@ -45,8 +45,16 @@ export function LoginModalProvider({ children }: LoginModalProviderProps) { const [description, setDescription] = React.useState() const pendingOnSuccessRef = React.useRef<(() => void) | undefined>(undefined) + const openerRef = React.useRef(null) + const openLoginModal = React.useCallback( (options?: { description?: string; onSuccess?: () => void }) => { + const active = document.activeElement + openerRef.current = + active instanceof HTMLElement && active !== document.body + ? active + : null + pendingOnSuccessRef.current = options?.onSuccess setDescription(options?.description) setHasLoadedModal(true) @@ -100,6 +108,7 @@ export function LoginModalProvider({ children }: LoginModalProviderProps) { open={isOpen} description={description} onOpenChange={handleOpenChange} + restoreFocusRef={openerRef} /> ) : null} diff --git a/src/routes/admin/roles.$roleId.tsx b/src/routes/admin/roles.$roleId.tsx index c8a663e83..c3ffde2c7 100644 --- a/src/routes/admin/roles.$roleId.tsx +++ b/src/routes/admin/roles.$roleId.tsx @@ -1,5 +1,5 @@ import { Link, redirect, createFileRoute } from '@tanstack/react-router' -import { useState, useMemo, useCallback } from 'react' +import { useState, useMemo, useCallback, useRef } from 'react' import { useRemoveUsersFromRole } from '~/utils/mutations' import { useQuery } from '@tanstack/react-query' import { getRole, getUsersWithRole } from '~/utils/roles.functions' @@ -47,6 +47,9 @@ function RoleDetailPage() { name: string } | null>(null) + const openerRef = useRef(null) + const tableFocusRef = useRef(null) + const userQuery = useCurrentUserQuery() const user = userQuery.data const roleQuery = useQuery({ @@ -200,12 +203,16 @@ function RoleDetailPage() { header: 'Actions', cell: ({ row }) => { const user = row.original + const name = user.name || user.email return (