@forwardreach/saas-ui 0.11.0 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **`SelectMenu`: a single choice whose popup this package draws.** A native
8
+ `<select>` styled down to a line of text has exactly one piece of chrome left —
9
+ the platform's own popup — and it is the one piece CSS cannot reach. This is
10
+ the component for that case. It is additive in API — no existing export
11
+ changes shape — and the one existing call site that renders differently is
12
+ named below.
13
+
14
+ - **Two trigger presentations.** `variant="control"` (the default) is `Select`'s
15
+ bounded field with a chevron, in the same `sm`/`md` scale, so swapping one for
16
+ the other returns the same field with a themed popup. `variant="inline"` draws
17
+ no box at rest: the trigger is the label's own text at the size of the line it
18
+ sits in, with the hover surface, the chevron, and a focus ring without an
19
+ offset arriving on hover, focus, and while open. The inline variant is why the
20
+ component exists, and shipping it is what keeps a consumer from forcing a 40px
21
+ bordered control down to a 20px line through `className` and child selectors.
22
+ - **The same options `Combobox` takes**, groups included: consecutive options
23
+ declaring one group collapse under one heading, an ungrouped option ends the
24
+ run above it, and the component never reorders. The option list, its keyboard
25
+ model, and its group semantics are `Combobox`'s own, extracted into an
26
+ internal module both components render — `Combobox`'s suite passes unedited
27
+ across the extraction.
28
+ - **A filter once the list is long enough**, at `SELECT_MENU_SEARCH_THRESHOLD`
29
+ options (eight) and not before; `searchable` forces it either way. With a
30
+ filter, focus stays in the field while the list is navigated; without one, the
31
+ listbox itself holds focus. Both run the listbox pattern through
32
+ `aria-activedescendant`.
33
+ - **`emptyLabel`** offers clearing the value as a row that reports `""`.
34
+ **`placeholder`** is what the trigger shows when no option matches `value` — a
35
+ field holding nothing, or one holding a value the list no longer offers, which
36
+ is neither shown as chosen nor replaced by whatever happens to be first.
37
+ - **`defaultOpen`, `open`, `onOpenChange`**, for the consumer whose control
38
+ appears in response to an edit gesture: open the popup as the row enters edit
39
+ mode, so choosing costs one click rather than two, and read a close that
40
+ followed no `onValueChange` as a cancel.
41
+ - **Form participation and `FormField` wiring** exactly as `RoleMenu` does
42
+ them: a hidden input when `name` is set, nothing submitted while disabled, the
43
+ enclosing field's label naming the trigger, and `aria-label` composing purpose
44
+ with the current value.
45
+
46
+ **The option list is drawn to be read as the choice**, which is the one
47
+ visible change to an existing component: `Combobox` renders the same list, so
48
+ its popup picks all three corrections up. Options take the full text colour
49
+ rather than the muted one, which came from a popup that hangs under a field
50
+ holding the answer; where the popup *is* the choice, options dimmer than the
51
+ heading over them read as less available than the label for them. The current
52
+ option carries a check and a medium weight rather than a fill alone, because
53
+ that fill is `--ssui-surface-muted` on `--ssui-surface-elevated` — a clear step
54
+ in a light theme and almost none in a dark one. And the keyboard highlight now
55
+ wins the background over the selected fill, so it stays visible on the selected
56
+ row, which for `SelectMenu` is the row a popup opens on.
57
+
58
+ **A bounded popup starts at the field's own width** and grows only if an option
59
+ needs more, so the two read as one control. It used to carry a fixed minimum for
60
+ both variants, which made the popup of any field narrower than that minimum
61
+ overhang it. An inline trigger is as wide as its text, which is no width for a
62
+ list, so it keeps a floor of its own.
63
+
64
+ **`Popover`** — `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`,
65
+ `PopoverClose`, `PopoverPortal` — is exported alongside it: the thin styled
66
+ wrapper `DropdownMenu` already has, on `@radix-ui/react-popover`, a new direct
67
+ dependency sharing its internals with the dialog and dropdown-menu packages
68
+ already here. `SelectMenu` is built on it rather than on `DropdownMenu` because a
69
+ menu's roving focus and typeahead both fight a filter field.
70
+
71
+ **What this does not do, on purpose.** It does not replace `Select`. On a phone
72
+ a native `<select>` opens the platform's own picker — thumb-sized, familiar,
73
+ outside the page — and `SelectMenu` cannot and will not. For a bounded field in
74
+ an ordinary form that is worth more than a themed popup, and `Select` stays the
75
+ default there. `docs/packages/saas-ui.md` now tables all four single-choice
76
+ controls — `Select`, `SelectMenu`, `Combobox`, `RoleMenu` — and which case each
77
+ answers, and the three older components' doc comments point at that table
78
+ rather than carrying partial copies of it. No multi-select: a chip-holding
79
+ trigger and checkable rows roughly double the API and the test matrix, and are
80
+ a change of their own.
81
+
3
82
  ## 0.11.0
4
83
 
5
84
  ### Minor Changes
@@ -64,7 +143,7 @@
64
143
  - **`ChoiceCard` no longer misreports what was saved.** React resets a
65
144
  `<form action={fn}>` once the action resolves, and a reset restores every
66
145
  control to its content attribute. A controlled radio only ever had its
67
- `checked` *property* written, so after a successful save the group snapped
146
+ `checked` _property_ written, so after a successful save the group snapped
68
147
  back to whatever was selected on first render, while the surrounding state,
69
148
  the hidden inputs, and the stored value all held the new selection. The write
70
149
  succeeded and only the control disagreed, which reads to a user as "my change
@@ -85,7 +164,7 @@
85
164
 
86
165
  - **A consumer's `filter` now runs with an empty query** rather than being
87
166
  skipped while nothing is typed. A predicate that only searches is
88
- unaffected; one that *excludes* had its rule ignored for exactly as long as
167
+ unaffected; one that _excludes_ had its rule ignored for exactly as long as
89
168
  the field was empty, which is the state the popup opens in.
90
169
 
91
170
  Two more came out of the same consumer looking at the running app a second
@@ -116,7 +195,7 @@
116
195
 
117
196
  - **The list variant no longer clips its own focus ring.** The container
118
197
  carried `overflow-hidden` to keep the first and last row's fills inside its
119
- radius. A Tailwind ring is a box-shadow drawn *outside* the element, and a
198
+ radius. A Tailwind ring is a box-shadow drawn _outside_ the element, and a
120
199
  list row has no border or radius of its own, so it fills the container's
121
200
  padding box exactly — the clip amputated both vertical sides of every row's
122
201
  focus indicator plus the outer edges of the first and last. A keyboard user
@@ -163,7 +242,7 @@
163
242
 
164
243
  This was established the expensive way: a third attempt generated and signed a
165
244
  sigstore bundle and the registry rejected it with `422 ... Unsupported GitHub
166
- Actions source repository visibility: "private"`. That attempt published
245
+ Actions source repository visibility: "private"`. That attempt published
167
246
  nothing, so `0.10.4` is the current release.
168
247
 
169
248
  ## 0.10.3
@@ -1,37 +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
- * Text the field shows once this option is committed, when that should differ
15
- * from `label`. Defaults to `label`.
16
- *
17
- * For an option whose `trailing` content is part of its identity rather than
18
- * decoration — a time zone's GMT offset, a currency's symbol — the collapsed
19
- * field would otherwise drop it: `trailing` is a `ReactNode` rendered into the
20
- * listbox row, and an input can only display a string. This is that string.
21
- */
22
- inputLabel?: string;
23
- /**
24
- * Optional heading this option sits under. Consecutive options declaring the
25
- * same group collapse into one heading, exactly as `<optgroup>` does — the
26
- * component never reorders, so interleaved groups (`A, B, A`) render three
27
- * headings, not two. Sort the list before passing it if that is not wanted.
28
- *
29
- * Grouped and ungrouped options may be mixed: an option declaring no group
30
- * ends the run above it and renders outside every group, the way an
31
- * `<option>` following an `</optgroup>` sits outside that group.
32
- */
33
- group?: string;
34
- }
2
+ import type { ComboboxOption } from "./option-list.js";
3
+ export type { ComboboxOption };
35
4
  export interface ComboboxProps {
36
5
  /** Full option list; the component filters it against the typed query. */
37
6
  options: ComboboxOption[];
@@ -112,5 +81,10 @@ export interface ComboboxProps {
112
81
  * highlight with wrap-around and scroll-into-view, Enter commits the active
113
82
  * option, Escape closes, and losing focus closes without committing a
114
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`.
115
89
  */
116
90
  export declare const Combobox: React.ForwardRefExoticComponent<ComboboxProps & React.RefAttributes<HTMLInputElement>>;
@@ -1,9 +1,10 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
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
+ import { OptionListbox, OptionRow, defaultOptionFilter, nextActiveIndex, optionElementId, scrollOptionIntoView, } from "./option-list.js";
7
8
  /**
8
9
  * The create entry's stand-in `value`. It exists only so the entry can travel
9
10
  * through the same `ComboboxOption` shape as every real option — sharing the
@@ -12,19 +13,6 @@ import { useFormFieldProps } from "./form-field.js";
12
13
  * routes on it and it never reaches `onValueChange`.
13
14
  */
14
15
  const CREATE_ENTRY_VALUE = "__ssui_combobox_create__";
15
- function defaultFilter(option, query) {
16
- const normalized = query.trim().toLowerCase().replaceAll(" ", "_");
17
- if (!normalized)
18
- return true;
19
- return (option.value.toLowerCase().includes(normalized) ||
20
- option.label.toLowerCase().replaceAll(" ", "_").includes(normalized) ||
21
- // `inputLabel` is what the collapsed field showed, so it is what a user
22
- // retypes after focusing clears the box. Searching only `label` would leave
23
- // an option findable by a string it never displayed and unfindable by the
24
- // one it did.
25
- (option.inputLabel !== undefined &&
26
- option.inputLabel.toLowerCase().replaceAll(" ", "_").includes(normalized)));
27
- }
28
16
  function defaultIsDuplicate(query, option) {
29
17
  const normalized = query.toLowerCase();
30
18
  return (option.value.toLowerCase() === normalized ||
@@ -36,8 +24,13 @@ function defaultIsDuplicate(query, option) {
36
24
  * highlight with wrap-around and scroll-into-view, Enter commits the active
37
25
  * option, Escape closes, and losing focus closes without committing a
38
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`.
39
32
  */
40
- export const Combobox = React.forwardRef(({ options, value, onValueChange, filter = defaultFilter, placeholder, disabled, emptyMessage = "No matches", onCreate, createLabel, isDuplicate = defaultIsDuplicate, keepOpenOnSelect = false, 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) => {
41
34
  const fieldProps = useFormFieldProps(ariaProps);
42
35
  const generatedId = React.useId();
43
36
  const id = fieldProps.id ?? generatedId;
@@ -142,32 +135,9 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
142
135
  function moveActive(delta) {
143
136
  if (navigable.length === 0)
144
137
  return;
145
- const next = activeIndex < 0
146
- ? delta === 1
147
- ? 0
148
- : navigable.length - 1
149
- : (activeIndex + delta + navigable.length) % navigable.length;
138
+ const next = nextActiveIndex(activeIndex, delta, navigable.length);
150
139
  setActiveIndex(next);
151
- const element = listRef.current?.querySelector(`[data-index="${next}"]`);
152
- // Arriving at the first option of a group brings that group's heading into
153
- // view as well as the option, so a keyboard user entering a group can see
154
- // which one they are in. Both, and in this order — scrolling only the
155
- // option leaves the 16px heading clipped above the scrollport, and
156
- // scrolling only the heading is worse, because entering a group from below
157
- // aligns the heading's bottom edge with the scrollport's and leaves the
158
- // newly highlighted option out of view entirely.
159
- //
160
- // Two `nearest` calls settle both directions. Downward: the first brings
161
- // the heading to the bottom edge, the second scrolls one option further,
162
- // leaving heading and option both visible. Upward: the first aligns the
163
- // heading to the top and the second is a no-op, the option having come
164
- // with it. Never losing the highlight is the constraint; showing the
165
- // heading is the preference.
166
- const previous = element?.previousElementSibling ?? null;
167
- if (previous?.getAttribute("role") === "presentation") {
168
- previous.scrollIntoView({ block: "nearest" });
169
- }
170
- element?.scrollIntoView({ block: "nearest" });
140
+ scrollOptionIntoView(listRef.current, next);
171
141
  }
172
142
  function handleKeyDown(event) {
173
143
  if (event.key === "ArrowDown" || event.key === "ArrowUp") {
@@ -201,32 +171,18 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
201
171
  }
202
172
  }
203
173
  }
204
- const rows = [];
205
- filtered.forEach((option, index) => {
206
- const last = rows[rows.length - 1];
207
- if (option.group === undefined) {
208
- rows.push({ kind: "option", entry: { option, index } });
209
- return;
210
- }
211
- if (last?.kind === "group" && last.group === option.group) {
212
- last.entries.push({ option, index });
213
- return;
214
- }
215
- rows.push({ kind: "group", group: option.group, entries: [{ option, index }] });
216
- });
217
- function renderOption({ option, index }) {
218
- 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
219
- ? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
220
- : "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));
221
- }
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.
222
178
  function renderCreateEntry(option, index) {
223
- return (_jsxs("button", { "aria-selected": false, className: cn("flex w-full items-center gap-3 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-left text-sm text-[color:var(--ssui-text-muted)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)]", index === activeIndex && "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: [_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));
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));
224
180
  }
225
181
  return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
226
182
  if (!event.currentTarget.contains(event.relatedTarget)) {
227
183
  close();
228
184
  }
229
- }, 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, "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",
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",
230
186
  // Content, not a hint. `cn` is tailwind-merge, so this replaces the
231
187
  // subtle placeholder colour above rather than racing it.
232
188
  showingValueAsPlaceholder &&
@@ -240,15 +196,9 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
240
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: () => {
241
197
  openList();
242
198
  document.getElementById(id)?.focus();
243
- }, 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 ? (_jsxs("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.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
244
- // A run is wrapped in `role="group"` labelled by its heading —
245
- // the APG grouped-listbox shape. Without it the grouping is
246
- // conveyed to sighted users only: a roleless heading is not in
247
- // the listbox's content model, so a screen-reader user hears a
248
- // flat list of options and never learns which kind each is.
249
- // The heading takes `role="presentation"` so it stays out of
250
- // that content model while `aria-labelledby` still names the
251
- // group from its text.
252
- _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}`))), createEntry ? renderCreateEntry(createEntry, filtered.length) : null, rows.length === 0 && !createEntry ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) : null] })) : 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] }));
253
203
  });
254
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";
@@ -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>>;
@@ -0,0 +1,142 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Check } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ /**
7
+ * Case-insensitive substring match on the option's value, label and
8
+ * `inputLabel`, with spaces treated as underscores so `new york` finds
9
+ * `America/New_York`. Returns true for an empty query.
10
+ */
11
+ export function defaultOptionFilter(option, query) {
12
+ const normalized = query.trim().toLowerCase().replaceAll(" ", "_");
13
+ if (!normalized)
14
+ return true;
15
+ return (option.value.toLowerCase().includes(normalized) ||
16
+ option.label.toLowerCase().replaceAll(" ", "_").includes(normalized) ||
17
+ // `inputLabel` is what the collapsed control showed, so it is what a user
18
+ // retypes after focusing clears the box. Searching only `label` would leave
19
+ // an option findable by a string it never displayed and unfindable by the
20
+ // one it did.
21
+ (option.inputLabel !== undefined &&
22
+ option.inputLabel.toLowerCase().replaceAll(" ", "_").includes(normalized)));
23
+ }
24
+ /**
25
+ * The DOM id of an option row, which `aria-activedescendant` points at.
26
+ *
27
+ * The value is a consumer's data — a user-defined field's option is whatever a
28
+ * person typed — and an id may not contain whitespace, while the single-IDREF
29
+ * attribute pointing at it has to resolve in every browser and screen reader.
30
+ * So the value is encoded into `[A-Za-z0-9-]`: every other character, `_`
31
+ * included so the encoding stays one-to-one, becomes `_<code point in hex>_`.
32
+ * Distinct values give distinct ids; a value that already fits passes through
33
+ * unchanged.
34
+ */
35
+ export function optionElementId(prefix, value) {
36
+ const encoded = value.replace(/[^A-Za-z0-9-]/g, (char) => `_${char.codePointAt(0)?.toString(16)}_`);
37
+ return `${prefix}-option-${encoded}`;
38
+ }
39
+ /**
40
+ * The next highlight position after one arrow press: from nothing highlighted,
41
+ * down lands on the first entry and up on the last; otherwise the highlight
42
+ * wraps at both ends. `-1` when there is nothing to highlight.
43
+ */
44
+ export function nextActiveIndex(current, delta, length) {
45
+ if (length === 0)
46
+ return -1;
47
+ if (current < 0)
48
+ return delta === 1 ? 0 : length - 1;
49
+ return (current + delta + length) % length;
50
+ }
51
+ /**
52
+ * Bring the highlighted row into view, and its group heading with it.
53
+ *
54
+ * Arriving at the first option of a group brings that group's heading into
55
+ * view as well as the option, so a keyboard user entering a group can see which
56
+ * one they are in. Both, and in this order — scrolling only the option leaves
57
+ * the 16px heading clipped above the scrollport, and scrolling only the heading
58
+ * is worse, because entering a group from below aligns the heading's bottom
59
+ * edge with the scrollport's and leaves the newly highlighted option out of
60
+ * view entirely.
61
+ *
62
+ * Two `nearest` calls settle both directions. Downward: the first brings the
63
+ * heading to the bottom edge, the second scrolls one option further, leaving
64
+ * heading and option both visible. Upward: the first aligns the heading to the
65
+ * top and the second is a no-op, the option having come with it. Never losing
66
+ * the highlight is the constraint; showing the heading is the preference.
67
+ */
68
+ export function scrollOptionIntoView(list, index) {
69
+ const element = list?.querySelector(`[data-index="${index}"]`);
70
+ const previous = element?.previousElementSibling ?? null;
71
+ if (previous?.getAttribute("role") === "presentation") {
72
+ previous.scrollIntoView({ block: "nearest" });
73
+ }
74
+ element?.scrollIntoView({ block: "nearest" });
75
+ }
76
+ /** One `role="option"` row. Pointer moves highlight it; clicks commit it. */
77
+ export function OptionRow({ option, index, active, selected, idPrefix, onCommit, onActivate, children, }) {
78
+ return (_jsxs("button", { "aria-selected": selected, className: cn(
79
+ // Every row takes the full text colour. The muted weight this used to
80
+ // carry belongs to a list read *beside* the answer — `Combobox`'s field
81
+ // holds the committed value above its own popup — and reads wrong where
82
+ // the popup is the choice: options dimmer than the heading over them
83
+ // make the things you may pick look less available than the label for
84
+ // them.
85
+ // The label takes the free space rather than the row distributing it:
86
+ // `justify-between` spreads three children across the row, so a row
87
+ // carrying both trailing content and the selected mark would strand its
88
+ // trailing content in the middle. Pushing off the first child instead
89
+ // packs everything after it to the right, for any number of them.
90
+ "flex w-full items-center gap-3 rounded-[var(--ssui-radius-sm)] px-2 py-1.5 text-left text-sm text-[color:var(--ssui-text)] transition-colors [&>:first-child]:mr-auto",
91
+ // Selection is carried by the check and the weight, with the fill as
92
+ // support rather than as the signal. The fill is `--ssui-surface-muted`
93
+ // on `--ssui-surface-elevated`, which is a clear step in a light theme
94
+ // and almost none in a dark one, so a reader in dark had no reliable way
95
+ // to tell which row was current.
96
+ selected && "bg-[color:var(--ssui-surface-muted)] font-medium",
97
+ // The highlight is where the pointer or the keyboard is *now*, so it
98
+ // wins the background: it lands last, and `cn` is tailwind-merge. The
99
+ // other order hid the highlight on the selected row, which is the row a
100
+ // popup opens on.
101
+ active ? "bg-[color:var(--ssui-overlay-hover)]" : "hover:bg-[color:var(--ssui-overlay-hover)]"), "data-index": index, id: optionElementId(idPrefix, option.value), onClick: () => onCommit(option),
102
+ // Keep focus where the keyboard model lives — the input or the listbox —
103
+ // rather than letting a click move it onto the row.
104
+ onMouseDown: (event) => event.preventDefault(), onMouseMove: () => onActivate(index), role: "option", tabIndex: -1, type: "button", children: [children ??
105
+ (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, selected ? _jsx(Check, { "aria-hidden": "true", className: "size-4 shrink-0" }) : null] }));
106
+ }
107
+ /**
108
+ * The popup list both `Combobox` and `SelectMenu` render: options walked into
109
+ * grouped runs under headings, each row a `role="option"` button, the highlight
110
+ * driven from outside by `activeIndex`. It owns no positioning and no keyboard
111
+ * handling — the owner decides which element holds focus and where the list
112
+ * sits — so the surrounding chrome is the owner's `className`.
113
+ *
114
+ * Internal to the package. Not exported from the barrel, so it is not an API
115
+ * this package has to keep.
116
+ */
117
+ export const OptionListbox = React.forwardRef(({ id, idPrefix, options, value, activeIndex, onActivate, onCommit, emptyMessage = "No matches", children, className, ...rest }, ref) => {
118
+ const rows = [];
119
+ options.forEach((option, index) => {
120
+ const last = rows[rows.length - 1];
121
+ if (option.group === undefined) {
122
+ rows.push({ kind: "option", entry: { option, index } });
123
+ return;
124
+ }
125
+ if (last?.kind === "group" && last.group === option.group) {
126
+ last.entries.push({ option, index });
127
+ return;
128
+ }
129
+ rows.push({ kind: "group", group: option.group, entries: [{ option, index }] });
130
+ });
131
+ const renderOption = ({ option, index }) => (_jsx(OptionRow, { active: index === activeIndex, idPrefix: idPrefix, index: index, onActivate: onActivate, onCommit: onCommit, option: option, selected: option.value === value }, option.value));
132
+ return (_jsxs("div", { ...rest, className: className, id: id, ref: ref, role: "listbox", children: [rows.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
133
+ // A run is wrapped in `role="group"` labelled by its heading — the
134
+ // APG grouped-listbox shape. Without it the grouping is conveyed to
135
+ // sighted users only: a roleless heading is not in the listbox's
136
+ // content model, so a screen-reader user hears a flat list of
137
+ // options and never learns which kind each is. The heading takes
138
+ // `role="presentation"` so it stays out of that content model while
139
+ // `aria-labelledby` still names the group from its text.
140
+ _jsxs("div", { "aria-labelledby": `${idPrefix}-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: `${idPrefix}-group-${rowIndex}`, role: "presentation", children: row.group }), row.entries.map(renderOption)] }, `group-${rowIndex}`))), children, rows.length === 0 && emptyMessage !== null ? (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) : null] }));
141
+ });
142
+ OptionListbox.displayName = "OptionListbox";
@@ -0,0 +1,23 @@
1
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
2
+ import * as React from "react";
3
+ /**
4
+ * The thin styled wrapper `dropdown-menu.tsx` gives its primitive, for the
5
+ * anchored panel that is not a menu: a popup holding a filter field, a form, or
6
+ * a list that keeps its own keyboard model. Radix supplies the portal, the
7
+ * collision-aware placement, and dismissal on outside click or Escape, and
8
+ * imposes no roving focus or typeahead of its own — which is what separates it
9
+ * from `DropdownMenu` and why `SelectMenu` is built on it.
10
+ */
11
+ export declare const Popover: React.FC<PopoverPrimitive.PopoverProps>;
12
+ export declare const PopoverTrigger: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverTriggerProps & React.RefAttributes<HTMLButtonElement>>;
13
+ export declare const PopoverAnchor: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverAnchorProps & React.RefAttributes<HTMLDivElement>>;
14
+ export declare const PopoverClose: React.ForwardRefExoticComponent<PopoverPrimitive.PopoverCloseProps & React.RefAttributes<HTMLButtonElement>>;
15
+ export declare const PopoverPortal: React.FC<PopoverPrimitive.PopoverPortalProps>;
16
+ export declare const PopoverContent: React.ForwardRefExoticComponent<Omit<PopoverPrimitive.PopoverContentProps & React.RefAttributes<HTMLDivElement>, "ref"> & {
17
+ /**
18
+ * Render the panel through a portal to `document.body` (the default). Pass
19
+ * `false` to render it inline, next to the trigger, when the panel must stay
20
+ * inside a subtree that tracks focus or owns its own stacking context.
21
+ */
22
+ portal?: boolean;
23
+ } & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as PopoverPrimitive from "@radix-ui/react-popover";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ /**
7
+ * The thin styled wrapper `dropdown-menu.tsx` gives its primitive, for the
8
+ * anchored panel that is not a menu: a popup holding a filter field, a form, or
9
+ * a list that keeps its own keyboard model. Radix supplies the portal, the
10
+ * collision-aware placement, and dismissal on outside click or Escape, and
11
+ * imposes no roving focus or typeahead of its own — which is what separates it
12
+ * from `DropdownMenu` and why `SelectMenu` is built on it.
13
+ */
14
+ export const Popover = PopoverPrimitive.Root;
15
+ export const PopoverTrigger = PopoverPrimitive.Trigger;
16
+ export const PopoverAnchor = PopoverPrimitive.Anchor;
17
+ export const PopoverClose = PopoverPrimitive.Close;
18
+ export const PopoverPortal = PopoverPrimitive.Portal;
19
+ export const PopoverContent = React.forwardRef(({ align = "start", className, collisionPadding = 8, portal = true, sideOffset = 6, ...props }, ref) => {
20
+ const content = (_jsx(PopoverPrimitive.Content, { ref: ref, align: align, collisionPadding: collisionPadding, sideOffset: sideOffset, className: cn(
21
+ // The panel is bounded by the space actually available at its
22
+ // placement. Radix measures that per placement and publishes it here.
23
+ // Without a ceiling the panel is sized by its widest content, and
24
+ // collision handling *shifts* a panel rather than shrinking it, so past
25
+ // the viewport there is nowhere left to shift to. Content that can
26
+ // ellipsize then does; content that cannot scrolls. `cn` is
27
+ // tailwind-merge, so a caller needing a different bound says so in
28
+ // `className` and replaces this rather than racing it.
29
+ "z-50 min-w-44 max-w-[var(--radix-popover-content-available-width)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 text-[color:var(--ssui-text)] shadow-[var(--ssui-shadow-md)] outline-none", className), ...props }));
30
+ return portal ? _jsx(PopoverPrimitive.Portal, { children: content }) : content;
31
+ });
32
+ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
@@ -72,11 +72,13 @@ export interface RoleMenuProps {
72
72
  /**
73
73
  * Single choice from a small closed set where each option needs a sentence.
74
74
  *
75
- * The third answer to "pick one of a few" in this package, and the one to reach
76
- * for when the options need explaining: `Select` is a native `<select>` whose
77
- * options may hold only text, and `Combobox` is a filterable input for lists long
78
- * enough to search. A role set is neither — two or three options, each carrying a
79
- * meaning the reader cannot infer from its name.
75
+ * One of four answers to "pick one" in this package, and the one to reach for
76
+ * when the options need explaining: `Select` is a native `<select>` whose
77
+ * options may hold only text, `Combobox` is a filterable input for lists long
78
+ * enough to search, and `SelectMenu` is the `Select` slot drawn as a menu. A
79
+ * role set is none of those — two or three options, each carrying a meaning the
80
+ * reader cannot infer from its name. The four and which case each answers are
81
+ * tabled once, in `docs/packages/saas-ui.md`.
80
82
  *
81
83
  * Options are radio items rather than plain menu items, so assistive technology
82
84
  * announces the option set and which member of it is current instead of leaving
@@ -9,11 +9,13 @@ import { useFormField, useFormFieldProps } from "./form-field.js";
9
9
  /**
10
10
  * Single choice from a small closed set where each option needs a sentence.
11
11
  *
12
- * The third answer to "pick one of a few" in this package, and the one to reach
13
- * for when the options need explaining: `Select` is a native `<select>` whose
14
- * options may hold only text, and `Combobox` is a filterable input for lists long
15
- * enough to search. A role set is neither — two or three options, each carrying a
16
- * meaning the reader cannot infer from its name.
12
+ * One of four answers to "pick one" in this package, and the one to reach for
13
+ * when the options need explaining: `Select` is a native `<select>` whose
14
+ * options may hold only text, `Combobox` is a filterable input for lists long
15
+ * enough to search, and `SelectMenu` is the `Select` slot drawn as a menu. A
16
+ * role set is none of those — two or three options, each carrying a meaning the
17
+ * reader cannot infer from its name. The four and which case each answers are
18
+ * tabled once, in `docs/packages/saas-ui.md`.
17
19
  *
18
20
  * Options are radio items rather than plain menu items, so assistive technology
19
21
  * announces the option set and which member of it is current instead of leaving
@@ -0,0 +1,128 @@
1
+ import * as React from "react";
2
+ import type { ComboboxOption } from "./option-list.js";
3
+ import type { SelectSize } from "./select.js";
4
+ export type SelectMenuVariant = "control" | "inline";
5
+ /**
6
+ * The option count at which `SelectMenu` shows a filter when the consumer has
7
+ * not said either way. Below it the whole list is on screen at once, and a
8
+ * filter is a row to skip past on the way to an answer already visible; above
9
+ * it the reader is scanning. Exported so a consumer can compare against it
10
+ * rather than duplicate the number.
11
+ */
12
+ export declare const SELECT_MENU_SEARCH_THRESHOLD = 8;
13
+ export interface SelectMenuProps {
14
+ /** The same option shape `Combobox` takes, groups included. Never reordered. */
15
+ options: ReadonlyArray<ComboboxOption>;
16
+ /** Selected option value. The component never changes this itself. */
17
+ value?: string;
18
+ /** Called with the chosen value; never fired for the option already selected. */
19
+ onValueChange?: (value: string) => void | Promise<void>;
20
+ /**
21
+ * How the trigger is drawn. `"control"` (the default) is `Select`'s bounded
22
+ * field with a chevron, in the same `sm`/`md` scale, so swapping one for the
23
+ * other returns the same field with a themed popup. `"inline"` draws no box
24
+ * at rest: the trigger is the label's own text on the row's own background,
25
+ * sized by the line it sits in, with the chevron and hover surface appearing
26
+ * on hover and focus. That is the presentation a dense properties row needs,
27
+ * and the reason this component exists — see `docs/packages/saas-ui.md`.
28
+ */
29
+ variant?: SelectMenuVariant;
30
+ /** Control scale for `variant="control"`. Ignored by `"inline"`, which takes its line from the text around it. */
31
+ size?: SelectSize;
32
+ /**
33
+ * Whether the popup carries a filter field. Left unset, the component decides
34
+ * from the option count: a filter at `SELECT_MENU_SEARCH_THRESHOLD` options
35
+ * or more, none below. `true` forces one onto a short list; `false` keeps one
36
+ * off a long one.
37
+ */
38
+ searchable?: boolean;
39
+ /** Placeholder for the filter field. Defaults to "Search". */
40
+ searchPlaceholder?: string;
41
+ /**
42
+ * Shown in the popup when there is nothing to list. Defaults to "No matches"
43
+ * when a filter is shown and "No options" when there is none — a list that
44
+ * has not loaded has not failed a search. `null` shows nothing at all, the
45
+ * meaning it carries on `Combobox`.
46
+ */
47
+ emptyMessage?: React.ReactNode;
48
+ /**
49
+ * Offer clearing the value as a row among the options, labelled with this
50
+ * text — "Empty", "None", "Unassigned". Choosing it reports `""`. Absent, no
51
+ * such row is offered.
52
+ */
53
+ emptyLabel?: string;
54
+ /**
55
+ * What the trigger shows when no option matches `value` — a field with
56
+ * nothing chosen, or one holding a value the list no longer offers. Rendered
57
+ * in the subtle text colour so it reads as a prompt rather than a choice.
58
+ */
59
+ placeholder?: React.ReactNode;
60
+ /** Submits the selected value with the surrounding form through a hidden input. */
61
+ name?: string;
62
+ disabled?: boolean;
63
+ /**
64
+ * Which edge of the trigger the popup aligns to. Defaults to `"start"`; a
65
+ * right-aligned trigger wants `"end"` so the popup opens inward rather than
66
+ * relying on collision detection to rescue it.
67
+ */
68
+ align?: "start" | "center" | "end";
69
+ /** Controlled open state. */
70
+ open?: boolean;
71
+ /**
72
+ * Open on mount. For a consumer whose control appears in response to an edit
73
+ * gesture — a click on the value it replaces — so that choosing a value is
74
+ * one click from the row rather than two.
75
+ */
76
+ defaultOpen?: boolean;
77
+ /**
78
+ * Notified whenever the popup opens or closes, whichever side asked for it.
79
+ * `false` after a choice and after a dismissal alike; a consumer that treats
80
+ * "closed without choosing" as a cancel reads it together with
81
+ * `onValueChange`, which fires first.
82
+ */
83
+ onOpenChange?: (open: boolean) => void;
84
+ /** Trigger id. Supplied by an enclosing `FormField` when there is one. */
85
+ id?: string;
86
+ /**
87
+ * Names the trigger's purpose, e.g. `"Owner"` on a record's properties. The
88
+ * current value is appended — the accessible name has to contain the
89
+ * trigger's visible text for voice control (WCAG 2.5.3), and a card of ten
90
+ * triggers all named by their values says nothing about what each one sets.
91
+ * Inside a `FormField` the field's visible label already names the control,
92
+ * so leave this unset there: an explicit `aria-label` still wins over the
93
+ * label, as it does on `RoleMenu`. With neither, the visible text is the name.
94
+ */
95
+ "aria-label"?: string;
96
+ "aria-describedby"?: string;
97
+ "aria-invalid"?: React.AriaAttributes["aria-invalid"];
98
+ /** Extra classes for the trigger. */
99
+ className?: string;
100
+ }
101
+ /**
102
+ * Single choice from a list, in a popup this package draws.
103
+ *
104
+ * The fourth answer to "choose one" here, and the one to reach for when the
105
+ * popup's appearance is the point: `Select` is the native control, whose popup
106
+ * belongs to the platform and cannot be themed past its background colour;
107
+ * `Combobox` is a search field for lists too long to read; `RoleMenu` is for
108
+ * two or three options that each need a sentence. `SelectMenu` is the `Select`
109
+ * slot drawn as a menu — a trigger that can read as bounded field or as plain
110
+ * text, an option list with headings, and a filter once the list is long
111
+ * enough to want one. The table in `docs/packages/saas-ui.md` says which of the
112
+ * four answers which case.
113
+ *
114
+ * Built on `Popover` rather than `DropdownMenu` because a menu's roving focus
115
+ * and typeahead both fight a filter field. Inside the popup the list runs the
116
+ * listbox pattern with `aria-activedescendant`: focus stays in the filter field
117
+ * when there is one, or on the listbox itself when there is not, and the
118
+ * highlight moves independently — the model `Combobox` already implements, and
119
+ * the same option list rendering, extracted so the two cannot drift.
120
+ *
121
+ * What it gives up, stated plainly: on a phone a native `<select>` opens the
122
+ * platform's own picker, and this cannot. `Select` stays for the bounded form
123
+ * field where that is worth more than a themed popup.
124
+ */
125
+ export declare function SelectMenu({ options, value, onValueChange, variant, size, searchable, searchPlaceholder, emptyMessage, emptyLabel, placeholder, name, disabled, align, open: openProp, defaultOpen, onOpenChange, className, ...ariaProps }: SelectMenuProps): import("react/jsx-runtime").JSX.Element;
126
+ export declare namespace SelectMenu {
127
+ var displayName: string;
128
+ }
@@ -0,0 +1,219 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { ChevronDown } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../utils/cn.js";
6
+ import { useFormFieldProps } from "./form-field.js";
7
+ import { OptionListbox, defaultOptionFilter, nextActiveIndex, optionElementId, scrollOptionIntoView, } from "./option-list.js";
8
+ import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
9
+ /**
10
+ * The option count at which `SelectMenu` shows a filter when the consumer has
11
+ * not said either way. Below it the whole list is on screen at once, and a
12
+ * filter is a row to skip past on the way to an answer already visible; above
13
+ * it the reader is scanning. Exported so a consumer can compare against it
14
+ * rather than duplicate the number.
15
+ */
16
+ export const SELECT_MENU_SEARCH_THRESHOLD = 8;
17
+ // Height, text and padding are held together per size rather than split across
18
+ // rules, matching `Select`, so the two controls line up when swapped.
19
+ const CONTROL_SIZE_CLASSES = {
20
+ md: "h-10 py-2 pl-3 pr-3 text-sm",
21
+ sm: "h-8 py-1 pl-2.5 pr-2.5 text-xs",
22
+ };
23
+ /**
24
+ * Single choice from a list, in a popup this package draws.
25
+ *
26
+ * The fourth answer to "choose one" here, and the one to reach for when the
27
+ * popup's appearance is the point: `Select` is the native control, whose popup
28
+ * belongs to the platform and cannot be themed past its background colour;
29
+ * `Combobox` is a search field for lists too long to read; `RoleMenu` is for
30
+ * two or three options that each need a sentence. `SelectMenu` is the `Select`
31
+ * slot drawn as a menu — a trigger that can read as bounded field or as plain
32
+ * text, an option list with headings, and a filter once the list is long
33
+ * enough to want one. The table in `docs/packages/saas-ui.md` says which of the
34
+ * four answers which case.
35
+ *
36
+ * Built on `Popover` rather than `DropdownMenu` because a menu's roving focus
37
+ * and typeahead both fight a filter field. Inside the popup the list runs the
38
+ * listbox pattern with `aria-activedescendant`: focus stays in the filter field
39
+ * when there is one, or on the listbox itself when there is not, and the
40
+ * highlight moves independently — the model `Combobox` already implements, and
41
+ * the same option list rendering, extracted so the two cannot drift.
42
+ *
43
+ * What it gives up, stated plainly: on a phone a native `<select>` opens the
44
+ * platform's own picker, and this cannot. `Select` stays for the bounded form
45
+ * field where that is worth more than a themed popup.
46
+ */
47
+ export function SelectMenu({ options, value, onValueChange, variant = "control", size = "md", searchable, searchPlaceholder = "Search", emptyMessage, emptyLabel, placeholder, name, disabled, align = "start", open: openProp, defaultOpen = false, onOpenChange, className, ...ariaProps }) {
48
+ // An enclosing `FormField` owns the trigger's id and error wiring, as it does
49
+ // for `Input`, `Combobox` and `RoleMenu`.
50
+ const { "aria-label": ariaLabel, ...fieldProps } = useFormFieldProps(ariaProps);
51
+ const generatedId = React.useId();
52
+ const id = fieldProps.id ?? generatedId;
53
+ const listboxId = `${id}-listbox`;
54
+ const filterId = `${id}-filter`;
55
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen);
56
+ const open = openProp ?? uncontrolledOpen;
57
+ const [query, setQuery] = React.useState("");
58
+ const [activeIndex, setActiveIndex] = React.useState(-1);
59
+ const filterRef = React.useRef(null);
60
+ const listRef = React.useRef(null);
61
+ const triggerRef = React.useRef(null);
62
+ // The empty choice is a real option at the head of the list — it shares the
63
+ // index space, the highlight and `aria-activedescendant` — so no second
64
+ // keyboard path exists for it. `""` is what it reports. An empty `value` is
65
+ // "nothing chosen" everywhere below rather than "the empty row is chosen":
66
+ // the trigger shows its placeholder, no row is marked current, and the list
67
+ // opens with nothing highlighted.
68
+ const chosen = value === "" ? undefined : value;
69
+ const selected = options.find((option) => option.value === chosen);
70
+ const selectedLabel = selected?.inputLabel ?? selected?.label;
71
+ const offered = emptyLabel !== undefined ? [{ value: "", label: emptyLabel }, ...options] : [...options];
72
+ const showFilter = searchable ?? options.length >= SELECT_MENU_SEARCH_THRESHOLD;
73
+ // A freshly opened list starts with nothing typed and the current value
74
+ // highlighted, so Enter with no other key confirms what is already set and an
75
+ // arrow moves from it rather than from the top. The reset keys on the open
76
+ // state itself rather than on the trigger, because the trigger is only one
77
+ // way in: `defaultOpen` and a consumer flipping `open` from its own gesture —
78
+ // the click-to-edit row this component exists for — never touch it. Done
79
+ // during render, in the pattern React documents for reacting to a prop
80
+ // change, so the popup's first frame is already reset rather than one frame
81
+ // stale.
82
+ const [wasOpen, setWasOpen] = React.useState(false);
83
+ if (open !== wasOpen) {
84
+ setWasOpen(open);
85
+ if (open) {
86
+ setQuery("");
87
+ setActiveIndex(chosen === undefined ? -1 : offered.findIndex((option) => option.value === chosen));
88
+ }
89
+ }
90
+ const navigable = showFilter ? offered.filter((option) => defaultOptionFilter(option, query)) : offered;
91
+ const activeOption = activeIndex >= 0 ? navigable[activeIndex] : undefined;
92
+ function setOpen(next) {
93
+ if (next === open)
94
+ return;
95
+ if (openProp === undefined)
96
+ setUncontrolledOpen(next);
97
+ onOpenChange?.(next);
98
+ }
99
+ function commit(option) {
100
+ if (option.value !== (value ?? ""))
101
+ void onValueChange?.(option.value);
102
+ setOpen(false);
103
+ }
104
+ function moveActive(delta) {
105
+ if (navigable.length === 0)
106
+ return;
107
+ const next = nextActiveIndex(activeIndex, delta, navigable.length);
108
+ setActiveIndex(next);
109
+ scrollOptionIntoView(listRef.current, next);
110
+ }
111
+ function handleKeyDown(event) {
112
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
113
+ event.preventDefault();
114
+ moveActive(event.key === "ArrowDown" ? 1 : -1);
115
+ return;
116
+ }
117
+ if (event.key === "Home" || event.key === "End") {
118
+ if (navigable.length === 0)
119
+ return;
120
+ event.preventDefault();
121
+ const next = event.key === "Home" ? 0 : navigable.length - 1;
122
+ setActiveIndex(next);
123
+ scrollOptionIntoView(listRef.current, next);
124
+ return;
125
+ }
126
+ if (event.key === "Enter") {
127
+ // The open popup owns Enter whether or not anything is highlighted, so a
128
+ // stray press cannot reach the form behind the control.
129
+ event.preventDefault();
130
+ if (activeOption)
131
+ commit(activeOption);
132
+ }
133
+ // Escape is left to the popover, which closes on it and returns focus to
134
+ // the trigger; stopping it here would keep the popup open.
135
+ }
136
+ function handleTriggerKeyDown(event) {
137
+ // A native `<select>` opens on either arrow, and so does `RoleMenu`; a user
138
+ // swapping `Select` for this keeps the key. Space and Enter are the button's
139
+ // own, and the open state's reset above does the rest.
140
+ if (open || (event.key !== "ArrowDown" && event.key !== "ArrowUp"))
141
+ return;
142
+ event.preventDefault();
143
+ setOpen(true);
144
+ }
145
+ // The name carries purpose and current value, as `RoleMenu`'s does. A
146
+ // `FormField`'s visible label does that job better, so a consumer inside one
147
+ // passes no `aria-label`; one that does gets what it asked for, as it would
148
+ // from `RoleMenu`. With neither, the button's own text — the current value —
149
+ // is its name.
150
+ const currentText = selectedLabel ?? (typeof placeholder === "string" ? placeholder : "none");
151
+ const triggerLabel = ariaLabel ? `${ariaLabel} (current: ${currentText})` : undefined;
152
+ const activeDescendant = open && activeOption ? optionElementId(id, activeOption.value) : undefined;
153
+ return (_jsxs(_Fragment, { children: [name ? _jsx("input", { disabled: disabled, name: name, type: "hidden", value: value ?? "" }) : null, _jsxs(Popover, { onOpenChange: setOpen, open: open, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { ...fieldProps, "aria-controls": open ? listboxId : undefined, "aria-haspopup": "listbox", "aria-label": triggerLabel, className: cn("group/select-menu inline-flex items-center gap-1.5 text-left transition-colors focus-visible:outline-none", variant === "control"
154
+ ? cn(
155
+ // `Select`'s field, so the two line up when swapped: same
156
+ // radius, border, shadow, sizes, and ring with an offset.
157
+ "w-full justify-between rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] text-[color:var(--ssui-text)] shadow-sm 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:pointer-events-none disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", CONTROL_SIZE_CLASSES[size])
158
+ : // No box at rest: the row's own text on the row's own background,
159
+ // and only as wide as that text — `w-fit`, because a grid or flex
160
+ // cell would otherwise stretch it, and a hover surface the width
161
+ // of the row reads as a bar rather than as the value lighting up.
162
+ // The hover surface and the chevron arrive on hover, focus and
163
+ // while open, and the focus ring carries no offset — an inline
164
+ // trigger sits in a line with a couple of pixels to give, and a
165
+ // ring pushed outside it lands on the lines above and below
166
+ // rather than on this one. The ring is 2px, so with no offset
167
+ // it fits exactly the gutter a dense list keeps. Disabled, the
168
+ // pointer gets nothing: hover is the whole signal this variant
169
+ // uses to say "operable", so it must not light up and grow a
170
+ // chevron for a trigger that will then do nothing.
171
+ "-mx-1 w-fit max-w-full rounded-[var(--ssui-radius-sm)] px-1 text-[color:var(--ssui-text)] hover:bg-[color:var(--ssui-overlay-hover)] focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] data-[state=open]:bg-[color:var(--ssui-overlay-hover)] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-70", className), disabled: disabled, onKeyDown: handleTriggerKeyDown, ref: triggerRef, type: "button", children: [_jsx("span", { className: cn("min-w-0 truncate", selectedLabel === undefined && "text-[color:var(--ssui-text-subtle)]"), children: selectedLabel ?? placeholder }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("shrink-0 text-[color:var(--ssui-text-subtle)]", variant === "control"
172
+ ? size === "sm"
173
+ ? "size-3.5"
174
+ : "size-4"
175
+ : "size-3.5 opacity-0 transition-opacity group-hover/select-menu:opacity-100 group-focus-visible/select-menu:opacity-100 group-data-[state=open]/select-menu:opacity-100") })] }) }), _jsxs(PopoverContent, { align: align,
176
+ // The panel's own padding plus a row's puts an option's text 12px
177
+ // inside the panel, while an inline trigger's text sits 4px inside the
178
+ // box the panel aligns to. Pulling the panel back by the difference
179
+ // lands the options directly under the value they replace, so the
180
+ // popup reads as that value opening rather than as a panel arriving
181
+ // beside it. A bounded trigger's own padding already matches, so it
182
+ // needs no correction.
183
+ alignOffset: variant === "inline" ? -8 : 0, className: cn("p-1",
184
+ // A bounded trigger's popup starts at the field's own width, so the two
185
+ // read as one control rather than as a panel that happens to be near it,
186
+ // and grows only if an option needs more than the field has. A fixed
187
+ // width would truncate those options; a fixed minimum — which this used
188
+ // to carry for both variants — made every narrow field's popup overhang
189
+ // it, which is what the field-type control showed. An inline trigger is
190
+ // as wide as its text, which is no width for a list, so it keeps a floor
191
+ // of its own.
192
+ variant === "control" ? "min-w-[var(--radix-popover-trigger-width)]" : "min-w-56"), onCloseAutoFocus: (event) => {
193
+ // Focus returns to the trigger on close — unless the trigger has
194
+ // left the document with the popup, as it does when a consumer
195
+ // unmounts the control on a choice or a cancel. Returning focus to
196
+ // a detached element is a no-op that still pre-empts wherever the
197
+ // consumer then puts it, so the return is skipped and left to them.
198
+ if (!triggerRef.current?.isConnected)
199
+ event.preventDefault();
200
+ }, onOpenAutoFocus: (event) => {
201
+ // Focus goes to whichever element runs the keyboard model — the
202
+ // filter field, or the listbox itself when there is none — not to
203
+ // the first tabbable thing Radix would pick.
204
+ event.preventDefault();
205
+ (filterRef.current ?? listRef.current)?.focus();
206
+ },
207
+ // The panel is a listbox popup, not a dialog: the element that holds
208
+ // focus inside it announces itself, and a "dialog" wrapper around a
209
+ // select's options would be one more thing to hear on the way to them.
210
+ role: "presentation", children: [showFilter ? (_jsx("input", { "aria-activedescendant": activeDescendant, "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": "true", "aria-label": searchPlaceholder, autoComplete: "off", className: "mb-1 flex h-8 w-full rounded-[var(--ssui-radius-sm)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-2 text-sm text-[color:var(--ssui-text)] placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)]", id: filterId, onChange: (event) => {
211
+ setQuery(event.target.value);
212
+ setActiveIndex(-1);
213
+ }, onKeyDown: handleKeyDown, placeholder: searchPlaceholder, ref: filterRef, role: "combobox", type: "text", value: query })) : null, _jsx(OptionListbox, { activeIndex: activeIndex, "aria-activedescendant": showFilter ? undefined : activeDescendant, "aria-label": showFilter ? undefined : ariaLabel, className: "max-h-64 overflow-y-auto outline-none",
214
+ // `undefined` is "the consumer said nothing"; `null` is "show
215
+ // nothing", which the list documents and `Combobox` honours. `??`
216
+ // would swallow the second, so the test is on `undefined` alone.
217
+ emptyMessage: emptyMessage !== undefined ? emptyMessage : showFilter ? "No matches" : "No options", id: listboxId, idPrefix: id, onActivate: setActiveIndex, onCommit: commit, onKeyDown: showFilter ? undefined : handleKeyDown, options: navigable, ref: listRef, tabIndex: showFilter ? undefined : -1, value: chosen })] })] })] }));
218
+ }
219
+ SelectMenu.displayName = "SelectMenu";
@@ -12,5 +12,11 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
12
12
  * Styled native `<select>` matching the shared `Input` look, with a trailing
13
13
  * chevron. Native on purpose: correct mobile behavior and form participation
14
14
  * with zero positioning code. Participates in `FormField` wiring.
15
+ *
16
+ * The default for a bounded field in an ordinary form. Its popup belongs to the
17
+ * platform and cannot be themed past its background colour; where that popup is
18
+ * the whole of what the reader sees — a choice presented as inline text — reach
19
+ * for `SelectMenu` instead. The four single-choice controls and which case each
20
+ * answers are tabled once, in `docs/packages/saas-ui.md`.
15
21
  */
16
22
  export declare const Select: React.ForwardRefExoticComponent<SelectProps & React.RefAttributes<HTMLSelectElement>>;
@@ -14,6 +14,12 @@ const SIZE_CLASSES = {
14
14
  * Styled native `<select>` matching the shared `Input` look, with a trailing
15
15
  * chevron. Native on purpose: correct mobile behavior and form participation
16
16
  * with zero positioning code. Participates in `FormField` wiring.
17
+ *
18
+ * The default for a bounded field in an ordinary form. Its popup belongs to the
19
+ * platform and cannot be themed past its background colour; where that popup is
20
+ * the whole of what the reader sees — a choice presented as inline text — reach
21
+ * for `SelectMenu` instead. The four single-choice controls and which case each
22
+ * answers are tabled once, in `docs/packages/saas-ui.md`.
17
23
  */
18
24
  export const Select = React.forwardRef(({ className, children, size = "md", ...props }, ref) => (_jsxs("div", { className: cn("relative", className), children: [_jsx("select", { ref: ref, className: cn("flex w-full cursor-pointer appearance-none rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] text-[color:var(--ssui-text)] shadow-sm transition-colors 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", SIZE_CLASSES[size].select), ...useFormFieldProps(props), children: children }), _jsx(ChevronDown, { "aria-hidden": "true", className: cn("pointer-events-none absolute top-1/2 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]", SIZE_CLASSES[size].chevron) })] })));
19
25
  Select.displayName = "Select";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Brand-neutral React UI primitives and SaaS app patterns for ForwardReach-owned business applications.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -51,6 +51,7 @@
51
51
  "@radix-ui/react-avatar": "^1.1.10",
52
52
  "@radix-ui/react-dialog": "^1.1.15",
53
53
  "@radix-ui/react-dropdown-menu": "^2.1.16",
54
+ "@radix-ui/react-popover": "^1.1.23",
54
55
  "@radix-ui/react-scroll-area": "^1.2.10",
55
56
  "@radix-ui/react-separator": "^1.1.7",
56
57
  "@radix-ui/react-slot": "^1.2.4",
@@ -72,7 +73,7 @@
72
73
  "typescript": "^5.8.3",
73
74
  "vitest": "^3.2.4"
74
75
  },
75
- "gitHead": "544931dd34ff3f738a58cf9e7ad161fb8fa8763f",
76
+ "gitHead": "7517a542f48380e40fb8d7632f12ce352151487d",
76
77
  "scripts": {
77
78
  "build": "pnpm clean && tsc -p tsconfig.build.json && node scripts/copy-styles.mjs",
78
79
  "dev": "node scripts/copy-styles.mjs && tsc -p tsconfig.build.json --watch",