import { useVisibility } from "@charcuterie/logic" import type { ControlSize, IntentName, } from "@charcuterie/tokens" import type { Placement } from "@floating-ui/react" import type { ComponentPropsWithRef, ReactNode, } from "react" import { Button } from "../Button/Button.tsx" import type { IntentAppearance } from "../intentStyles.ts" import type { ListboxItem } from "../Listbox/Listbox.tsx" import { Listbox } from "../Listbox/Listbox.tsx" import { toClassName } from "../toClassName.ts" export type PickerOption = ListboxItem /** * A constant element rather than a component, for the same reason * `Select` inlines its own: a second component in this file trips the * house rule, and this never varies. */ const CHEVRON_DOWN = ( ) export type PickerProps = Omit< ComponentPropsWithRef<"button">, "disabled" | "onChange" | "value" > & { appearance?: IntentAppearance className?: string intent?: IntentName isDisabled?: boolean /** * What the control is FOR — "Language", "Quantifier", "Rule type". * Not the current value; that is read off `options`. */ label: string /** * Defaults to a chevron. Pass `null` to drop it — a picker rendered * inside a denser control row may not want the affordance, and a * refactor moving an existing hand-rolled trigger onto `Picker` * stays pixel-identical by passing `null` rather than re-baselining * every screenshot that contains it. */ iconEnd?: ReactNode onChange: (value: string) => void options: readonly PickerOption[] placement?: Placement /** Read by the trigger when `value` matches no option. */ placeholder?: string size?: ControlSize value?: string } /** * A `Listbox` with the trigger already attached — the shape every app * reaches for when it wants "a select, but ours". * * ### Four repos wrote this, and the fourth is this package * * `Listbox` is deliberately trigger-agnostic: it takes a `trigger` * element and owns none of the open state, which is what lets it hang * off a button, a tile, a table header. The cost is that the *common* * case — a button showing the current value — is about thirty lines, * and the fleet wrote them four times: queuepilot's `SelectListbox`, * board-games' `SelectMenu` (on `useState` rather than * `useVisibility`), mux-magic's `ListboxPicker`, and — the tell — * **twice inside this package**, in `QueryBuilderCombinator` and in * `QueryBuilder`'s own story. Each also hand-rolled the same chevron. * * `Listbox` stays exactly as it is. This is the assembled default. * * ### Why it is not called `SinglePicker` * * The state layer has `useSinglePicker` and `useMultiplePicker`, so a * name near "picker" invites the question of whether this is their * component. It is not, and no component is: `useSinglePicker` is a * state kind that `Listbox`, `Combobox` and `Tabs` all compose. This * component owns a `useVisibility` and delegates selection to * `Listbox`, which is where the picker state actually lives. The name * is the owner's, chosen over `SelectListbox` and `ListboxButton`. * * ### The accessible name carries the current value * * `"Language: English"`, not `"Language"`. The trigger's visible text * IS the current value, and WCAG 2.5.3 requires the visible text to be * contained in the accessible name — so a bare `aria-label={label}` * fails it, which is a latent defect in one of the hand-rolled * versions this replaces. It also makes each control uniquely findable * when a form has several, which is the property `expectAgentDrivable` * exists to protect. * * Query it as `getByRole("button", { name: /^Language: / })`. * * ### `id` survives — it did not always * * `useAnchoredOverlay` used to overwrite the trigger's `id` with a * generated one so the portalled listbox could point `aria-labelledby` * across at it. That silently broke `Field`, which renders * `