From f32bf7374c79f3e0742487b8dcce86b35b3c29de Mon Sep 17 00:00:00 2001 From: Koji Wakayama Date: Fri, 24 Jul 2026 07:30:42 +0200 Subject: [PATCH 1/3] refactor(react): one useDisclosure and shared Modal/Anchored surfaces for overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the byte-identical controlled/uncontrolled open-state block from Dialog, Drawer, Popover, DropdownMenu, and Collapsible into a single useDisclosure hook (disclosure.ts). Introduce two internal shared surfaces: - modal-surface.tsx — ModalContext, useModal, useModalContentEffect, ModalTrigger, ModalClose, ModalContent; used by Dialog and Drawer as skins. - anchored-surface.tsx — AnchoredContext, AnchoredRoot, AnchoredTrigger, AnchoredContent; used by Popover and DropdownMenu as skins. Consolidate the TODO(a11y) blocks to the two surface files (one site for future focus-trap / scroll-lock / portal work). Public component APIs, rendered DOM, and class strings are unchanged. All ratchets (forwardRef 0/0, feature-toggle 0/0, passthrough 0/0, inline-context 0/0) and 131 react tests pass unmodified. Net: 971 LOC across 8 files vs 976 LOC in the original 5 files (−5). Note: the behavioral machinery itself (~254 lines in 3 new files) is non-trivial; Dialog/Drawer each save ~73 lines, Popover/DropdownMenu ~55 each, Collapsible ~5, totalling 259 lines removed from skins. --- src/react/components/ui/anchored-surface.tsx | 105 ++++++++++++++ src/react/components/ui/collapsible.tsx | 11 +- src/react/components/ui/dialog.tsx | 137 +++++-------------- src/react/components/ui/disclosure.ts | 27 ++++ src/react/components/ui/drawer.tsx | 127 ++++------------- src/react/components/ui/dropdown-menu.tsx | 89 +++--------- src/react/components/ui/modal-surface.tsx | 122 +++++++++++++++++ src/react/components/ui/popover.tsx | 83 ++--------- 8 files changed, 348 insertions(+), 353 deletions(-) create mode 100644 src/react/components/ui/anchored-surface.tsx create mode 100644 src/react/components/ui/disclosure.ts create mode 100644 src/react/components/ui/modal-surface.tsx diff --git a/src/react/components/ui/anchored-surface.tsx b/src/react/components/ui/anchored-surface.tsx new file mode 100644 index 0000000000..3a5e2e867a --- /dev/null +++ b/src/react/components/ui/anchored-surface.tsx @@ -0,0 +1,105 @@ +/** + * Shared behavioral machinery for Popover and DropdownMenu. + * TODO(a11y): focus trap, portal + collision-aware positioning (flip/shift), + * aria-controls/aria-expanded, side/align offsets. + * DropdownMenu: roving focus, typeahead, Tab, aria-activedescendant, sub menus. + * @module react/components/ui/anchored-surface + */ +import * as React from "react"; +import { cx as cn } from "./cva.ts"; +import { Slot } from "./slot.tsx"; +import { Floating } from "./floating.tsx"; +import { type DisclosureOptions, useDisclosure } from "./disclosure.ts"; + +/** Context value shared between an anchored skin's Root and its parts. */ +export interface AnchoredState { + open: boolean; + setOpen: (open: boolean) => void; + anchorRef: React.RefObject; +} + +/** Context for Popover and DropdownMenu skins. */ +export const AnchoredContext = React.createContext(null); + +/** + * Anchor `` + disclosure state + context provider. + * Both `` and `` delegate here — the span is the + * positioning anchor for `Floating`. + */ +export function AnchoredRoot( + { children, open, defaultOpen, onOpenChange }: DisclosureOptions & { children: React.ReactNode }, +): React.ReactElement { + const { open: isOpen, setOpen } = useDisclosure({ open, defaultOpen, onOpenChange }); + const anchorRef = React.useRef(null); + const ctx = React.useMemo(() => ({ open: isOpen, setOpen, anchorRef }), [isOpen, setOpen]); + return ( + + + {children} + + + ); +} + +/** Props for `AnchoredTrigger`. */ +export interface AnchoredTriggerProps extends React.ButtonHTMLAttributes { + asChild?: boolean; + /** `aria-haspopup` value — `"dialog"` for Popover, `"menu"` for DropdownMenu. */ + haspopup: NonNullable; +} + +/** + * Toggle trigger for anchored surfaces. Sets `aria-haspopup`/`aria-expanded`; + * skins differ only in the `haspopup` value they pass. + */ +export function AnchoredTrigger( + { children, asChild, onClick, haspopup, ...props }: AnchoredTriggerProps, +): React.ReactElement { + const ctx = React.useContext(AnchoredContext); + const Comp = asChild ? Slot : "button"; + return ( + ) => { + onClick?.(e); + ctx?.setOpen(!ctx.open); + }} + {...props} + > + {children} + + ); +} + +/** Props for `AnchoredContent`. */ +export interface AnchoredContentProps extends React.HTMLAttributes { + align?: "start" | "end"; +} + +/** + * `Floating` wrapper with base anchored-surface classes. + * Skins extend via `className` (min-width, padding) and `role`. + */ +export function AnchoredContent( + { children, className, align, ...props }: AnchoredContentProps, +): React.ReactElement | null { + const ctx = React.useContext(AnchoredContext); + if (!ctx) return null; + return ( + ctx.setOpen(false)} + className={cn( + "z-50 overflow-hidden rounded-lg bg-[var(--popover)] text-[var(--foreground)] shadow-sm outline-none", + className, + )} + {...props} + > + {children} + + ); +} diff --git a/src/react/components/ui/collapsible.tsx b/src/react/components/ui/collapsible.tsx index 881d6a3396..4c16bace52 100644 --- a/src/react/components/ui/collapsible.tsx +++ b/src/react/components/ui/collapsible.tsx @@ -11,6 +11,7 @@ */ import * as React from "react"; import { Slot } from "./slot.tsx"; +import { useDisclosure } from "./disclosure.ts"; const CollapsibleContext = React.createContext< { open: boolean; toggle: () => void; disabled?: boolean } | null @@ -33,14 +34,8 @@ export function Collapsible({ children, ...props }: CollapsibleProps): React.ReactElement { - const [internal, setInternal] = React.useState(defaultOpen ?? false); - const isControlled = open !== undefined; - const isOpen = isControlled ? open : internal; - const toggle = React.useCallback(() => { - const next = !isOpen; - if (!isControlled) setInternal(next); - onOpenChange?.(next); - }, [isOpen, isControlled, onOpenChange]); + const { open: isOpen, setOpen } = useDisclosure({ open, defaultOpen, onOpenChange }); + const toggle = React.useCallback(() => setOpen(!isOpen), [isOpen, setOpen]); return (
diff --git a/src/react/components/ui/dialog.tsx b/src/react/components/ui/dialog.tsx index b9cfa57599..3717e573d5 100644 --- a/src/react/components/ui/dialog.tsx +++ b/src/react/components/ui/dialog.tsx @@ -3,28 +3,22 @@ * Trigger / Content + Header / Title / Description / Body / Footer / Action / * Cancel / Close / Form). Classes ported 1:1 from Studio's `Dialog` (tokens * remapped; `Heading` level 2 + `Text` inlined). Modal overlay + centered panel; - * dismisses on `Escape` and overlay click. - * - * TODO(a11y): focus trap + restore, `aria-labelledby`/`aria-describedby` wiring, - * scroll-lock, portal, enter/exit animation. Private to the chat module. + * dismisses on `Escape` and overlay click. A11y work tracked in modal-surface.tsx. * * @module react/components/ui/dialog */ import * as React from "react"; import { cx as cn } from "./cva.ts"; -import { Slot } from "./slot.tsx"; import { ScrollFade } from "./scroll-fade.tsx"; import { Button, type ButtonProps, LoadingButton } from "./button.tsx"; - -const DialogContext = React.createContext< - { open: boolean; setOpen: (open: boolean) => void } | null ->(null); - -function useDialog() { - const ctx = React.useContext(DialogContext); - if (!ctx) throw new Error("Dialog parts must be used within "); - return ctx; -} +import { useDisclosure } from "./disclosure.ts"; +import { + ModalClose, + ModalContent, + ModalContext, + ModalTrigger, + useModal, +} from "./modal-surface.tsx"; /** Props accepted by ``. */ export interface DialogProps { @@ -41,41 +35,20 @@ export function Dialog({ defaultOpen, onOpenChange, }: DialogProps): React.ReactElement { - const [internal, setInternal] = React.useState(defaultOpen ?? false); - const isControlled = open !== undefined; - const isOpen = isControlled ? open : internal; - const setOpen = React.useCallback((next: boolean) => { - if (!isControlled) setInternal(next); - onOpenChange?.(next); - }, [isControlled, onOpenChange]); + const { open: isOpen, setOpen } = useDisclosure({ open, defaultOpen, onOpenChange }); + const ctx = React.useMemo(() => ({ open: isOpen, setOpen }), [isOpen, setOpen]); return ( - + {children} - + ); } /** Trigger — opens the dialog. `asChild` merges onto the child element. */ -export function DialogTrigger({ - children, - asChild, - onClick, - ...props -}: React.ButtonHTMLAttributes & { asChild?: boolean }): React.ReactElement { - const ctx = useDialog(); - const Comp = asChild ? Slot : "button"; - return ( - ) => { - onClick?.(e); - ctx.setOpen(true); - }} - {...props} - > - {children} - - ); +export function DialogTrigger( + props: React.ButtonHTMLAttributes & { asChild?: boolean }, +): React.ReactElement { + return ; } /** Modal surface — overlay + centered panel, rendered while open. */ @@ -84,47 +57,17 @@ export function DialogContent({ children, ...props }: React.HTMLAttributes): React.ReactElement | null { - const ctx = useDialog(); - const panelRef = React.useRef(null); - - React.useEffect(() => { - if (!ctx.open) return; - const onKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") ctx.setOpen(false); - }; - document.addEventListener("keydown", onKeyDown); - // Focus the first focusable descendant on open (radix-like) — e.g. a - // CommandInput — falling back to the panel itself. Full focus-trap is TODO. - const panel = panelRef.current; - const focusable = panel?.querySelector( - 'input:not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', - ); - (focusable ?? panel)?.focus(); - return () => document.removeEventListener("keydown", onKeyDown); - }, [ctx.open]); - - if (!ctx.open) return null; return ( -
-
ctx.setOpen(false)} - /> -
- {children} -
-
+ + {children} + ); } @@ -231,7 +174,7 @@ export function DialogCancel({ onClick, ...props }: ButtonProps): React.ReactElement { - const ctx = useDialog(); + const ctx = useModal("Dialog"); return (