diff --git a/src/react/components/ui/anchored-surface.test.tsx b/src/react/components/ui/anchored-surface.test.tsx new file mode 100644 index 0000000000..080854162c --- /dev/null +++ b/src/react/components/ui/anchored-surface.test.tsx @@ -0,0 +1,54 @@ +import { renderToString } from "react-dom/server"; +import { assert, assertEquals, assertStringIncludes } from "#veryfront/testing/assert"; +import { describe, it } from "#veryfront/testing/bdd"; +import { Popover, PopoverTrigger } from "./popover.tsx"; +import { DropdownMenu, DropdownMenuTrigger } from "./dropdown-menu.tsx"; + +describe("anchored surfaces anchor to the trigger ref", () => { + it("Popover root renders no wrapper node", () => { + const html = renderToString( + + Open + , + ); + + // The trigger button is the outermost markup - no anchor wrapper. + assert( + html.startsWith(" { + const html = renderToString( + + Open + , + ); + + assert( + html.startsWith(" { + const html = renderToString( + + + Open + + , + ); + + assert( + html.startsWith(" { asChild?: boolean; + /** Composed with the internal positioning-anchor ref. */ + ref?: React.Ref; /** `aria-haspopup` value -- `"dialog"` for Popover, `"menu"` for DropdownMenu. */ haspopup: NonNullable; } @@ -44,8 +46,9 @@ export function createAnchoredSurfaceParts() { const Context = React.createContext(null); /** - * Anchor `` + disclosure state + context provider. - * The span is the positioning anchor for `Floating`. + * Disclosure state + context provider. Renders no node of its own - the + * positioning anchor for `Floating` is the trigger element itself, carried + * on context as `anchorRef` and attached by `AnchoredTrigger`. */ function AnchoredRoot( { children, open, defaultOpen, onOpenChange }: DisclosureOptions & { @@ -56,20 +59,25 @@ export function createAnchoredSurfaceParts() { const anchorRef = React.useRef(null); const ctx = React.useMemo(() => ({ open: isOpen, setOpen, anchorRef }), [isOpen, setOpen]); return ( - - - {children} - - + + {children} + ); } /** * Toggle trigger. Sets `aria-haspopup` and `aria-expanded`; toggles open on - * click. Skins differ only in the `haspopup` value they supply. + * click; carries the positioning-anchor ref (composed with any consumer + * `ref`, including through `asChild`). Skins differ only in the `haspopup` + * value they supply. + * + * `asChild` contract: the child must forward `ref` to its DOM node (every + * `ui` component does; refs pass as regular props on function components in + * React 19). A child that drops `ref` leaves the surface unanchored — + * `Floating` warns in that case instead of silently rendering nothing. */ function AnchoredTrigger( - { children, asChild, onClick, haspopup, ...props }: AnchoredTriggerProps, + { children, asChild, onClick, haspopup, ref, ...props }: AnchoredTriggerProps, ): React.ReactElement { const ctx = React.useContext(Context); const Comp = asChild ? Slot : "button"; @@ -78,6 +86,10 @@ export function createAnchoredSurfaceParts() { {...(asChild ? {} : { type: "button" as const })} aria-haspopup={haspopup} aria-expanded={ctx?.open} + ref={composeRefs( + ctx?.anchorRef as React.Ref | undefined, + ref, + )} onClick={(e: React.MouseEvent) => { onClick?.(e); // Guard ctx before reading ctx.open (trigger may render outside a Root). diff --git a/src/react/components/ui/dropdown-menu.tsx b/src/react/components/ui/dropdown-menu.tsx index 2ce335dcae..881d467f43 100644 --- a/src/react/components/ui/dropdown-menu.tsx +++ b/src/react/components/ui/dropdown-menu.tsx @@ -31,9 +31,15 @@ export function DropdownMenu(props: DropdownMenuProps): React.ReactElement { return <_Root {...props} />; } -/** Trigger — toggles the menu. `asChild` merges onto the child element. */ +/** + * Trigger — toggles the menu; the positioning anchor. `asChild` merges onto + * the child element, which must forward `ref` to its DOM node. + */ export function DropdownMenuTrigger( - props: React.ButtonHTMLAttributes & { asChild?: boolean }, + props: React.ButtonHTMLAttributes & { + asChild?: boolean; + ref?: React.Ref; + }, ): React.ReactElement { return <_Trigger {...props} haspopup="menu" />; } diff --git a/src/react/components/ui/floating.tsx b/src/react/components/ui/floating.tsx index 240ebcc733..33756b249c 100644 --- a/src/react/components/ui/floating.tsx +++ b/src/react/components/ui/floating.tsx @@ -15,9 +15,12 @@ import * as React from "react"; import { createPortal } from "react-dom"; import { UI_SCOPE_SELECTOR } from "./design-tokens.ts"; +// Warn once per session, not per render, when a surface opens unanchored. +let warnedMissingAnchor = false; + /** Props accepted by ``. */ export interface FloatingProps extends React.HTMLAttributes { - /** Element the surface is positioned against (usually the trigger wrapper). */ + /** Element the surface is positioned against (usually the trigger element). */ anchorRef: React.RefObject; open: boolean; /** Horizontal edge to align to. */ @@ -57,6 +60,13 @@ export function Floating({ React.useLayoutEffect(() => { if (!open) return; const update = () => { + if (anchorRef.current === null && !warnedMissingAnchor) { + warnedMissingAnchor = true; + console.warn( + "[ui] Floating surface opened without an anchor element. " + + "If the trigger uses asChild, its child must forward `ref` to a DOM node.", + ); + } const a = anchorRef.current?.getBoundingClientRect(); const c = ref.current; if (!a || !c) return; diff --git a/src/react/components/ui/popover.tsx b/src/react/components/ui/popover.tsx index 5e17696cc7..89883495e3 100644 --- a/src/react/components/ui/popover.tsx +++ b/src/react/components/ui/popover.tsx @@ -30,9 +30,15 @@ export function Popover(props: PopoverProps): React.ReactElement { return <_Root {...props} />; } -/** Trigger — toggles the popover. `asChild` merges onto the child element. */ +/** + * Trigger — toggles the popover; the positioning anchor. `asChild` merges onto + * the child element, which must forward `ref` to its DOM node. + */ export function PopoverTrigger( - props: React.ButtonHTMLAttributes & { asChild?: boolean }, + props: React.ButtonHTMLAttributes & { + asChild?: boolean; + ref?: React.Ref; + }, ): React.ReactElement { return <_Trigger {...props} haspopup="dialog" />; } diff --git a/src/react/components/ui/slot.tsx b/src/react/components/ui/slot.tsx index b8628d72d2..0595ea7883 100644 --- a/src/react/components/ui/slot.tsx +++ b/src/react/components/ui/slot.tsx @@ -16,7 +16,7 @@ import * as React from "react"; type AnyProps = Record; /** Compose multiple refs into one callback ref. */ -function composeRefs( +export function composeRefs( ...refs: Array | undefined> ): React.RefCallback { return (node) => {