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 = (
-
+
)
@@ -240,7 +243,7 @@ export function BaselineSection({
)
return (
- <>
+
-