@forwardreach/saas-ui 0.11.0 → 0.13.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,128 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **The chrome a host draws its own main pages with, so a page built only from shared
8
+ parts is indistinguishable from one the host draws by hand.** Reviewing ReachMe's
9
+ PeopleThread extension beside PeopleThread's Timeline found three gaps in this
10
+ package — no reference pill, no shared page column, and a `PageHeader` whose default
11
+ was not the host's heading shape — and each was being filled locally, in the wrong
12
+ place. This release fills them here.
13
+
14
+ - **Pill tokens.** `--ssui-pill-<tone>-bg` / `-text` for five tones: `reference` (a
15
+ link to a record), `tag`, `attribute` (a field), `date`, `category` (a grouping
16
+ tag). Every default is a _derivation_ of the status and accent ramps, never a
17
+ literal, so a consumer that has mapped the base contract sees a coherent pair in
18
+ both themes before it maps a pill token. That is now the rule for any token added
19
+ for a role a consumer may already color, and a test enforces it. A second test
20
+ holds the documented token list equal to the shipped declarations, so a consumer
21
+ can check its mapping for completeness against the docs.
22
+ - **`PageContainer`.** The centered page column: `width="narrow"` (a reading
23
+ column, 48rem), `"wide"` (a working column, 64rem), or `"full"` (a workspace, no
24
+ gutters). A page opts in; a host never wraps another product's page in it.
25
+ - **`Card`.** The bordered surface — `--ssui-surface` on `--ssui-border` at
26
+ `--ssui-radius-lg` — with `elevated`, an `interactive` border-hover for a card
27
+ whose parts are clickable, and `asChild`. Padding is the consumer's. `ListShell`
28
+ is a different thing and stays.
29
+ - **`Pill`.** An inline reference chip in the five tones, `align-baseline` so it
30
+ sits in a line of text, with `asChild` so the consumer's own link carries the
31
+ presentation. The `reference` tone carries a hairline border, which is what
32
+ separates a chip that reaches its record from one that is only a label. **The
33
+ previous `Pill` export was an alias of `Badge`**; the gallery was its only
34
+ consumer and is updated here, no product consumed it, and the name now means this
35
+ component. A badge-shaped pill is `Badge variant="outline"`.
36
+ - **`PageHeader` takes the host's heading shape.** The title ramp is now
37
+ `text-xl sm:text-2xl` (was `text-2xl`); the bottom rule is an opt-in `divider`
38
+ prop (was always drawn); a new `aside` slot renders on the title's baseline at
39
+ the right edge of the title row, for context that is not an action — a date, a
40
+ count — while `actions` keeps its own slot, so the two never share a line at
41
+ narrow widths. From `sm` up the header row now wraps: actions too wide to sit
42
+ beside the full title move to a row beneath it instead of shrinking the title
43
+ to nothing. **This is a visual change for every consumer that relied on the
44
+ rule and the larger title.** Pass `divider` where the rule is wanted; both known
45
+ consumers do so in the commit that takes this version.
46
+
47
+ ## 0.12.0
48
+
49
+ ### Minor Changes
50
+
51
+ - **`SelectMenu`: a single choice whose popup this package draws.** A native
52
+ `<select>` styled down to a line of text has exactly one piece of chrome left —
53
+ the platform's own popup — and it is the one piece CSS cannot reach. This is
54
+ the component for that case. It is additive in API — no existing export
55
+ changes shape — and the one existing call site that renders differently is
56
+ named below.
57
+
58
+ - **Two trigger presentations.** `variant="control"` (the default) is `Select`'s
59
+ bounded field with a chevron, in the same `sm`/`md` scale, so swapping one for
60
+ the other returns the same field with a themed popup. `variant="inline"` draws
61
+ no box at rest: the trigger is the label's own text at the size of the line it
62
+ sits in, with the hover surface, the chevron, and a focus ring without an
63
+ offset arriving on hover, focus, and while open. The inline variant is why the
64
+ component exists, and shipping it is what keeps a consumer from forcing a 40px
65
+ bordered control down to a 20px line through `className` and child selectors.
66
+ - **The same options `Combobox` takes**, groups included: consecutive options
67
+ declaring one group collapse under one heading, an ungrouped option ends the
68
+ run above it, and the component never reorders. The option list, its keyboard
69
+ model, and its group semantics are `Combobox`'s own, extracted into an
70
+ internal module both components render — `Combobox`'s suite passes unedited
71
+ across the extraction.
72
+ - **A filter once the list is long enough**, at `SELECT_MENU_SEARCH_THRESHOLD`
73
+ options (eight) and not before; `searchable` forces it either way. With a
74
+ filter, focus stays in the field while the list is navigated; without one, the
75
+ listbox itself holds focus. Both run the listbox pattern through
76
+ `aria-activedescendant`.
77
+ - **`emptyLabel`** offers clearing the value as a row that reports `""`.
78
+ **`placeholder`** is what the trigger shows when no option matches `value` — a
79
+ field holding nothing, or one holding a value the list no longer offers, which
80
+ is neither shown as chosen nor replaced by whatever happens to be first.
81
+ - **`defaultOpen`, `open`, `onOpenChange`**, for the consumer whose control
82
+ appears in response to an edit gesture: open the popup as the row enters edit
83
+ mode, so choosing costs one click rather than two, and read a close that
84
+ followed no `onValueChange` as a cancel.
85
+ - **Form participation and `FormField` wiring** exactly as `RoleMenu` does
86
+ them: a hidden input when `name` is set, nothing submitted while disabled, the
87
+ enclosing field's label naming the trigger, and `aria-label` composing purpose
88
+ with the current value.
89
+
90
+ **The option list is drawn to be read as the choice**, which is the one
91
+ visible change to an existing component: `Combobox` renders the same list, so
92
+ its popup picks all three corrections up. Options take the full text colour
93
+ rather than the muted one, which came from a popup that hangs under a field
94
+ holding the answer; where the popup _is_ the choice, options dimmer than the
95
+ heading over them read as less available than the label for them. The current
96
+ option carries a check and a medium weight rather than a fill alone, because
97
+ that fill is `--ssui-surface-muted` on `--ssui-surface-elevated` — a clear step
98
+ in a light theme and almost none in a dark one. And the keyboard highlight now
99
+ wins the background over the selected fill, so it stays visible on the selected
100
+ row, which for `SelectMenu` is the row a popup opens on.
101
+
102
+ **A bounded popup starts at the field's own width** and grows only if an option
103
+ needs more, so the two read as one control. It used to carry a fixed minimum for
104
+ both variants, which made the popup of any field narrower than that minimum
105
+ overhang it. An inline trigger is as wide as its text, which is no width for a
106
+ list, so it keeps a floor of its own.
107
+
108
+ **`Popover`** — `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`,
109
+ `PopoverClose`, `PopoverPortal` — is exported alongside it: the thin styled
110
+ wrapper `DropdownMenu` already has, on `@radix-ui/react-popover`, a new direct
111
+ dependency sharing its internals with the dialog and dropdown-menu packages
112
+ already here. `SelectMenu` is built on it rather than on `DropdownMenu` because a
113
+ menu's roving focus and typeahead both fight a filter field.
114
+
115
+ **What this does not do, on purpose.** It does not replace `Select`. On a phone
116
+ a native `<select>` opens the platform's own picker — thumb-sized, familiar,
117
+ outside the page — and `SelectMenu` cannot and will not. For a bounded field in
118
+ an ordinary form that is worth more than a themed popup, and `Select` stays the
119
+ default there. `docs/packages/saas-ui.md` now tables all four single-choice
120
+ controls — `Select`, `SelectMenu`, `Combobox`, `RoleMenu` — and which case each
121
+ answers, and the three older components' doc comments point at that table
122
+ rather than carrying partial copies of it. No multi-select: a chip-holding
123
+ trigger and checkable rows roughly double the API and the test matrix, and are
124
+ a change of their own.
125
+
3
126
  ## 0.11.0
4
127
 
5
128
  ### Minor Changes
@@ -64,7 +187,7 @@
64
187
  - **`ChoiceCard` no longer misreports what was saved.** React resets a
65
188
  `<form action={fn}>` once the action resolves, and a reset restores every
66
189
  control to its content attribute. A controlled radio only ever had its
67
- `checked` *property* written, so after a successful save the group snapped
190
+ `checked` _property_ written, so after a successful save the group snapped
68
191
  back to whatever was selected on first render, while the surrounding state,
69
192
  the hidden inputs, and the stored value all held the new selection. The write
70
193
  succeeded and only the control disagreed, which reads to a user as "my change
@@ -85,7 +208,7 @@
85
208
 
86
209
  - **A consumer's `filter` now runs with an empty query** rather than being
87
210
  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
211
+ unaffected; one that _excludes_ had its rule ignored for exactly as long as
89
212
  the field was empty, which is the state the popup opens in.
90
213
 
91
214
  Two more came out of the same consumer looking at the running app a second
@@ -116,7 +239,7 @@
116
239
 
117
240
  - **The list variant no longer clips its own focus ring.** The container
118
241
  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
242
+ radius. A Tailwind ring is a box-shadow drawn _outside_ the element, and a
120
243
  list row has no border or radius of its own, so it fills the container's
121
244
  padding box exactly — the clip amputated both vertical sides of every row's
122
245
  focus indicator plus the outer edges of the first and last. A keyboard user
@@ -163,7 +286,7 @@
163
286
 
164
287
  This was established the expensive way: a third attempt generated and signed a
165
288
  sigstore bundle and the registry rejected it with `422 ... Unsupported GitHub
166
- Actions source repository visibility: "private"`. That attempt published
289
+ Actions source repository visibility: "private"`. That attempt published
167
290
  nothing, so `0.10.4` is the current release.
168
291
 
169
292
  ## 0.10.3
@@ -6,4 +6,3 @@ export declare const badgeVariants: (props?: ({
6
6
  export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {
7
7
  }
8
8
  export declare const Badge: React.ForwardRefExoticComponent<BadgeProps & React.RefAttributes<HTMLSpanElement>>;
9
- export declare const Pill: React.ForwardRefExoticComponent<BadgeProps & React.RefAttributes<HTMLSpanElement>>;
@@ -20,4 +20,3 @@ export const badgeVariants = cva("inline-flex items-center gap-1 rounded-full bo
20
20
  });
21
21
  export const Badge = React.forwardRef(({ className, variant, ...props }, ref) => (_jsx("span", { ref: ref, className: cn(badgeVariants({ variant, className })), ...props })));
22
22
  Badge.displayName = "Badge";
23
- export const Pill = Badge;
@@ -0,0 +1,15 @@
1
+ import * as React from "react";
2
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ asChild?: boolean;
4
+ /** Sit on `--ssui-surface-elevated` instead of `--ssui-surface`. */
5
+ elevated?: boolean;
6
+ /** Strengthen the border on hover, for a card whose parts are clickable. Nothing else changes. */
7
+ interactive?: boolean;
8
+ }
9
+ /**
10
+ * The bordered surface: `--ssui-surface` on `--ssui-border` at `--ssui-radius-lg`. Padding is
11
+ * the consumer's (`className`), because a card holding a row and a card holding a form pad
12
+ * differently and a default would be overridden everywhere. `ListShell` is a different thing —
13
+ * the divided list container with header, toolbar, footer, and empty slots — and both stay.
14
+ */
15
+ export declare const Card: React.ForwardRefExoticComponent<CardProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ /**
6
+ * The bordered surface: `--ssui-surface` on `--ssui-border` at `--ssui-radius-lg`. Padding is
7
+ * the consumer's (`className`), because a card holding a row and a card holding a form pad
8
+ * differently and a default would be overridden everywhere. `ListShell` is a different thing —
9
+ * the divided list container with header, toolbar, footer, and empty slots — and both stay.
10
+ */
11
+ export const Card = React.forwardRef(({ asChild = false, className, elevated = false, interactive = false, ...props }, ref) => {
12
+ const Comp = asChild ? Slot : "div";
13
+ return (_jsx(Comp, { ref: ref, className: cn("rounded-[var(--ssui-radius-lg)] border border-[color:var(--ssui-border)] text-[color:var(--ssui-text)]", elevated
14
+ ? "bg-[color:var(--ssui-surface-elevated)]"
15
+ : "bg-[color:var(--ssui-surface)]", interactive && "transition-colors hover:border-[color:var(--ssui-border-strong)]", className), ...props }));
16
+ });
17
+ Card.displayName = "Card";
@@ -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";
@@ -4,6 +4,7 @@ export * from "./avatar.js";
4
4
  export * from "./badge.js";
5
5
  export * from "./brand-icons.js";
6
6
  export * from "./button.js";
7
+ export * from "./card.js";
7
8
  export * from "./checkbox.js";
8
9
  export * from "./choice-card.js";
9
10
  export * from "./collapsible.js";
@@ -23,12 +24,16 @@ export * from "./input.js";
23
24
  export * from "./list-shell.js";
24
25
  export * from "./login.js";
25
26
  export * from "./overflow-menu.js";
27
+ export * from "./page-container.js";
26
28
  export * from "./page-header.js";
29
+ export * from "./pill.js";
30
+ export * from "./popover.js";
27
31
  export * from "./rail-toggle.js";
28
32
  export * from "./request-access.js";
29
33
  export * from "./role-menu.js";
30
34
  export * from "./scroll-area.js";
31
35
  export * from "./search-input.js";
36
+ export * from "./select-menu.js";
32
37
  export * from "./select.js";
33
38
  export * from "./separator.js";
34
39
  export * from "./settings-layout.js";
@@ -4,6 +4,7 @@ export * from "./avatar.js";
4
4
  export * from "./badge.js";
5
5
  export * from "./brand-icons.js";
6
6
  export * from "./button.js";
7
+ export * from "./card.js";
7
8
  export * from "./checkbox.js";
8
9
  export * from "./choice-card.js";
9
10
  export * from "./collapsible.js";
@@ -23,12 +24,16 @@ export * from "./input.js";
23
24
  export * from "./list-shell.js";
24
25
  export * from "./login.js";
25
26
  export * from "./overflow-menu.js";
27
+ export * from "./page-container.js";
26
28
  export * from "./page-header.js";
29
+ export * from "./pill.js";
30
+ export * from "./popover.js";
27
31
  export * from "./rail-toggle.js";
28
32
  export * from "./request-access.js";
29
33
  export * from "./role-menu.js";
30
34
  export * from "./scroll-area.js";
31
35
  export * from "./search-input.js";
36
+ export * from "./select-menu.js";
32
37
  export * from "./select.js";
33
38
  export * from "./separator.js";
34
39
  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>>;