@forwardreach/saas-ui 0.10.4 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,6 @@
1
1
  import * as React from "react";
2
- export interface ComboboxOption {
3
- value: string;
4
- label: string;
5
- /** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
6
- trailing?: React.ReactNode;
7
- /**
8
- * Optional secondary text shown next to the label, in the reading order a
9
- * subtitle would take. Distinct from `trailing`, which is right-aligned and
10
- * monospaced; an option may carry both.
11
- */
12
- description?: React.ReactNode;
13
- /**
14
- * Optional heading this option sits under. Consecutive options declaring the
15
- * same group collapse into one heading, exactly as `<optgroup>` does — the
16
- * component never reorders, so interleaved groups (`A, B, A`) render three
17
- * headings, not two. Sort the list before passing it if that is not wanted.
18
- *
19
- * Grouped and ungrouped options may be mixed: an option declaring no group
20
- * ends the run above it and renders outside every group, the way an
21
- * `<option>` following an `</optgroup>` sits outside that group.
22
- */
23
- group?: string;
24
- }
2
+ import type { ComboboxOption } from "./option-list.js";
3
+ export type { ComboboxOption };
25
4
  export interface ComboboxProps {
26
5
  /** Full option list; the component filters it against the typed query. */
27
6
  options: ComboboxOption[];
@@ -35,8 +14,60 @@ export interface ComboboxProps {
35
14
  filter?: (option: ComboboxOption, query: string) => boolean;
36
15
  placeholder?: string;
37
16
  disabled?: boolean;
38
- /** Message shown when no option matches. Defaults to "No matches". */
17
+ /**
18
+ * Message shown when no option matches. Defaults to "No matches". Not shown
19
+ * while a create entry is offered — the popup is offering an action, so
20
+ * saying there is nothing to do contradicts the row underneath it.
21
+ */
39
22
  emptyMessage?: React.ReactNode;
23
+ /**
24
+ * Offer the typed query as a new option. When set, a trailing create entry
25
+ * joins the listbox whenever the trimmed query is non-empty and equals no
26
+ * option's label or value; choosing it calls this instead of
27
+ * `onValueChange`.
28
+ *
29
+ * The query arrives trimmed and otherwise raw. Normalization — casing,
30
+ * separators, a leading `#` — is the consumer's, because it is a product
31
+ * rule about what the created thing is called, not something this control
32
+ * can know.
33
+ */
34
+ onCreate?: (query: string) => void;
35
+ /** Label for the create entry. Defaults to `Create "<query>"`. */
36
+ createLabel?: (query: string) => React.ReactNode;
37
+ /**
38
+ * Decide whether an existing option already covers the typed query, which is
39
+ * what suppresses the create entry. Defaults to case-insensitive equality
40
+ * against an option's `label` or `value`.
41
+ *
42
+ * The default is case-*insensitive* rather than exact because the offer is
43
+ * only useful if it agrees with what `onCreate` will do, and casing is the
44
+ * cheapest way for the two to disagree: with `urgent` in the list, typing
45
+ * `Urgent` under an exact comparison offers "Create Urgent", the consumer
46
+ * lowercases it on create, and the user has taken an action that either did
47
+ * nothing or made a duplicate.
48
+ *
49
+ * Casing is only the common half. A consumer whose normalization does more —
50
+ * hyphenating spaces, stripping a leading `#` — owns the rest, and this is
51
+ * where it says so, because the component cannot guess the rule it was
52
+ * deliberately not given in {@link ComboboxProps.onCreate}.
53
+ */
54
+ isDuplicate?: (query: string, option: ComboboxOption) => boolean;
55
+ /**
56
+ * Leave the popup open after a choice is committed, clearing the query for
57
+ * the next one instead of closing.
58
+ *
59
+ * For the caller whose control adds to a set rather than setting a value — a
60
+ * tag picker, an attendee list — where closing after each pick makes adding
61
+ * three things cost three round trips through opening the control. Such a
62
+ * caller typically pairs this with `value=""`, since a control that holds no
63
+ * selection has nothing to display once the choice has been applied
64
+ * elsewhere.
65
+ *
66
+ * Off by default: for an ordinary single-select, staying open after the one
67
+ * choice has been made is the wrong behaviour, and closing is what says the
68
+ * choice registered.
69
+ */
70
+ keepOpenOnSelect?: boolean;
40
71
  id?: string;
41
72
  name?: string;
42
73
  "aria-label"?: string;
@@ -50,5 +81,10 @@ export interface ComboboxProps {
50
81
  * highlight with wrap-around and scroll-into-view, Enter commits the active
51
82
  * option, Escape closes, and losing focus closes without committing a
52
83
  * partial value. Selection submits via a hidden input when `name` is set.
84
+ *
85
+ * For the list where search *is* the interaction — too long to read, or one
86
+ * the consumer offers to add to. A short list that only wants a themed popup is
87
+ * `SelectMenu`'s case; the four single-choice controls and which case each
88
+ * answers are tabled once, in `docs/packages/saas-ui.md`.
53
89
  */
54
90
  export declare const Combobox: React.ForwardRefExoticComponent<ComboboxProps & React.RefAttributes<HTMLInputElement>>;
@@ -1,15 +1,22 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { ChevronDown } from "lucide-react";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { ChevronDown, Plus } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { cn } from "../utils/cn.js";
6
6
  import { useFormFieldProps } from "./form-field.js";
7
- function defaultFilter(option, query) {
8
- const normalized = query.trim().toLowerCase().replaceAll(" ", "_");
9
- if (!normalized)
10
- return true;
11
- return (option.value.toLowerCase().includes(normalized) ||
12
- option.label.toLowerCase().replaceAll(" ", "_").includes(normalized));
7
+ import { OptionListbox, OptionRow, defaultOptionFilter, nextActiveIndex, optionElementId, scrollOptionIntoView, } from "./option-list.js";
8
+ /**
9
+ * The create entry's stand-in `value`. It exists only so the entry can travel
10
+ * through the same `ComboboxOption` shape as every real option — sharing the
11
+ * index space, the `data-index` lookup, and the `aria-activedescendant` id —
12
+ * rather than needing a parallel keyboard path beside the list. `commit`
13
+ * routes on it and it never reaches `onValueChange`.
14
+ */
15
+ const CREATE_ENTRY_VALUE = "__ssui_combobox_create__";
16
+ function defaultIsDuplicate(query, option) {
17
+ const normalized = query.toLowerCase();
18
+ return (option.value.toLowerCase() === normalized ||
19
+ option.label.toLowerCase() === normalized);
13
20
  }
14
21
  /**
15
22
  * Filterable single-select following the WAI-ARIA combobox pattern: a text
@@ -17,25 +24,91 @@ function defaultFilter(option, query) {
17
24
  * highlight with wrap-around and scroll-into-view, Enter commits the active
18
25
  * option, Escape closes, and losing focus closes without committing a
19
26
  * partial value. Selection submits via a hidden input when `name` is set.
27
+ *
28
+ * For the list where search *is* the interaction — too long to read, or one
29
+ * the consumer offers to add to. A short list that only wants a themed popup is
30
+ * `SelectMenu`'s case; the four single-choice controls and which case each
31
+ * answers are tabled once, in `docs/packages/saas-ui.md`.
20
32
  */
21
- export const Combobox = React.forwardRef(({ options, value, onValueChange, filter = defaultFilter, placeholder, disabled, emptyMessage = "No matches", className, name, ...ariaProps }, ref) => {
33
+ export const Combobox = React.forwardRef(({ options, value, onValueChange, filter = defaultOptionFilter, placeholder, disabled, emptyMessage = "No matches", onCreate, createLabel, isDuplicate = defaultIsDuplicate, keepOpenOnSelect = false, className, name, ...ariaProps }, ref) => {
22
34
  const fieldProps = useFormFieldProps(ariaProps);
23
35
  const generatedId = React.useId();
24
36
  const id = fieldProps.id ?? generatedId;
25
37
  const listboxId = `${id}-listbox`;
38
+ const currentValueId = `${id}-current-value`;
26
39
  const [open, setOpen] = React.useState(false);
27
40
  const [query, setQuery] = React.useState(null);
28
41
  const [activeIndex, setActiveIndex] = React.useState(-1);
29
42
  const listRef = React.useRef(null);
30
43
  const selectedOption = options.find((option) => option.value === value);
31
- const filtered = query === null
32
- ? options
33
- : options.filter((option) => filter(option, query));
34
- const inputValue = query ?? selectedOption?.label ?? "";
35
- const activeOption = activeIndex >= 0 ? filtered[activeIndex] : undefined;
44
+ // The predicate runs even with nothing typed, against an empty query. It used to be
45
+ // skipped entirely in that state, on the reasoning that "no query" means "show
46
+ // everything" — true of a predicate that only *searches*, and false of one that also
47
+ // *excludes*. A consumer hiding options it must not offer (an already-applied tag, a
48
+ // record already named) had its rule silently ignored for exactly as long as the field
49
+ // was empty, which is the state the popup opens in. `defaultFilter` returns true for
50
+ // an empty query, so nothing changes for a consumer that only searches.
51
+ const filtered = options.filter((option) => filter(option, query ?? ""));
52
+ // What the field shows: the live query while searching, the committed option's
53
+ // own text otherwise. `inputLabel` lets an option carry a fuller form for the
54
+ // collapsed field than the listbox row needs.
55
+ const committedLabel = selectedOption?.inputLabel ?? selectedOption?.label ?? "";
56
+ const inputValue = query ?? committedLabel;
57
+ // The committed value moves into the placeholder while searching, which is a
58
+ // presentation trick with two costs to pay off.
59
+ //
60
+ // Colour: a placeholder is styled `--ssui-text-subtle`, which is the pairing
61
+ // this release retired from `ChoiceCard`'s description for measuring 2.87:1.
62
+ // That is the right weight for a hint the user is meant to type over and the
63
+ // wrong one for the answer to "what is this set to right now?", so the value
64
+ // takes `--ssui-text-muted` and only a true placeholder stays subtle.
65
+ //
66
+ // Announcement: screen-reader handling of placeholder text is inconsistent
67
+ // and never guaranteed, so with the box cleared on focus a non-sighted user
68
+ // could lose the current value entirely. `aria-describedby` gives it a
69
+ // programmatic home that does not depend on how the placeholder is treated.
70
+ const showingValueAsPlaceholder = open && committedLabel !== "";
71
+ const describedBy = [fieldProps["aria-describedby"], showingValueAsPlaceholder ? currentValueId : null]
72
+ .filter(Boolean)
73
+ .join(" ") || undefined;
74
+ // Offered only when there is something to create that is not already there:
75
+ // a "Create X" row sitting above an existing X is an offer to do nothing.
76
+ // The comparison is against the whole option list rather than the filtered
77
+ // one, because a match hidden by a consumer's own `filter` is still an
78
+ // existing option. `isDuplicate` decides what counts as a match, and its
79
+ // default is case-insensitive — see the prop's own note for why exact
80
+ // equality is the wrong default here.
81
+ const trimmedQuery = (query ?? "").trim();
82
+ const createEntry = onCreate !== undefined &&
83
+ trimmedQuery.length > 0 &&
84
+ !options.some((option) => isDuplicate(trimmedQuery, option))
85
+ ? { value: CREATE_ENTRY_VALUE, label: trimmedQuery }
86
+ : null;
87
+ // The one list the keyboard model runs on. Appending the create entry here
88
+ // rather than rendering it as a footer is the whole of its integration:
89
+ // wrap-around, `activeIndex`, scroll-into-view and `aria-activedescendant`
90
+ // all keep working with no branch for it.
91
+ const navigable = createEntry ? [...filtered, createEntry] : filtered;
92
+ const activeOption = activeIndex >= 0 ? navigable[activeIndex] : undefined;
36
93
  function openList() {
37
94
  setOpen(true);
38
95
  setActiveIndex(-1);
96
+ // Focusing starts a search, and a search starts empty.
97
+ //
98
+ // Leaving the committed label in the box made the field read as editable
99
+ // text holding the setting: a caret landed in the middle of
100
+ // "America/New_York", typing appended to it, and the result matched
101
+ // nothing. The user had to notice their own value was in the way and clear
102
+ // it before the control would do the one thing it exists to do. It also
103
+ // invited the reasonable belief that the text was the value and could be
104
+ // corrected in place, which it never was — an uncommitted query is
105
+ // discarded on close.
106
+ //
107
+ // Empty on focus keeps the two modes distinct: unfocused the field displays
108
+ // a value, focused it takes a query. The value is not lost while searching —
109
+ // it becomes the placeholder below — and it cannot be edited into something
110
+ // that was never a choice.
111
+ setQuery("");
39
112
  }
40
113
  function close() {
41
114
  setOpen(false);
@@ -43,38 +116,28 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
43
116
  setActiveIndex(-1);
44
117
  }
45
118
  function commit(option) {
46
- onValueChange?.(option.value);
119
+ if (option.value === CREATE_ENTRY_VALUE) {
120
+ onCreate?.(option.label);
121
+ }
122
+ else {
123
+ onValueChange?.(option.value);
124
+ }
125
+ if (keepOpenOnSelect) {
126
+ // Back to the state a freshly opened popup is in — empty query, nothing
127
+ // active — rather than closed. `setQuery("")` and not `setQuery(null)`:
128
+ // null means "show the committed value", and this control is mid-search.
129
+ setQuery("");
130
+ setActiveIndex(-1);
131
+ return;
132
+ }
47
133
  close();
48
134
  }
49
135
  function moveActive(delta) {
50
- if (filtered.length === 0)
136
+ if (navigable.length === 0)
51
137
  return;
52
- const next = activeIndex < 0
53
- ? delta === 1
54
- ? 0
55
- : filtered.length - 1
56
- : (activeIndex + delta + filtered.length) % filtered.length;
138
+ const next = nextActiveIndex(activeIndex, delta, navigable.length);
57
139
  setActiveIndex(next);
58
- const element = listRef.current?.querySelector(`[data-index="${next}"]`);
59
- // Arriving at the first option of a group brings that group's heading into
60
- // view as well as the option, so a keyboard user entering a group can see
61
- // which one they are in. Both, and in this order — scrolling only the
62
- // option leaves the 16px heading clipped above the scrollport, and
63
- // scrolling only the heading is worse, because entering a group from below
64
- // aligns the heading's bottom edge with the scrollport's and leaves the
65
- // newly highlighted option out of view entirely.
66
- //
67
- // Two `nearest` calls settle both directions. Downward: the first brings
68
- // the heading to the bottom edge, the second scrolls one option further,
69
- // leaving heading and option both visible. Upward: the first aligns the
70
- // heading to the top and the second is a no-op, the option having come
71
- // with it. Never losing the highlight is the constraint; showing the
72
- // heading is the preference.
73
- const previous = element?.previousElementSibling ?? null;
74
- if (previous?.getAttribute("role") === "presentation") {
75
- previous.scrollIntoView({ block: "nearest" });
76
- }
77
- element?.scrollIntoView({ block: "nearest" });
140
+ scrollOptionIntoView(listRef.current, next);
78
141
  }
79
142
  function handleKeyDown(event) {
80
143
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
@@ -108,45 +171,34 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
108
171
  }
109
172
  }
110
173
  }
111
- const rows = [];
112
- filtered.forEach((option, index) => {
113
- const last = rows[rows.length - 1];
114
- if (option.group === undefined) {
115
- rows.push({ kind: "option", entry: { option, index } });
116
- return;
117
- }
118
- if (last?.kind === "group" && last.group === option.group) {
119
- last.entries.push({ option, index });
120
- return;
121
- }
122
- rows.push({ kind: "group", group: option.group, entries: [{ option, index }] });
123
- });
124
- function renderOption({ option, index }) {
125
- return (_jsxs("button", { "aria-selected": option.value === value, className: cn("flex w-full items-center justify-between gap-3 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-left text-sm transition-colors", index === activeIndex && "bg-[color:var(--ssui-overlay-hover)]", option.value === value
126
- ? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
127
- : "text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]"), "data-index": index, id: `${id}-option-${option.value}`, onClick: () => commit(option), onMouseDown: (event) => event.preventDefault(), onMouseMove: () => setActiveIndex(index), role: "option", tabIndex: -1, type: "button", children: [option.description !== undefined ? (_jsxs("span", { className: "flex min-w-0 items-baseline gap-1.5", children: [_jsx("span", { className: "truncate", children: option.label }), _jsx("span", { className: "truncate text-xs text-[color:var(--ssui-text-subtle)]", children: option.description })] })) : (_jsx("span", { className: "truncate", children: option.label })), option.trailing !== undefined ? (_jsx("span", { className: "shrink-0 font-mono text-xs text-[color:var(--ssui-text-subtle)]", children: option.trailing })) : null] }, option.value));
174
+ // The create entry is a row of its own kind — an action, not a value — but
175
+ // it is rendered through the same row primitive so wrap-around,
176
+ // `activeIndex`, scroll-into-view and `aria-activedescendant` all keep
177
+ // working with no branch for it.
178
+ function renderCreateEntry(option, index) {
179
+ return (_jsx(OptionRow, { active: index === activeIndex, idPrefix: id, index: index, onActivate: setActiveIndex, onCommit: commit, option: option, selected: false, children: _jsxs("span", { className: "flex min-w-0 items-center gap-3", children: [_jsx(Plus, { "aria-hidden": "true", className: "size-3.5 shrink-0" }), _jsx("span", { className: "min-w-0 truncate", children: createLabel ? (createLabel(option.label)) : (_jsxs(_Fragment, { children: ["Create", " ", _jsx("span", { className: "font-medium text-[color:var(--ssui-text)]", children: option.label })] })) })] }) }, option.value));
128
180
  }
129
181
  return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
130
182
  if (!event.currentTarget.contains(event.relatedTarget)) {
131
183
  close();
132
184
  }
133
- }, children: [_jsx("input", { ...fieldProps, ref: ref, id: id, role: "combobox", type: "text", "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": open, "aria-haspopup": "listbox", "aria-activedescendant": open && activeOption ? `${id}-option-${activeOption.value}` : undefined, autoComplete: "off", className: "flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", disabled: disabled, onChange: (event) => {
185
+ }, children: [_jsx("input", { ...fieldProps, ref: ref, id: id, role: "combobox", type: "text", "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": open, "aria-haspopup": "listbox", "aria-activedescendant": open && activeOption ? optionElementId(id, activeOption.value) : undefined, "aria-describedby": describedBy, autoComplete: "off", className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70",
186
+ // Content, not a hint. `cn` is tailwind-merge, so this replaces the
187
+ // subtle placeholder colour above rather than racing it.
188
+ showingValueAsPlaceholder &&
189
+ "placeholder:text-[color:var(--ssui-text-muted)]"), disabled: disabled, onChange: (event) => {
134
190
  setQuery(event.target.value);
135
191
  setOpen(true);
136
192
  setActiveIndex(-1);
137
- }, onFocus: openList, onKeyDown: handleKeyDown, placeholder: placeholder, value: inputValue }), _jsx("button", { "aria-label": "Show options", className: "absolute right-1 top-1 grid size-8 place-items-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)] disabled:pointer-events-none disabled:opacity-50", disabled: disabled, onClick: () => {
138
- setQuery(null);
193
+ }, onFocus: openList, onKeyDown: handleKeyDown,
194
+ // While searching, the committed value takes the placeholder's place, so
195
+ // emptying the box to type never hides what is currently set.
196
+ placeholder: open && committedLabel ? committedLabel : placeholder, value: inputValue }), _jsx("button", { "aria-label": "Show options", className: "absolute right-1 top-1 grid size-8 place-items-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)] disabled:pointer-events-none disabled:opacity-50", disabled: disabled, onClick: () => {
139
197
  openList();
140
198
  document.getElementById(id)?.focus();
141
- }, tabIndex: -1, type: "button", children: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) }), name ? _jsx("input", { name: name, type: "hidden", value: value ?? "" }) : null, open ? (_jsx("div", { className: "absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 shadow-[var(--ssui-shadow-md)]", id: listboxId, ref: listRef, role: "listbox", children: rows.length > 0 ? (rows.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
142
- // A run is wrapped in `role="group"` labelled by its heading —
143
- // the APG grouped-listbox shape. Without it the grouping is
144
- // conveyed to sighted users only: a roleless heading is not in
145
- // the listbox's content model, so a screen-reader user hears a
146
- // flat list of options and never learns which kind each is.
147
- // The heading takes `role="presentation"` so it stays out of
148
- // that content model while `aria-labelledby` still names the
149
- // group from its text.
150
- _jsxs("div", { "aria-labelledby": `${id}-group-${rowIndex}`, role: "group", children: [_jsx("div", { className: cn("px-2 pb-0.5 pt-4 text-xs font-medium uppercase tracking-wide text-[color:var(--ssui-text-subtle)]", rowIndex === 0 && "pt-1"), id: `${id}-group-${rowIndex}`, role: "presentation", children: row.group }), row.entries.map(renderOption)] }, `group-${rowIndex}`)))) : (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) })) : null] }));
199
+ }, tabIndex: -1, type: "button", children: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) }), showingValueAsPlaceholder ? (_jsx("span", { className: "sr-only", id: currentValueId, children: `Current selection: ${committedLabel}` })) : null, name ? _jsx("input", { name: name, type: "hidden", value: value ?? "" }) : null, open ? (_jsx(OptionListbox, { activeIndex: activeIndex, className: "absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 shadow-[var(--ssui-shadow-md)]",
200
+ // The create entry is an action the reader can take, so saying there
201
+ // is nothing to do would contradict the row underneath it.
202
+ emptyMessage: createEntry ? null : emptyMessage, id: listboxId, idPrefix: id, onActivate: setActiveIndex, onCommit: commit, options: filtered, ref: listRef, value: value, children: createEntry ? renderCreateEntry(createEntry, filtered.length) : null })) : null] }));
151
203
  });
152
204
  Combobox.displayName = "Combobox";
@@ -24,11 +24,13 @@ export * from "./list-shell.js";
24
24
  export * from "./login.js";
25
25
  export * from "./overflow-menu.js";
26
26
  export * from "./page-header.js";
27
+ export * from "./popover.js";
27
28
  export * from "./rail-toggle.js";
28
29
  export * from "./request-access.js";
29
30
  export * from "./role-menu.js";
30
31
  export * from "./scroll-area.js";
31
32
  export * from "./search-input.js";
33
+ export * from "./select-menu.js";
32
34
  export * from "./select.js";
33
35
  export * from "./separator.js";
34
36
  export * from "./settings-layout.js";
@@ -24,11 +24,13 @@ export * from "./list-shell.js";
24
24
  export * from "./login.js";
25
25
  export * from "./overflow-menu.js";
26
26
  export * from "./page-header.js";
27
+ export * from "./popover.js";
27
28
  export * from "./rail-toggle.js";
28
29
  export * from "./request-access.js";
29
30
  export * from "./role-menu.js";
30
31
  export * from "./scroll-area.js";
31
32
  export * from "./search-input.js";
33
+ export * from "./select-menu.js";
32
34
  export * from "./select.js";
33
35
  export * from "./separator.js";
34
36
  export * from "./settings-layout.js";
@@ -2,5 +2,5 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../utils/cn.js";
4
4
  import { useFormFieldProps } from "./form-field.js";
5
- export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
5
+ export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
6
6
  Input.displayName = "Input";
@@ -0,0 +1,140 @@
1
+ import * as React from "react";
2
+ /**
3
+ * One option, in the shape both `Combobox` and `SelectMenu` take. Two controls
4
+ * presenting the same list should not need two option types, so the shape lives
5
+ * here and each component re-exports it under the name its consumers know.
6
+ */
7
+ export interface ComboboxOption {
8
+ value: string;
9
+ label: string;
10
+ /** Optional trailing content (e.g. a GMT offset) shown right-aligned. */
11
+ trailing?: React.ReactNode;
12
+ /**
13
+ * Optional secondary text shown next to the label, in the reading order a
14
+ * subtitle would take. Distinct from `trailing`, which is right-aligned and
15
+ * monospaced; an option may carry both.
16
+ */
17
+ description?: React.ReactNode;
18
+ /**
19
+ * Text the closed control shows once this option is committed, when that
20
+ * should differ from `label`. Defaults to `label`.
21
+ *
22
+ * For an option whose `trailing` content is part of its identity rather than
23
+ * decoration — a time zone's GMT offset, a currency's symbol — the collapsed
24
+ * control would otherwise drop it: `trailing` is a `ReactNode` rendered into
25
+ * the listbox row, and an input can only display a string. This is that
26
+ * string. `SelectMenu`'s trigger shows it too, for the same reason; the name
27
+ * predates that component and stays because renaming it would break shipped
28
+ * consumers to fix a word.
29
+ */
30
+ inputLabel?: string;
31
+ /**
32
+ * Optional heading this option sits under. Consecutive options declaring the
33
+ * same group collapse into one heading, exactly as `<optgroup>` does — the
34
+ * component never reorders, so interleaved groups (`A, B, A`) render three
35
+ * headings, not two. Sort the list before passing it if that is not wanted.
36
+ *
37
+ * Grouped and ungrouped options may be mixed: an option declaring no group
38
+ * ends the run above it and renders outside every group, the way an
39
+ * `<option>` following an `</optgroup>` sits outside that group.
40
+ */
41
+ group?: string;
42
+ }
43
+ /**
44
+ * Case-insensitive substring match on the option's value, label and
45
+ * `inputLabel`, with spaces treated as underscores so `new york` finds
46
+ * `America/New_York`. Returns true for an empty query.
47
+ */
48
+ export declare function defaultOptionFilter(option: ComboboxOption, query: string): boolean;
49
+ /**
50
+ * The DOM id of an option row, which `aria-activedescendant` points at.
51
+ *
52
+ * The value is a consumer's data — a user-defined field's option is whatever a
53
+ * person typed — and an id may not contain whitespace, while the single-IDREF
54
+ * attribute pointing at it has to resolve in every browser and screen reader.
55
+ * So the value is encoded into `[A-Za-z0-9-]`: every other character, `_`
56
+ * included so the encoding stays one-to-one, becomes `_<code point in hex>_`.
57
+ * Distinct values give distinct ids; a value that already fits passes through
58
+ * unchanged.
59
+ */
60
+ export declare function optionElementId(prefix: string, value: string): string;
61
+ /**
62
+ * The next highlight position after one arrow press: from nothing highlighted,
63
+ * down lands on the first entry and up on the last; otherwise the highlight
64
+ * wraps at both ends. `-1` when there is nothing to highlight.
65
+ */
66
+ export declare function nextActiveIndex(current: number, delta: 1 | -1, length: number): number;
67
+ /**
68
+ * Bring the highlighted row into view, and its group heading with it.
69
+ *
70
+ * Arriving at the first option of a group brings that group's heading into
71
+ * view as well as the option, so a keyboard user entering a group can see which
72
+ * one they are in. Both, and in this order — scrolling only the option leaves
73
+ * the 16px heading clipped above the scrollport, and scrolling only the heading
74
+ * is worse, because entering a group from below aligns the heading's bottom
75
+ * edge with the scrollport's and leaves the newly highlighted option out of
76
+ * view entirely.
77
+ *
78
+ * Two `nearest` calls settle both directions. Downward: the first brings the
79
+ * heading to the bottom edge, the second scrolls one option further, leaving
80
+ * heading and option both visible. Upward: the first aligns the heading to the
81
+ * top and the second is a no-op, the option having come with it. Never losing
82
+ * the highlight is the constraint; showing the heading is the preference.
83
+ */
84
+ export declare function scrollOptionIntoView(list: HTMLElement | null, index: number): void;
85
+ export interface OptionRowProps {
86
+ option: ComboboxOption;
87
+ /** Position in the navigable list; what `data-index` and the highlight key on. */
88
+ index: number;
89
+ /** Highlighted by keyboard or pointer. */
90
+ active: boolean;
91
+ /** The committed value, marked to assistive technology as well as visually. */
92
+ selected: boolean;
93
+ /** Prefix for the row's DOM id — the owning control's id. */
94
+ idPrefix: string;
95
+ onCommit: (option: ComboboxOption) => void;
96
+ onActivate: (index: number) => void;
97
+ /**
98
+ * Replaces the row's label and description, for a row that is an action
99
+ * rather than an option — `Combobox`'s create entry. The option's `trailing`
100
+ * content and the selected mark are the row's own and still render after it,
101
+ * so a custom row carrying either is marked like any other. Pass a row that
102
+ * needs neither, or leave those fields unset on its option.
103
+ */
104
+ children?: React.ReactNode;
105
+ }
106
+ /** One `role="option"` row. Pointer moves highlight it; clicks commit it. */
107
+ export declare function OptionRow({ option, index, active, selected, idPrefix, onCommit, onActivate, children, }: OptionRowProps): import("react/jsx-runtime").JSX.Element;
108
+ export interface OptionListboxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "id" | "role"> {
109
+ /** The listbox's own DOM id, which the controlling element's `aria-controls` names. */
110
+ id: string;
111
+ /** Prefix for option and heading ids — the owning control's id. */
112
+ idPrefix: string;
113
+ /** The options to present, already filtered. Rendered in the order given. */
114
+ options: ComboboxOption[];
115
+ /** The committed value. */
116
+ value?: string;
117
+ /** Index into `options` of the highlighted row, or `-1`. */
118
+ activeIndex: number;
119
+ onActivate: (index: number) => void;
120
+ onCommit: (option: ComboboxOption) => void;
121
+ /**
122
+ * Shown when `options` is empty. Pass `null` to show nothing — for a caller
123
+ * that is rendering an action row of its own into `children` and so has not
124
+ * left the reader with nothing to do.
125
+ */
126
+ emptyMessage?: React.ReactNode;
127
+ /** Rows rendered after the options, inside the listbox — `Combobox`'s create entry. */
128
+ children?: React.ReactNode;
129
+ }
130
+ /**
131
+ * The popup list both `Combobox` and `SelectMenu` render: options walked into
132
+ * grouped runs under headings, each row a `role="option"` button, the highlight
133
+ * driven from outside by `activeIndex`. It owns no positioning and no keyboard
134
+ * handling — the owner decides which element holds focus and where the list
135
+ * sits — so the surrounding chrome is the owner's `className`.
136
+ *
137
+ * Internal to the package. Not exported from the barrel, so it is not an API
138
+ * this package has to keep.
139
+ */
140
+ export declare const OptionListbox: React.ForwardRefExoticComponent<OptionListboxProps & React.RefAttributes<HTMLDivElement>>;