// ~/src/siteplane/components/combobox/combobox.tsx "use client"; import { Combobox as ComboboxPrimitive } from "@base-ui/react/combobox"; import { ChevronsUpDownIcon, XIcon } from "lucide-react"; import * as React from "react"; import { cn, type WithStringClassName } from "../../system/utils/cn"; import { slotClass, withClass } from "../../system/utils/slot-class"; import { useReducedMotionPreference } from "../../system/motion/use-reduced-motion-preference"; import { type SiteplaneControlSize, SiteplaneUIPortalScope, useResolvedControlSize, } from "../../system/provider/siteplane-ui-provider"; import { ChoiceMenuItemCheck, choiceMenuItemEndGridClassName, choiceMenuItemEndLabelClassName, menuRowLabel, } from "../../system/styles/choice-menu-item"; import { formControlSurfaceClassNames, type FormControlSurface, surfaceClassNames, } from "../../system/styles/surface"; import { KitInput } from "../input/input-internal"; import { ExternalInputOwner } from "../input/input"; import { Button } from "../button"; import { KitButton } from "../button/button-internal"; import { GlideOnChange, motionGhostAttribute } from "../motion/motion-presence"; import { ScrollArea } from "../scroll-area"; export const ComboboxContext: React.Context<{ chipsRef: React.RefObject | null; inputRef: React.RefObject | null; multiple: boolean; }> = React.createContext<{ chipsRef: React.RefObject | null; inputRef: React.RefObject | null; multiple: boolean; }>({ chipsRef: null, inputRef: null, multiple: false, }); /** * The control size of the Combobox's input, ComboboxChipsInput's or * ComboboxInput's. A popup anchored to a chips field sizes its items by it, * because the field grows a row for each line of chips (FB-9). Internal, beside * the public ComboboxContext. */ const ComboboxInputSizeContext = React.createContext<{ size: SiteplaneControlSize | undefined; setSize: React.Dispatch< React.SetStateAction >; } | null>(null); /** Gives the Combobox the resolved control size of its input. */ function useComboboxInputSize(size: SiteplaneControlSize) { const setSize = React.useContext(ComboboxInputSizeContext)?.setSize; React.useLayoutEffect(() => { if (!setSize) return; setSize(size); return () => setSize((current) => (current === size ? undefined : current)); }, [setSize, size]); } export function Combobox( props: ComboboxPrimitive.Root.Props, ): React.ReactElement { const chipsRef = React.useRef(null); const inputRef = React.useRef(null); const [inputSize, setInputSize] = React.useState(); const inputSizeValue = React.useMemo( () => ({ size: inputSize, setSize: setInputSize }), [inputSize], ); const [resetKey, setResetKey] = React.useState(0); React.useEffect(() => { if (props.value !== undefined) { return; } const handleReset = (event: Event) => { const form = event.target; const input = inputRef.current; if ( form instanceof HTMLFormElement && (input?.form === form || (props.form ? form.id === props.form : Boolean(input && form.contains(input)))) ) { setResetKey((current) => current + 1); } }; document.addEventListener("reset", handleReset, true); return () => document.removeEventListener("reset", handleReset, true); }, [props.form, props.value]); return ( ); } export function ComboboxChipsInput({ className, ref, size, ...props }: Omit, "size"> & { size?: SiteplaneControlSize | number; ref?: React.Ref; }): React.ReactElement { const visualSize = typeof size === "number" ? undefined : size; const nativeSize = typeof size === "number" ? size : undefined; const resolvedSize = useResolvedControlSize(visualSize); useComboboxInputSize(resolvedSize); const contextInputRef = React.useContext(ComboboxContext).inputRef; const fallbackInputRef = React.useRef(null); const inputRef = contextInputRef ?? fallbackInputRef; React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); return ( ); } export type ComboboxInputProfile = "default" | "embedded"; export type ComboboxInputProps = Omit< WithStringClassName, "render" | "size" > & { showTrigger?: boolean; showClear?: boolean; startAddon?: React.ReactNode; size?: SiteplaneControlSize | number | undefined; variant?: FormControlSurface | undefined; profile?: ComboboxInputProfile; ref?: React.Ref; triggerProps?: WithStringClassName; clearProps?: WithStringClassName; }; export function ComboboxInput({ className, disabled, profile = "default", ref, showTrigger = true, showClear = false, startAddon, size, variant = "default", triggerProps, clearProps, ...props }: ComboboxInputProps): React.ReactElement { const visualSize = typeof size === "number" ? undefined : size; const nativeSize = typeof size === "number" ? size : undefined; const resolvedSize = useResolvedControlSize(visualSize); useComboboxInputSize(resolvedSize); const contextInputRef = React.useContext(ComboboxContext).inputRef; const fallbackInputRef = React.useRef(null); const inputRef = contextInputRef ?? fallbackInputRef; React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); return ( {startAddon && ( )} {/* Combobox.Input already connects the input to Field. */} } disabled={disabled} ref={inputRef} /> {showTrigger && ( ) } > )} {showClear && ( ) } > )} ); } export function ComboboxTrigger({ className, children, render, ...props }: WithStringClassName): React.ReactElement { return ( ) } {...props} > {children} ); } export function ComboboxPopup({ className, children, side = "bottom", sideOffset = 4, alignOffset, align = "start", anchor: anchorProp, portalProps, ...props }: WithStringClassName & { align?: ComboboxPrimitive.Positioner.Props["align"]; sideOffset?: ComboboxPrimitive.Positioner.Props["sideOffset"]; alignOffset?: ComboboxPrimitive.Positioner.Props["alignOffset"]; side?: ComboboxPrimitive.Positioner.Props["side"]; anchor?: ComboboxPrimitive.Positioner.Props["anchor"]; portalProps?: WithStringClassName; }): React.ReactElement { const { chipsRef, multiple } = React.useContext(ComboboxContext); const anchor = anchorProp ?? (multiple ? chipsRef : undefined); // An item is as tall as the one-line field the popup anchors to (the item // rule in design-rework.css). A chips field grows a row for each line of // chips, so a popup anchored to it names the control size of its input // instead, and its items take that size's height (FB-9). const contextSize = useResolvedControlSize(undefined); const inputSize = React.useContext(ComboboxInputSizeContext)?.size; const itemSize = anchorProp === undefined && multiple ? (inputSize ?? contextSize) : undefined; return ( {children} ); } export function ComboboxItem({ className, children, indicatorPosition = "start", onPointerCancel, onPointerDown, onPointerMove, onPointerUp, ...props }: WithStringClassName & { /** * Where the item shows its check: in the leading slot (`start`), or at the * end of the row (`end`) for identity rows that lead with an avatar or a * logo, such as the people of an assignee picker. */ indicatorPosition?: "start" | "end" | undefined; }): React.ReactElement { const touchPointerRef = React.useRef<{ id: number; moved: boolean; x: number; y: number; } | null>(null); // An identity row is the end-indicator radio row of the menus (rulebook I8): // it takes their grid and label, so its avatar or logo leads in the check's // slot, a row of text alone keeps that slot, and the label keeps one line. // The check keeps its 8 px inset, now from the end edge. const end = indicatorPosition === "end"; const indicator = ( ); const label = end ? (
{menuRowLabel(children)}
) : (
{children}
); return ( [data-slot=combobox-item-label]>*))]:content-center [&:not(:has(>[data-slot=combobox-item-label]>*))]:items-start", className, )} data-indicator-position={end ? "end" : undefined} data-slot="combobox-item" onPointerCancel={(event) => { touchPointerRef.current = null; onPointerCancel?.(event); }} onPointerDown={(event) => { if (event.pointerType === "touch") { touchPointerRef.current = { id: event.pointerId, moved: false, x: event.clientX, y: event.clientY, }; } onPointerDown?.(event); }} onPointerMove={(event) => { const pointer = touchPointerRef.current; if ( pointer?.id === event.pointerId && (Math.abs(event.clientX - pointer.x) > 8 || Math.abs(event.clientY - pointer.y) > 8) ) { pointer.moved = true; } onPointerMove?.(event); }} onPointerUp={(event) => { const pointer = touchPointerRef.current; touchPointerRef.current = null; onPointerUp?.(event); if (pointer?.id !== event.pointerId || pointer.moved) { return; } const item = event.currentTarget; let nativeClickFired = false; const markNativeClick = () => { nativeClickFired = true; }; item.addEventListener("click", markNativeClick, { once: true }); window.setTimeout(() => { item.removeEventListener("click", markNativeClick); if (!nativeClickFired && item.isConnected) { item.click(); } }); }} {...props} > {end ? label : indicator} {end ? indicator : label} ); } export function ComboboxSeparator({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function ComboboxGroup({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function ComboboxGroupLabel({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function ComboboxEmpty({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function ComboboxRow({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } /** The chips, the input and the start addon of a chips field. */ function chipsParts(chips: Element | null | undefined): HTMLElement[] { return chips ? [...chips.children].filter( (child): child is HTMLElement => child instanceof HTMLElement && !child.hasAttribute(motionGhostAttribute), ) : []; } export function ComboboxValue({ children, ...props }: ComboboxPrimitive.Value.Props): React.ReactElement { const { chipsRef, multiple } = React.useContext(ComboboxContext); const reduced = useReducedMotionPreference(); // The chips of a multiple selection come and go with the value (rulebook M6, // M7): a chip that is added fades in, a removed one fades out where it was, // and the chips and the input after them glide to their new place. Base UI // drops a removed chip at once, so the keyboard order of the chips stays its // own; what fades is a picture of it. const glide = multiple && typeof children === "function" ? (value: unknown) => ( <> {children(value)} chipsRef?.current as HTMLElement | null} parts={() => chipsParts(chipsRef?.current)} presence reduced={reduced} /> ) : children; return ( {glide} ); } export function ComboboxList({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function ComboboxClear({ className, render, ...props }: WithStringClassName): React.ReactElement { return ( ) } {...props} /> ); } export function ComboboxStatus({ className, ...props }: WithStringClassName): React.ReactElement { return ( ); } export function ComboboxCollection( props: ComboboxPrimitive.Collection.Props, ): React.ReactElement { return ( ); } export function ComboboxChips({ className, children, startAddon, variant = "default", layout = "input", ...props }: WithStringClassName & { startAddon?: React.ReactNode; variant?: FormControlSurface | undefined; layout?: "input" | "standalone" | undefined; }): React.ReactElement { const { chipsRef } = React.useContext(ComboboxContext); return ( | null} {...props} > {startAddon && ( )} {children} ); } /** The plain text of a chip's value, for the title of its label. */ function chipText(node: React.ReactNode): string { if (typeof node === "string" || typeof node === "number") return `${node}`; if (Array.isArray(node)) return node.map(chipText).join("").trim(); if (React.isValidElement<{ children?: React.ReactNode }>(node)) return chipText(node.props.children); return ""; } export function ComboboxChip({ children, removeProps, render, startAddon, ...props }: WithStringClassName & { removeProps?: WithStringClassName; /** * A small decorative avatar or icon before the value, such as the portrait of * a person. The chip starts closer to its edge when it has one. */ startAddon?: React.ReactNode; }): React.ReactElement { // The remove action is named "Remove" and the chip's value, such as // "Remove Mara Ellis", so each one in a row of chips is distinct. const labelId = React.useId(); const removeId = React.useId(); // A chip comes, goes and glides with the value (ComboboxValue). return ( {startAddon && ( )} {/* A long value ends in an ellipsis on the chip's one line, at the width the chips give it, and the title carries the full value (rulebook K5). The badge label rule owns it (design-rework.css). */} {children} ); } export function ComboboxChipRemove({ render, ...props }: WithStringClassName): React.ReactElement { return ( ) } {...props} // A className of the app's replaces the action's own classes; the // slot's class stays either way. The chassis keeps the kit's action at // its width; an element of the app's render takes shrink-0 for that. className={withClass( props.className ?? (render ? "me-0.5 shrink-0" : "me-0.5"), slotClass("combobox-chip-remove", props, render), )} > ); } export const useComboboxFilter: typeof ComboboxPrimitive.useFilter = ComboboxPrimitive.useFilter; export { ComboboxPrimitive }; // ~/src/siteplane/components/combobox/index.ts export * from "./combobox";