import { useVisibility } from "@charcuterie/logic" import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters" import type { ControlSize } from "@charcuterie/tokens" import type { Meta, StoryObj } from "@storybook/react" import type { ReactNode } from "react" import { useRef, useState } from "react" import { controlSizeArgType } from "../argTypes.storyHelpers.ts" import { Button } from "../Button/Button.tsx" import { StoryCell, StoryGrid, } from "../board.storyHelpers.tsx" import type { ListboxItem } from "../Listbox/Listbox.tsx" import { Combobox } from "./Combobox.tsx" const LANGUAGES: ListboxItem[] = [ { label: "English", value: "eng" }, { label: "Spanish", value: "spa" }, { label: "French", value: "fra" }, { label: "German", value: "deu" }, { label: "Japanese", value: "jpn" }, { label: "Korean", value: "kor" }, { label: "Portuguese", value: "por" }, ] const LANGUAGES_DISABLED_FIRST: ListboxItem[] = [ { isDisabled: true, label: "English", value: "eng" }, { label: "Spanish", value: "spa" }, { label: "French", value: "fra" }, ] // Long enough to overflow the panel's height cap, short enough to stay // under `AUTO_VIRTUALIZE_THRESHOLD` — the shape most app pickers have, // and the one that exercises the plain DOM scroll path. const MANY_SHORT: ListboxItem[] = Array.from( { length: 62 }, (_unused, index) => ({ label: `Item ${index + 1}`, textValue: `Item ${index + 1}`, value: `item-${index + 1}`, }), ) // A long list, to trip the auto-virtualization threshold. const MANY: ListboxItem[] = Array.from( { length: 500 }, (_unused, index) => ({ label: `Track ${index + 1}`, textValue: `Track ${index + 1}`, value: `track-${index + 1}`, }), ) const meta = { title: "Components/Controls/Combobox", component: Combobox, parameters: { layout: "padded" }, argTypes: { itemSize: controlSizeArgType }, args: { itemSize: "md" }, } satisfies Meta export default meta type Story = StoryObj const ComboboxHarness = ({ className, emptyLabel, error, footer, isCreatable, isInitiallyVisible = false, isLoading, isMultiple, isVirtualized, itemSize, options, selectedValue, triggerLabel, }: { className?: string emptyLabel?: string error?: ReactNode footer?: ReactNode isCreatable?: boolean isInitiallyVisible?: boolean isLoading?: boolean isMultiple?: boolean isVirtualized?: boolean itemSize?: ControlSize options: ListboxItem[] selectedValue?: readonly string[] | string triggerLabel: string }): ReactNode => { const { hide, isVisible, toggle } = useVisibility({ isVisible: isInitiallyVisible, }) const [chosen, setChosen] = useState( selectedValue === undefined ? [] : typeof selectedValue === "string" ? [selectedValue] : [...selectedValue], ) return ( <> { // Mirror the control's own selection model so the "Chosen" // line never disagrees with the ticks in the list: multi-select // accumulates/toggles, single-select replaces (one value, one // ✓). Toggling into an array in single-select made the line // claim several were chosen while only the last carried a tick. setChosen((previous) => { if (!isMultiple) { return [value] } return previous.includes(value) ? previous.filter((one) => one !== value) : [...previous, value] }) }} options={options} selectedValue={selectedValue} trigger={ } />

Chosen: {chosen.join(", ") || "—"}

) } export const Playground: Story = { parameters: playgroundParameters, args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES, trigger: , }, render: () => ( ), } // Rich labels that pin a trailing category tag to the row's right edge: // the label is a full-width flex row, so its tag sits just inside the // always-reserved ✓ gutter. Selecting a row must not shift the tag — the // checkmark is always laid out, only made `invisible` when unselected. const TAGGED_LANGUAGES: ListboxItem[] = [ { name: "English", tag: "Germanic", value: "eng" }, { name: "Spanish", tag: "Romance", value: "spa" }, { name: "French", tag: "Romance", value: "fra" }, { name: "German", tag: "Germanic", value: "deu" }, ].map(({ name, tag, value }) => ({ label: ( {name} {tag} ), textValue: name, value, })) /** * Rich option rows that pin a **trailing category tag** to the right * edge, with the first option pre-selected. The ✓ gutter is always laid * out (only made `invisible` when a row is unselected), so selecting a * row is a paint-only change and the trailing tag never jumps left. */ export const TrailingElement: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: TAGGED_LANGUAGES, trigger: , }, render: () => ( ), } /** * A very long, full-sentence `footer`. The panel is width-capped * (`maxWidthPx`), so the footer **wraps** to multiple lines instead of * stretching the popup to the width of the sentence. */ export const LongFooter: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES, trigger: , }, render: () => ( ), } /** * The loading and error panel states — the ones only `PathPicker` had * all of, and every fleet picker did differently. Both keep the popup * open. */ export const AllStates: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES, trigger: , }, render: () => (
), } /** * Multi-select renders the chosen values as an always-visible row of * removable chips **above the trigger** — they stay on screen after the * popup closes, and each carries its option's human label (not its raw * value) and an ✕ to remove it. The `LanguageCodeField` and `LinkPicker` * shapes. `isCreatable` lets Enter commit the raw query. */ export const AllVariants: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES, trigger: , }, render: () => ( ), } /** * Five hundred options, windowed with `@tanstack/react-virtual`: only * the visible rows are in the DOM, so each carries `aria-setsize` and * `aria-posinset` — otherwise a screen reader announces "2 of 12" for * a list that is nothing of the sort. */ export const Virtualized: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: MANY, trigger: , }, render: () => ( ), } /** * Open it and type: the list filters as you go, arrows move the active * option while the caret stays in the input, Enter chooses, and Escape * clears the query before it closes. */ export const Interactive: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES, trigger: , }, render: () => ( ), } /** * A disabled option in the seed position. Opening seeds the active row at * index 0, so the resolved active descendant must skip past the disabled * first option to the first enabled one — and Enter must never commit the * disabled value. */ // A tiny in-memory directory tree for the attached-input demo: keys are // parent paths (with trailing slash), values their child folders. const TREE: Record = { "/": ["apps", "config", "media"], "/apps/": ["mux-magic", "charcuterie"], "/config/": ["hosts", "secrets"], "/media/": ["movies", "music"], } const AttachedInputHarness = (): ReactNode => { const inputRef = useRef(null) const [value, setValue] = useState("/") const [isVisible, setIsVisible] = useState(true) const lastSlash = value.lastIndexOf("/") const parentPath = value.slice(0, lastSlash + 1) || "/" const tail = value.slice(lastSlash + 1) const options: ListboxItem[] = (TREE[parentPath] ?? []) .filter((name) => name.startsWith(tail)) .map((name) => ({ label: `📁 ${name}`, textValue: name, value: name, })) return ( <> { setValue(changeEvent.target.value) setIsVisible(true) }} ref={inputRef} value={value} /> { setIsVisible(false) }} onSelect={(name) => { // Drill in: append the picked folder + separator. The changed // query refetches the new directory; the popup stays open // because we never flip `isVisible`. setValue(`${parentPath}${name}/`) }} options={options} query={value} /> ) } /** * Attached-input mode: the consumer renders its own `` (both the * committed value and the query) and Combobox anchors a list-only popup * to it. Type a path; picking a folder appends it and re-queries that * folder's children **without closing** — a shell-style drill-down. This * is `PathPicker`'s shape. */ export const AttachedInputDrillDown: Story = { args: { inputRef: { current: null }, isVisible: true, onDismiss: () => {}, onSelect: () => {}, options: [], query: "/", }, render: () => , } // Long, space-separated names — the real-world case (a media library of // long folder names) the short demo tree does not show. const LONG_OPTIONS: ListboxItem[] = [ "Armored Trooper Votoms - Brilliantly Shining Heresy [anidb-7910]", "Armored Trooper Votoms - Case;Irvine [anidb-7911]", "Armored Trooper Votoms - Chirico's Return [anidb-1234]", "Armored Trooper Votoms - Deadworld Sunsa [anidb-5678]", "Armored Trooper Votoms - Pailsen Files - The Movie [anidb-9012]", ].map((name) => ({ label: `📁 ${name}`, textValue: name, value: name, })) const LongOptionsHarness = (): ReactNode => { const inputRef = useRef(null) const [isVisible, setIsVisible] = useState(true) return ( <> { setIsVisible(false) }} onSelect={() => {}} options={LONG_OPTIONS} query="/media/Media-Storage/Anime/" /> ) } /** * How the attached-input popup handles **long option labels** — a media * library of long folder names, the case the short drill-down demo hides. * Shows how the panel sizes and how each option row treats overflow. */ export const AttachedInputLongOptions: Story = { args: { inputRef: { current: null }, isVisible: true, onDismiss: () => {}, onSelect: () => {}, options: LONG_OPTIONS, query: "/media/Media-Storage/Anime/", }, render: () => , } export const DisabledFirstOption: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES_DISABLED_FIRST, trigger: , }, render: () => ( ), } // 150 long folder names: over the ~100 auto-virtualize threshold, and in // a narrow (`w-72`) panel each wraps to two lines — the combination that // broke. The virtualizer estimates 36px/row, so a fixed-height row clipped // the second line and stacked the next row on top of it (overlapping // text). This is the regression guard for the measured-row fix. const MANY_LONG: ListboxItem[] = Array.from( { length: 150 }, (_unused, index) => { const name = `Armored Trooper Votoms - Very Long Chapter Title Number ${index + 1} [anidb-${1000 + index}]` return { label: `📁 ${name}`, textValue: name, value: name, } }, ) /** * The real-world break: **many** long option labels in a **narrow** panel, * so the list both virtualizes (over ~100 options) and wraps each row to * two lines. Rows are measured, not pinned to the 36px estimate, so they * lay out below one another instead of overlapping. * * Multi-select as well, so it exercises the case together: every picked * row keeps its own ✓ (a virtualized row that scrolls out and back is * re-rendered from `selected`, not remembered by the DOM), and the chips * above the trigger stay in sync with the ticks. */ export const VirtualizedLongOptions: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: MANY_LONG, trigger: , }, render: () => ( ), } /** * A chosen value far down the list. Opening lands on it — highlighted, * ticked and centred in the panel — rather than at the head of the list * with the current value scrolled out of sight. Reopening to correct a * misclick is then one glance, not a hunt. * * Not windowed (seven options), so this covers the plain DOM path; * `VirtualizedChosenValue` covers the virtualized one. */ export const ChosenValueOnOpen: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: MANY_SHORT, selectedValue: "item-58", trigger: , }, render: () => ( ), } /** * The same rule through the virtualizer: track 400 of 500 is chosen, so * opening scrolls the window to it instead of rendering rows 1–12. */ export const VirtualizedChosenValue: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: MANY, selectedValue: "track-400", trigger: , }, render: () => ( ), } /** * The three row sizes — and the fix the owner's screenshot asked for. * * The report was that a `Combobox`'s trigger was visibly bigger than * the options under it. Two things were true at once: the option row * was a hardcoded `px-2 py-1.5 text-sm` that no control shares, and * the panel's **own** search field was bigger than the options it * filters — two sizes inside one panel, which is the defect rather * than either number being wrong. * * `itemSize` now drives the search row and the options together, from * the same `--control-height-*` a `Button` reads. At the `md` default * a trigger, the search field and every option are all 2.25rem. */ export const ItemSizes: Story = { args: { isVisible: false, onDismiss: () => {}, onSelect: () => {}, options: LANGUAGES, trigger: , }, render: () => ( ), }