import { useClonedChild, useUniqueId, } from "@charcuterie/logic" import type { Placement, VirtualElement, } from "@floating-ui/react" import { autoUpdate, flip, offset, shift, size, useDismiss, useFloating, useInteractions, useRole, } from "@floating-ui/react" import type { ReactElement, RefObject } from "react" import { createElement, useContext, useLayoutEffect, } from "react" import { AnchoredOverlayScopeContext } from "./AnchoredOverlayScope.ts" /** * The floating-ui block every anchored overlay — `Popover`, `Menu`, * `Listbox`, `Combobox` — wrote out for itself, consolidated once. * * `Popover` and `Menu` had the same twelve-line `useFloating` + * `useInteractions` + `useClonedChild` preamble with two constants * different (the offset and the role), and the fleet's four pickers * hand-rolled a fifth, sixth, seventh and eighth `computePosition` * that reimplemented `flip`/`shift` against `window.innerHeight`. * This is the one implementation they all take. * * ### The owner's "configurable, but those are the basics" * * Escape and outside-press both close, **on by default**, for every * anchored overlay — that is what an overlay is. A component switches * one off through `dismiss` (a `Combobox`'s Escape clears the query * before it closes; a menu opened from a context click wants no * outside-press race with the thing that spawned it), but it opts out * rather than opting in. * * ### `size`, for the pickers * * `matchTriggerWidth` and `maxHeightPx` are the two things * `PortalDropdown` did with a hand-measured `TriggerRect` and a * `window.innerHeight` clamp. The `size` middleware does both from the * platform: the panel takes the trigger's width, and it never grows * past the space actually left in the viewport. * * The computed width/height are written **straight onto the floating * element's style** rather than lifted into React state, which is * floating-ui's own documented shape and is deliberate twice over: a * `setState` inside `apply` re-runs the position, which re-runs * `apply`, which is a render loop; and because these properties never * enter the `floatingStyles` object React manages, a component * re-render (a keystroke in a combobox) does not wipe them — only an * `autoUpdate` tick recomputes them. * * ### Portal-agnostic * * This returns the pieces — `clonedTrigger`, `getFloatingProps`, * `setFloating`, `floatingStyles`, `context` — and renders nothing. * Whether the panel goes in a `FloatingPortal`, the top layer, or in * place is the component's call, which is what lets the same hook back * a portalled `Listbox` and (for now) a top-layer `Popover`. */ export type AnchoredOverlayRole = | "dialog" | "listbox" | "menu" /** * What a panel can hang off in anchor mode: a real element, or a * **virtual** one — an object that only knows its own rectangle. * * The virtual half is what a context menu needs. A menu opened by * a press-and-hold belongs at the finger, not at the corner of the * card underneath it, and floating-ui positions against a rect * without caring where the rect came from. `flip` and `shift` work * unchanged, which is the whole reason to express a point this way * rather than with `top`/`left` of our own. */ export type AnchoredOverlayAnchor = | HTMLElement | VirtualElement export type UseAnchoredOverlayOptions = { /** * Anchor the panel to a **pre-existing** element the consumer already * rendered, instead of cloning a `trigger`. For the attached-input * `Combobox`, whose reference is the consumer's own `` (both the * value and the query) — there is nothing to clone. Supply exactly one * of `anchorRef` / `trigger`; with `anchorRef`, `clonedTrigger` is * `null`. * * A **virtual** anchor is allowed here too, which is how a context * menu opens at the point that was pressed. */ anchorRef?: RefObject /** * Both default **on** — Escape and outside-press are what makes an * overlay an overlay. Switch one off for the honest exception (a * combobox that swallows the first Escape to clear its query), never * to make a plain overlay sticky. Named for the flattened * floating-ui `useDismiss` options they carry. */ isEscapeDismissable?: boolean /** * Clamp the panel to the space the viewport actually leaves below * (or above) the trigger, with no fixed cap. The panel owes itself * an `overflow`, or a clamped one simply hides its last rows. * * `maxHeightPx` implies this *and* caps it at a number — a `Listbox` * never wants to be 900px tall even on a monitor that would allow * it. A `Menu` wants the other half only: as tall as its items need, * and never off the bottom of the screen. */ isHeightClamped?: boolean isOutsidePressDismissable?: boolean /** Ports `PortalDropdown`'s anchor-width match, via `size`. */ isTriggerWidthMatched?: boolean /** * Clamp the panel to the width the viewport actually leaves, with no * fixed cap — the inline twin of `isHeightClamped`, and `maxWidthPx` * without the number. * * A portalled panel is `position: fixed`, so its shrink-to-fit width * already stops at the *viewport*, not at the space `shift` left it * — which means a long label produces a panel exactly as wide as the * window, positioned 8px in, with 8px of itself off the right edge. * `availableWidth` is the shifted number, so this is what makes a * long label wrap instead. */ isWidthClamped?: boolean isVisible: boolean /** Clamps the panel to `min(this, available viewport space)`. */ maxHeightPx?: number /** * Caps the panel's width at `min(this, available viewport space)`, * symmetric with `maxHeightPx`. Without it a picker grows to its * widest content — a long `Combobox` footer sentence or option label * drags the whole panel absurdly wide; the cap lets that content wrap * instead. Omit for width-follows-content (the default). */ maxWidthPx?: number /** Popover 8, Menu/Listbox/Combobox 4. */ offsetValue?: number onDismiss: () => void placement?: Placement role: AnchoredOverlayRole /** * The control the panel hangs off. **Cloned, not wrapped.** Omit only * when `anchorRef` is supplied instead. */ trigger?: ReactElement } // `useClonedChild` insists on exactly one element, and hooks cannot be // called conditionally — so in `anchorRef` mode we still call it, on this // throwaway, and discard the result. It is never rendered and never // receives the floating reference (that goes to `anchorRef`). const PLACEHOLDER_TRIGGER = createElement("span") export const useAnchoredOverlay = ({ anchorRef, isEscapeDismissable = true, isHeightClamped = false, isOutsidePressDismissable = true, isTriggerWidthMatched = false, isVisible, isWidthClamped = false, maxHeightPx, maxWidthPx, offsetValue = 8, onDismiss, placement = "bottom-start", role, trigger, }: UseAnchoredOverlayOptions) => { const scope = useContext(AnchoredOverlayScopeContext) const anchorId = useUniqueId() const register = scope?.register const unregister = scope?.unregister useLayoutEffect(() => { if (!isVisible || !register || !unregister) return register(anchorId) return () => unregister(anchorId) }, [isVisible, anchorId, register, unregister]) const isTopAnchor = scope === null || scope.topId === anchorId const middleware = [ offset(offsetValue), flip(), shift({ padding: 8 }), ] if ( isHeightClamped || isTriggerWidthMatched || isWidthClamped || maxHeightPx !== undefined || maxWidthPx !== undefined ) { middleware.push( size({ padding: 8, apply({ availableHeight, availableWidth, elements, rects, }) { const heightCap = maxHeightPx === undefined ? availableHeight : Math.min(maxHeightPx, availableHeight) elements.floating.style.maxHeight = `${heightCap}px` if (isWidthClamped || maxWidthPx !== undefined) { const widthCap = maxWidthPx === undefined ? availableWidth : Math.min(maxWidthPx, availableWidth) elements.floating.style.maxWidth = `${widthCap}px` } if (isTriggerWidthMatched) { elements.floating.style.width = `${rects.reference.width}px` } }, }), ) } const isAnchored = anchorRef !== undefined const { context, floatingStyles, refs } = useFloating({ // Read-only. Charcuterie remains the sole owner; floating-ui is // told what is true and never decides it. open: isVisible, onOpenChange: (isNextVisible) => { if (!isNextVisible) { onDismiss() } }, placement, middleware, strategy: "fixed", whileElementsMounted: autoUpdate, }) // `useRole` writes `aria-expanded`/`aria-haspopup`/`aria-controls` // on the trigger and `id` + `role` on the panel from one source — // which is what keeps `aria-controls` pointing at the panel's real // id across the portal boundary, the objection the top-layer // decision raised and this answers. const { getFloatingProps, getReferenceProps } = useInteractions([ useDismiss(context, { escapeKey: isEscapeDismissable && isTopAnchor, outsidePress: isOutsidePressDismissable && isTopAnchor, }), useRole(context, { role }), ]) // `useRole` for a `listbox` overwrites the trigger's own role with // `combobox` — the select-only-combobox pattern, which then demands // its own `aria-label`. The fleet's pattern is a plain button that // *opens* a listbox, so the injected `role` is dropped and the // trigger keeps its native semantics while still gaining // `aria-haspopup="listbox"`/`aria-expanded`/`aria-controls`. `useRole` // injects a reference `role` only for listbox/combobox, so this is a // no-op for `Popover` (dialog) and `Menu` (menu). const { role: _discardedTriggerRole, ...referenceProps } = getReferenceProps() // A stable id on the trigger, so a panel that needs a name can point // `aria-labelledby` at it across the portal. `useRole` already gives // a `menu`'s reference an id (and labels the panel with it); this // reuses that when present and supplies one otherwise — which is what // a `listbox` panel needs, since a bare `role="listbox"` is an ARIA // input field and must be named. const generatedTriggerId = useUniqueId() /** * The trigger's OWN id wins over both. * * This hook used to mint an id and clone it over whatever the * trigger already had, on the reasoning that the panel needs a * stable target for `aria-labelledby`. It does — but any id serves * that purpose, including the caller's, and overwriting had a * consequence nobody traced: `Field` clones a `controlId` onto its * child and renders `