"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 === import { useEffect, useRef, useState } from "react" /** * Toast — a notification queue that lives outside React, so anything can * call `toast()` (event handlers, async code, other modules) and every * mounted `` reflects it. * * Parts: * - `createToaster()` an isolated queue: `{ toast, dismiss, subscribe }` * - `toast(payload)` the shared default queue — a string or * `{ message, duration, ...anything }`; returns an id. * `duration` defaults to 5000ms; `null` never * auto-dismisses * - `` the viewport — `role="region"` + `aria-live="polite"`; * each item is `role="status"`. Pass `render(item, * dismiss)` to draw items yourself * - `useToasts()` subscribe to a queue's items directly * * Auto-dismiss pauses while an item is hovered or holds focus, and restarts * in full when it is left. * * Styling hooks: `data-state="open"` on each item. The primitive positions * nothing — style the region (e.g. fixed bottom-right) in your wrapper. */ let nextId = 0 /** * @returns {{ * toast: (payload: string | { message?: any, duration?: number | null }) => number, * dismiss: (id: number) => void, * subscribe: (listener: (items: any[]) => void) => () => void, * }} */ export function createToaster() { let items = [] const listeners = new Set() function emit() { for (const listener of listeners) listener([...items]) } function toast(payload) { nextId += 1 const item = typeof payload === "string" ? { message: payload } : payload items = [...items, { duration: 5000, ...item, id: nextId }] emit() return nextId } function dismiss(id) { items = items.filter((item) => item.id !== id) emit() } function subscribe(listener) { listeners.add(listener) listener([...items]) return () => listeners.delete(listener) } return { toast, dismiss, subscribe } } /** The default queue that the bare `toast()` helper feeds. */ export const defaultToaster = createToaster() /** Enqueue on the default queue. See `createToaster`. */ export const toast = defaultToaster.toast /** * The current items of a queue, as React state. * * @param {ReturnType} [toaster] */ export function useToasts(toaster = defaultToaster) { const [items, setItems] = useState([]) useEffect(() => toaster.subscribe(setItems), [toaster]) return items } function ToastItem({ item, toaster, render }) { const timerRef = useRef(null) function start() { if (item.duration != null) { timerRef.current = setTimeout(() => toaster.dismiss(item.id), item.duration) } } function pause() { clearTimeout(timerRef.current) } useEffect(() => { start() return pause }, []) return (
{render ? render(item, () => toaster.dismiss(item.id)) : item.message}
) } /** * @param {object} props * @param {ReturnType} [props.toaster] Defaults to the shared queue. * @param {(item: any, dismiss: () => void) => React.ReactNode} [props.render] * @param {string} [props.label="Notifications"] Accessible name for the region. * @param {string} [props.className] */ export function Toaster({ toaster = defaultToaster, render, label = "Notifications", ...props }) { const items = useToasts(toaster) return (
{items.map((item) => ( ))}
) }