"use client" // === primitives/core.js (embedded) — shared machinery; ends at the next banner. // Copying several primitives? Fetch /r/primitives/core.txt once, save it as // primitives/core.js, and restore each primitive's `from "./core.js"` import // instead of carrying this block in every file. import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react" /** * PresetUI primitives — shared core. * * Everything the popup-family primitives have in common lives here, so each * primitive file stays a readable description of its own behavior: * * - `useControllableState` controlled-or-uncontrolled state * - `usePosition` fixed-position engine: flip when out of room, * shift to stay in the viewport * - `useDismiss` layered outside-press + Escape dismissal * - `useRovingFocus` arrow keys / Home / End across items * - `useTypeahead` buffered character search * - `focusFirst` move focus into a container * - `composeRefs` several refs, one callback * - `mergeProps` merge our props onto a consumer element * (asChild) — handlers chain, consumer first * * This file imports nothing but React. Primitives import only this file. */ /** * State that is uncontrolled until the consumer passes `value`. `onChange` * fires for every attempted change in both modes. * * @template T * @param {T | undefined} value Controlled value, or undefined. * @param {T} defaultValue * @param {(next: T) => void} [onChange] * @returns {[T, (next: T | ((prev: T) => T)) => void]} */ export function useControllableState(value, defaultValue, onChange) { const [internal, setInternal] = useState(defaultValue) const controlled = value !== undefined const current = controlled ? value : internal function setState(next) { const resolved = typeof next === "function" ? next(current) : next if (!controlled) setInternal(resolved) if (resolved !== current) onChange?.(resolved) } return [current, setState] } /** * One callback ref that fills every ref it is given. * * @param {...(((node: any) => void) | { current: any } | undefined)} refs */ export function composeRefs(...refs) { return (node) => { for (const ref of refs) { if (typeof ref === "function") ref(node) else if (ref) ref.current = node } } } /** * Merge our behavior props onto a consumer-supplied element's props * (the `asChild` pattern). The consumer's handler runs first; ours is * skipped if it called `event.preventDefault()`. `className` joins, * `style` merges, `ref`s compose. * * @param {Record} ownProps * @param {Record} childProps */ export function mergeProps(ownProps, childProps) { const merged = { ...ownProps, ...childProps } for (const key of Object.keys(ownProps)) { const own = ownProps[key] const theirs = childProps[key] if (own === undefined || theirs === undefined || own === theirs) continue if (/^on[A-Z]/.test(key) && typeof own === "function" && typeof theirs === "function") { merged[key] = (event, ...rest) => { theirs(event, ...rest) if (!event?.defaultPrevented) own(event, ...rest) } } else if (key === "className") { merged[key] = `${own} ${theirs}` } else if (key === "style") { merged[key] = { ...own, ...theirs } } else if (key === "ref") { merged[key] = composeRefs(own, theirs) } } return merged } const FOCUSABLE = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])" /** * Focus the first focusable element inside `container`, or the container * itself (give it `tabIndex={-1}`) when nothing inside can take focus. * * @param {HTMLElement | null} container */ export function focusFirst(container) { if (!container) return const target = container.querySelector(FOCUSABLE) ?? container target.focus() } // Open layers, in mount order. Only the topmost layer reacts to outside // presses and Escape, so nested popups unwind one at a time. const layers = [] /** * Outside-press and Escape dismissal, stacking-aware. * * @param {object} options * @param {boolean} options.enabled Attach only while open. * @param {{ current: HTMLElement | null }[]} options.refs Presses inside any of these do not dismiss. * @param {() => void} options.onDismiss * @param {boolean} [options.escape=true] * @param {boolean} [options.outsidePress=true] */ export function useDismiss({ enabled, refs, onDismiss, escape = true, outsidePress = true }) { const latest = useRef({ refs, onDismiss }) latest.current = { refs, onDismiss } useEffect(() => { if (!enabled) return const layer = {} layers.push(layer) const isTop = () => layers[layers.length - 1] === layer function onPointerDown(event) { if (!outsidePress || !isTop()) return const inside = latest.current.refs.some( (ref) => ref.current && ref.current.contains(event.target), ) if (!inside) latest.current.onDismiss?.() } function onKeyDown(event) { if (!escape || event.key !== "Escape" || !isTop()) return latest.current.onDismiss?.() } document.addEventListener("pointerdown", onPointerDown) document.addEventListener("keydown", onKeyDown) return () => { layers.splice(layers.indexOf(layer), 1) document.removeEventListener("pointerdown", onPointerDown) document.removeEventListener("keydown", onKeyDown) } }, [enabled, escape, outsidePress]) } /** * Position a floating element against an anchor with `position: fixed`. * Flips to the opposite side when there is no room, shifts along the other * axis to stay inside the viewport, and follows scroll and resize. * * @param {object} options * @param {boolean} options.open Only measures while open. * @param {{ current: HTMLElement | null }} options.anchorRef * @param {{ current: HTMLElement | null }} options.floatingRef * @param {"top" | "right" | "bottom" | "left"} [options.side="bottom"] * @param {"start" | "center" | "end"} [options.align="center"] * @param {number} [options.sideOffset=6] * @returns {{ top: number, left: number, side: string }} */ export function usePosition({ open, anchorRef, floatingRef, side = "bottom", align = "center", sideOffset = 6, }) { const [position, setPosition] = useState({ top: 0, left: 0, side }) const update = useCallback(() => { const anchor = anchorRef.current const floating = floatingRef.current if (!anchor || !floating) return const a = anchor.getBoundingClientRect() const f = floating.getBoundingClientRect() const vw = window.innerWidth const vh = window.innerHeight const PADDING = 8 let s = side if (s === "bottom" && a.bottom + sideOffset + f.height > vh && a.top - sideOffset - f.height >= 0) s = "top" else if (s === "top" && a.top - sideOffset - f.height < 0 && a.bottom + sideOffset + f.height <= vh) s = "bottom" else if (s === "right" && a.right + sideOffset + f.width > vw && a.left - sideOffset - f.width >= 0) s = "left" else if (s === "left" && a.left - sideOffset - f.width < 0 && a.right + sideOffset + f.width <= vw) s = "right" let top let left if (s === "top" || s === "bottom") { top = s === "bottom" ? a.bottom + sideOffset : a.top - sideOffset - f.height left = align === "start" ? a.left : align === "end" ? a.right - f.width : a.left + a.width / 2 - f.width / 2 left = Math.min(Math.max(left, PADDING), Math.max(vw - f.width - PADDING, PADDING)) } else { left = s === "right" ? a.right + sideOffset : a.left - sideOffset - f.width top = align === "start" ? a.top : align === "end" ? a.bottom - f.height : a.top + a.height / 2 - f.height / 2 top = Math.min(Math.max(top, PADDING), Math.max(vh - f.height - PADDING, PADDING)) } setPosition((previous) => previous.top === top && previous.left === left && previous.side === s ? previous : { top, left, side: s }, ) }, [anchorRef, floatingRef, side, align, sideOffset]) useLayoutEffect(() => { if (!open) return update() window.addEventListener("scroll", update, true) window.addEventListener("resize", update) const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update) if (observer) { if (anchorRef.current) observer.observe(anchorRef.current) if (floatingRef.current) observer.observe(floatingRef.current) } return () => { window.removeEventListener("scroll", update, true) window.removeEventListener("resize", update) observer?.disconnect() } }, [open, update, anchorRef, floatingRef]) return position } /** * Arrow-key focus movement across the focusable items inside a container. * Items opt in with the `data-roving` attribute; disabled ones carry * `data-disabled` and are skipped. * * @param {{ current: HTMLElement | null }} containerRef * @param {object} [options] * @param {"vertical" | "horizontal"} [options.orientation="vertical"] * @param {boolean} [options.loop=true] * @param {string} [options.scope] When containers nest (submenus), a selector * that identifies a container — only items whose closest such ancestor is * THIS container participate, so a child menu's items stay out of the * parent's arrow keys. */ export function useRovingFocus(containerRef, { orientation = "vertical", loop = true, scope } = {}) { function items() { const found = containerRef.current?.querySelectorAll("[data-roving]") ?? [] return [...found].filter( (el) => !el.hasAttribute("data-disabled") && (!scope || el.closest(scope) === containerRef.current), ) } function focusAt(list, index) { if (list.length === 0) return const target = loop ? list[(index + list.length) % list.length] : list[Math.min(Math.max(index, 0), list.length - 1)] target?.focus() } function onKeyDown(event) { const nextKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight" const previousKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft" const list = items() const index = list.indexOf(document.activeElement) if (event.key === nextKey) { event.preventDefault() focusAt(list, index + 1) } else if (event.key === previousKey) { event.preventDefault() focusAt(list, index - 1) } else if (event.key === "Home") { event.preventDefault() focusAt(list, 0) } else if (event.key === "End") { event.preventDefault() focusAt(list, list.length - 1) } } return { onKeyDown, items, focusFirstItem: () => focusAt(items(), 0), focusLastItem: () => { const list = items() focusAt(list, list.length - 1) }, } } /** * Buffered character search: printable keys accumulate into a query, which * clears half a second after the last keystroke. * * @param {(query: string) => void} onQuery Called with the running query. * @returns {(key: string) => boolean} Feed it `event.key`; returns whether the key was consumed. */ export function useTypeahead(onQuery) { const state = useRef({ query: "", timer: null }) return (key) => { if (key.length !== 1 || key === " ") return false clearTimeout(state.current.timer) state.current.query += key.toLowerCase() state.current.timer = setTimeout(() => { state.current.query = "" }, 500) onQuery(state.current.query) return true } } // === the primitive itself === /** * DropdownMenu — headless action menu on a trigger, following the WAI-ARIA * menu-button pattern. * * Parts: * - `` state root; controlled via `open`/`onOpenChange` * - `` a ` ) } /** * @param {object} props * @param {"top" | "right" | "bottom" | "left"} [props.side="bottom"] * @param {"start" | "center" | "end"} [props.align="start"] * @param {number} [props.sideOffset=6] * @param {string} [props.className] */ export function MenuContent({ side = "bottom", align = "start", sideOffset = 6, style, children, ...props }) { const context = useMenuContext("MenuContent") useDismiss({ enabled: context.open, refs: [context.triggerRef, context.contentRef], onDismiss: () => context.setOpen(false), }) const position = usePosition({ open: context.open, anchorRef: context.triggerRef, floatingRef: context.contentRef, side, align, sideOffset, }) const roving = useRovingFocus(context.contentRef, { scope: "[role=menu]" }) const typeahead = useTypeahead((query) => { const match = roving .items() .find((item) => item.textContent.toLowerCase().trim().startsWith(query)) match?.focus() }) useEffect(() => { if (!context.open) return if (context.openFocusRef.current === "last") roving.focusLastItem() else roving.focusFirstItem() return () => context.triggerRef.current?.focus() }, [context.open]) if (!context.open) return null return ( ) } /** * @param {object} props * @param {(event: { preventDefault: () => void, defaultPrevented: boolean }) => void} [props.onSelect] * @param {boolean} [props.disabled=false] * @param {string} [props.className] */ export function MenuItem({ onSelect, disabled = false, children, ...props }) { const context = useMenuContext("MenuItem") function select() { if (disabled) return const event = { defaultPrevented: false, preventDefault() { this.defaultPrevented = true }, } onSelect?.(event) if (!event.defaultPrevented) context.setOpen(false) } return (
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault() select() } }} onFocus={(event) => event.currentTarget.setAttribute("data-highlighted", "")} onBlur={(event) => event.currentTarget.removeAttribute("data-highlighted")} onPointerEnter={(event) => { if (!disabled) event.currentTarget.focus() }} {...props} > {children}
) } const SubContext = createContext(null) function useSubContext(part) { const context = useContext(SubContext) if (!context) throw new Error(`${part} must be used inside `) return context } /** State root for one submenu. Nest freely. */ export function MenuSub({ children }) { const [open, setOpen] = useState(false) const triggerRef = useRef(null) const contentRef = useRef(null) const contentId = useId() return ( {children} ) } /** * A menu item that opens its submenu — via ArrowRight, Enter, Space, click, * or hover. * * @param {object} props * @param {boolean} [props.disabled=false] * @param {string} [props.className] */ export function MenuSubTrigger({ disabled = false, children, ...props }) { const sub = useSubContext("MenuSubTrigger") function open(event) { if (disabled) return event.preventDefault() sub.setOpen(true) } return (
{ sub.triggerRef.current = node }} role="menuitem" tabIndex={-1} aria-haspopup="menu" aria-expanded={sub.open} aria-controls={sub.open ? sub.contentId : undefined} data-roving={disabled ? undefined : ""} data-disabled={disabled ? "" : undefined} aria-disabled={disabled || undefined} data-state={sub.open ? "open" : "closed"} onClick={open} onKeyDown={(event) => { if (event.key === "ArrowRight" || event.key === "Enter" || event.key === " ") { event.stopPropagation() open(event) } }} onFocus={(event) => event.currentTarget.setAttribute("data-highlighted", "")} onBlur={(event) => event.currentTarget.removeAttribute("data-highlighted")} onPointerEnter={(event) => { if (disabled) return event.currentTarget.focus() sub.setOpen(true) }} {...props} > {children}
) } /** * The nested panel — its own `role="menu"` with its own arrow keys and * typeahead; the parent's keys never reach into it. * * @param {object} props * @param {"top" | "right" | "bottom" | "left"} [props.side="right"] * @param {"start" | "center" | "end"} [props.align="start"] * @param {number} [props.sideOffset=2] * @param {string} [props.className] */ export function MenuSubContent({ side = "right", align = "start", sideOffset = 2, style, children, ...props }) { const root = useMenuContext("MenuSubContent") const sub = useSubContext("MenuSubContent") useDismiss({ enabled: sub.open, refs: [sub.triggerRef, sub.contentRef], onDismiss: () => sub.setOpen(false), }) const position = usePosition({ open: sub.open, anchorRef: sub.triggerRef, floatingRef: sub.contentRef, side, align, sideOffset, }) const roving = useRovingFocus(sub.contentRef, { scope: "[role=menu]" }) const typeahead = useTypeahead((query) => { const match = roving .items() .find((item) => item.textContent.toLowerCase().trim().startsWith(query)) match?.focus() }) useEffect(() => { if (!sub.open) return roving.focusFirstItem() return () => { const trigger = sub.triggerRef.current // The whole menu may be unmounting with us — only reclaim focus when // the sub trigger is still on the page. if (trigger?.isConnected) trigger.focus() } }, [sub.open]) if (!sub.open) return null return ( ) } /** @param {object} props */ export function MenuGroup(props) { return
} /** @param {object} props */ export function MenuLabel(props) { return
} /** @param {object} props */ export function MenuSeparator(props) { return
}