@forwardreach/saas-ui 0.10.4 → 0.11.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,29 +1,170 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **Four additive form-primitive changes, every default unchanged.** Found by a
8
+ consumer adopting these components rather than by reading them; grouped because
9
+ they ship in one release, not because they are otherwise related.
10
+
11
+ - **`--ssui-border-control`.** A control's boundary is now separable from a
12
+ container's edge. Six boundaries read it — `Input`, `Textarea`, `Select`,
13
+ `Combobox`, `Checkbox`, and `AuditLog`'s filter selects — as
14
+ `var(--ssui-border-control,var(--ssui-border))`, the fallback pattern
15
+ `--ssui-switch-track` already uses. The package defines no value for it
16
+ anywhere, so **this is a visual no-op until a consumer sets it**, and setting
17
+ it is the whole of the opt-in.
18
+
19
+ WCAG 1.4.11 asks 3:1 of a boundary needed to identify an interactive control
20
+ and exempts a container's edge; those are two jobs and one token was doing
21
+ both, so raising a text field's edge meant raising eighteen container edges
22
+ with it. Worth being blunt rather than reassuring about what this does not
23
+ fix: the package's own `--ssui-border` is `#d0d5dd`, **1.47:1** on white, and
24
+ that is still what a control boundary resolves to if you do nothing. A
25
+ default that met the floor would darken every control in every consuming app
26
+ on a version bump, which is the decision this token exists to hand to the
27
+ consumer rather than make for them. See the token contract in
28
+ `docs/packages/saas-ui.md`.
29
+
30
+ - **`Select` gains `size`.** `"sm"` is 32px/12px with the chevron re-inset, to
31
+ sit in a row of `Button size="sm"`. `"md"` is today's styling verbatim and
32
+ the default. The name is taken over from the native `size` attribute, which
33
+ sets visible rows on a list-box select and is meaningless on the collapsed
34
+ control this styles.
35
+
36
+ - **`ChoiceCardGroup` gains `variant`.** `"list"` draws one bordered container
37
+ of divided rows — selection carried by a fill, the radio, and the title's
38
+ weight rather than a per-option border, with hover scoped to unchosen rows so
39
+ it cannot dim the chosen one. `"card"` remains the default and is unchanged.
40
+ Both shapes are legitimate; boxing _only_ the chosen option is the third
41
+ convention and the one that does not work. `TimeZoneField` takes the same
42
+ prop and passes it through.
43
+
44
+ - **`Combobox` gains `onCreate` / `createLabel` / `isDuplicate`.** When
45
+ `onCreate` is set, and the trimmed query matches no existing option, a create
46
+ entry joins the listbox as a real option — arrow-navigable,
47
+ Enter-committable, in the same index space — so no consumer needs a parallel
48
+ keyboard path to offer one row. It suppresses `emptyMessage`, and passes the
49
+ query to `onCreate` unnormalized, because what the created thing is called is
50
+ a product rule.
51
+
52
+ `isDuplicate` is what decides "no existing option", and it defaults to
53
+ case-insensitive equality on an option's `label` or `value`. Exact equality
54
+ would be the obvious default and is the wrong one: the offer is only honest
55
+ if it agrees with the normalization the consumer was just handed, and casing
56
+ is the cheapest way for the two to disagree — with `urgent` in the list,
57
+ typing `Urgent` would offer to create it, the consumer would lowercase it,
58
+ and the user has taken an action that did nothing or made a duplicate. Pass
59
+ your own when your rule goes further than casing.
60
+
61
+ Four more landed after a consumer reviewed the running app, and they are the
62
+ reason to take this release rather than the one before it:
63
+
64
+ - **`ChoiceCard` no longer misreports what was saved.** React resets a
65
+ `<form action={fn}>` once the action resolves, and a reset restores every
66
+ control to its content attribute. A controlled radio only ever had its
67
+ `checked` *property* written, so after a successful save the group snapped
68
+ back to whatever was selected on first render, while the surrounding state,
69
+ the hidden inputs, and the stored value all held the new selection. The write
70
+ succeeded and only the control disagreed, which reads to a user as "my change
71
+ did not take". `defaultChecked` now tracks the selection.
72
+
73
+ - **`Combobox`'s field is a search box, not editable text.** It used to keep
74
+ the committed label in the input, so a caret landed inside it, typing
75
+ appended to it, and the result matched nothing — and it invited the belief
76
+ that the text was the value and could be corrected in place, which it never
77
+ was. Focus now starts an empty query and the committed value moves to the
78
+ placeholder, so it stays visible while searching and cannot be edited into
79
+ something that was never a choice.
80
+
81
+ - **`ComboboxOption.inputLabel`** — the text the field shows once an option is
82
+ committed, defaulting to `label`. Where `trailing` carries part of an
83
+ option's identity rather than decoration, the collapsed field used to drop
84
+ it. `TimeZoneField` uses it to keep a chosen zone's GMT offset on screen.
85
+
86
+ - **A consumer's `filter` now runs with an empty query** rather than being
87
+ 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
89
+ the field was empty, which is the state the popup opens in.
90
+
91
+ Two more came out of the same consumer looking at the running app a second
92
+ time, and both are visible changes to what this package already rendered:
93
+
94
+ - **`ChoiceCard`'s description is body text.** It was `text-xs` on
95
+ `--ssui-text-subtle`, a pairing that fails the contrast floor on the row
96
+ most likely to be read: measured against a selected row's
97
+ `--ssui-accent-subtle` fill in a consuming app, 2.87:1 light and 3.77:1
98
+ dark, with unselected rows at 3.31:1 light, where 12px body text wants
99
+ 4.5:1. It is now `text-sm` on `--ssui-text-muted`, which clears the floor on
100
+ every one of those pairings. The size half matters independently: the local
101
+ treatments this component replaced across one consumer ran 14px, 12px, and
102
+ 11px, and normalising on the smallest silently shrank the caller whose
103
+ descriptions are whole sentences. **This changes existing rendering** — a
104
+ caller wanting a terser hint can pass `children` and size it itself.
105
+
106
+ - **`Combobox` gains `keepOpenOnSelect`.** Off by default. When set, a commit
107
+ clears the query and leaves the popup open instead of closing, for the
108
+ picker that adds to a set rather than setting a value. Without it such a
109
+ caller pays a full open-the-control round trip per item, and — if it keeps
110
+ its own panel open around the control, as a tag picker does — is left
111
+ showing an empty field with no list beneath it, which reads as inert rather
112
+ than as ready for the next choice.
113
+
114
+ A review of the change closed four more gaps in it, three of them in the parts
115
+ it had shipped to improve accessibility:
116
+
117
+ - **The list variant no longer clips its own focus ring.** The container
118
+ 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
120
+ list row has no border or radius of its own, so it fills the container's
121
+ padding box exactly — the clip amputated both vertical sides of every row's
122
+ focus indicator plus the outer edges of the first and last. A keyboard user
123
+ tabbing in saw one horizontal line, easily read as the divider. The rows now
124
+ round their own outer corners instead, which squares the fills off without
125
+ clipping anything.
126
+
127
+ - **The committed value is no longer styled as a hint.** Moving it into the
128
+ placeholder gave it `--ssui-text-subtle` — the pairing this same release
129
+ retired from `ChoiceCard`'s description for measuring 2.87:1. It now takes
130
+ `--ssui-text-muted` while a true placeholder stays subtle, and it is exposed
131
+ through `aria-describedby` as well, because screen-reader handling of
132
+ placeholder text is inconsistent and the field is empty while you search.
133
+
134
+ - **The default filter searches `inputLabel`.** An option showing
135
+ `Asia/Tokyo (GMT+09:00)` in the collapsed field was not findable by that
136
+ string once focus cleared the box — displayed but unsearchable.
137
+
138
+ - **`AuditLog`'s filters are the shared `Select`.** They were a hand-rolled
139
+ `<select>` on a local class string, which existed only because `Select` had
140
+ no size that fitted an inline filter row; `size="sm"` is that size. The copy
141
+ had already drifted — no focus ring, no chevron. **This changes existing
142
+ rendering**: the filters are 32px rather than 36px and gain the chevron and
143
+ the focus ring.
144
+
145
+ Existing behavior is otherwise unchanged: of the 174 tests predating this
146
+ release, the only two edited are the `TimeZoneField` assertions that pinned the
147
+ bare zone name in the collapsed field — the contract `inputLabel` deliberately
148
+ changes.
149
+
3
150
  ## 0.10.4
4
151
 
5
152
  ### Patch Changes
6
153
 
7
- - First release published with an npm provenance attestation. The package contents
8
- are unchanged from `0.10.2`; what is new is that the registry now records where
9
- this artifact came from — this repository, the commit it was built from, and the
10
- workflow run that built it — signed by the CI identity and recorded in a public
11
- transparency log, so a consumer can check a version's origin rather than trust
12
- its number.
13
-
14
- That check is what was missing when two vendored tarballs, `@forwardreach/saas-ui@0.9.1`
15
- and `@forwardreach/saas-mcp@0.1.3`, turned out to carry versions no commit in this
16
- repository ever produced. One left a consumer a month stale behind a _higher_
17
- version number than the real thing. Nothing in the pipeline could have caught
18
- either, because a locally packed tarball records no origin at all.
154
+ - **No provenance attestation, and none is coming.** Like `0.10.3`, this release
155
+ existed to carry one and does not; contents remain identical to `0.10.2`.
19
156
 
20
- `0.10.3` was meant to be this release and shipped without an attestation; see its
21
- entry for why. Both numbers stay retired rather than reissued.
157
+ npm refuses a provenance attestation whose GitHub source repository is
158
+ private, and this repository is private — the package being public on npm is
159
+ not sufficient, because the source repository is what an attestation has to
160
+ name. So no version of this package will carry one unless that changes. Use
161
+ the version lineage and the changelog to answer "which code is this?", and see
162
+ `future.md` for the packing-provenance work that remains open.
22
163
 
23
- Verify an installed copy with `npm audit signatures`, or from a clone of this
24
- repository with `node scripts/verify-provenance.mjs @forwardreach/saas-ui@<version>`.
25
- Neither needs registry credentials. Versions published before this one cannot
26
- gain an attestation retroactively.
164
+ This was established the expensive way: a third attempt generated and signed a
165
+ sigstore bundle and the registry rejected it with `422 ... Unsupported GitHub
166
+ Actions source repository visibility: "private"`. That attempt published
167
+ nothing, so `0.10.4` is the current release.
27
168
 
28
169
  ## 0.10.3
29
170
 
@@ -32,22 +173,21 @@
32
173
  - **No provenance attestation, despite what this release was for.** `0.10.3` was
33
174
  published to be the first artifact carrying one, and it is not: the package
34
175
  contents are identical to `0.10.2` and the registry reports no
35
- `dist.attestations` for it. Use `0.10.4` or later if you want a version whose
36
- origin you can check; this number is spent and will not be reissued.
37
-
38
- The cause is worth recording, because it is exactly the silent failure the
39
- verification step was added to catch. `changeset publish` detects a pnpm
40
- workspace and shells out to `pnpm publish`, and pnpm does not read arbitrary
41
- `NPM_CONFIG_*` environment variables into its configuration — it reads
42
- `provenance` as an rc option only. So `NPM_CONFIG_PROVENANCE=true` was
43
- accepted by the shell, ignored by the publisher, and the package went out
44
- with no attestation and no warning of any kind. Nothing in the publish output
45
- suggested anything was wrong. `pnpm config get provenance` even reports
46
- `true` for that env var, so it is not a usable pre-flight check either.
47
-
48
- The release failed red rather than reporting success, which is the property
49
- that mattered: the post-publish check queried the registry, found no
50
- attestation, and stopped the run before tags were pushed.
176
+ `dist.attestations` for it. It is real and installable; it is simply the same
177
+ code as `0.10.2` under a number that bought nothing. See `0.10.4`.
178
+
179
+ Two mechanisms were tried before the real blocker surfaced, and both failed
180
+ silently, which is worth recording. `changeset publish` detects a pnpm
181
+ workspace and shells out to `pnpm publish`; pnpm does not read arbitrary
182
+ `NPM_CONFIG_*` environment variables into its configuration, and
183
+ libnpmpublish gates provenance on `provenance === true`, a strict boolean
184
+ identity check. So both `NPM_CONFIG_PROVENANCE=true` (this release) and
185
+ `provenance=true` in an rc file (`0.10.4`) were accepted by the shell and
186
+ ignored by the publisher, with nothing in the publish output to say so.
187
+ `pnpm config get provenance` even reports `true` for the environment variable
188
+ the publisher ignores, so it is not a usable pre-flight check.
189
+
190
+ Neither number is reissued.
51
191
 
52
192
  ## 0.10.2
53
193
 
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
- import { cn } from "../utils/cn.js";
4
+ import { Select } from "./select.js";
5
5
  import { SettingsSection } from "./settings-section.js";
6
6
  function defaultFormatTimestamp(iso) {
7
7
  const date = new Date(iso);
@@ -9,7 +9,6 @@ function defaultFormatTimestamp(iso) {
9
9
  return iso;
10
10
  return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
11
11
  }
12
- const selectClass = "h-9 rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-2 text-xs text-[color:var(--ssui-text)]";
13
12
  const targetBadgeClass = "inline-flex items-center gap-1 rounded-md bg-[color:var(--ssui-surface-muted)] px-1.5 py-0.5 text-xs text-[color:var(--ssui-text-muted)]";
14
13
  /**
15
14
  * A domain-agnostic audit log: a titled section with optional filter dropdowns,
@@ -20,5 +19,11 @@ const targetBadgeClass = "inline-flex items-center gap-1 rounded-md bg-[color:va
20
19
  * reuse it directly.
21
20
  */
22
21
  export function AuditLog({ title = "Activity", description, icon, entries, filters = [], loading = false, error, emptyState = "No activity has been recorded.", renderTargetLink, formatTimestamp = defaultFormatTimestamp, }) {
23
- return (_jsxs(SettingsSection, { action: _jsx("span", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: loading ? "Loading..." : `${entries.length} events` }), description: description, icon: icon, title: title, children: [filters.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: filters.map((filter) => (_jsx("select", { "aria-label": filter.ariaLabel, className: cn(selectClass, "min-w-32"), onChange: (event) => filter.onChange(event.target.value), value: filter.value, children: filter.options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) }, filter.id))) })), error && _jsx("div", { className: "mt-2 text-xs text-red-600", children: error }), _jsxs("div", { className: "mt-3 divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)]", children: [entries.length === 0 && _jsx("div", { className: "px-3 py-6 text-center text-sm text-[color:var(--ssui-text-muted)]", children: emptyState }), entries.map((entry) => (_jsxs("details", { children: [_jsxs("summary", { className: "grid cursor-pointer list-none gap-2 px-3 py-3 md:grid-cols-[11rem_minmax(0,1fr)_8rem_8rem]", children: [_jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: formatTimestamp(entry.timestamp) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium", children: entry.title }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-1 text-xs text-[color:var(--ssui-text-muted)]", children: [entry.actorLabel && _jsx("span", { className: "truncate font-medium text-[color:var(--ssui-text)]", children: entry.actorLabel }), (entry.badges ?? []).map((badge) => (_jsx("span", { children: badge }, badge)))] })] }), _jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: entry.outcome }), _jsx("div", { className: "flex flex-wrap gap-1", children: (entry.targetIds ?? []).slice(0, 2).map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsx("span", { className: targetBadgeClass, children: "Record" }) }, targetId))) })] }), _jsxs("div", { className: "space-y-3 border-t border-[color:var(--ssui-border)] px-3 py-3", children: [entry.details && entry.details.length > 0 && (_jsx("div", { className: "grid gap-2 text-xs text-[color:var(--ssui-text-muted)] md:grid-cols-3", children: entry.details.map((detail) => (_jsxs("div", { children: [detail.label, ": ", detail.value] }, detail.label))) })), entry.targetIds && entry.targetIds.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5", children: entry.targetIds.map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsxs("span", { className: targetBadgeClass, children: ["Record ", targetId] }) }, targetId))) })), entry.metadata !== undefined && entry.metadata !== null && (_jsx("pre", { className: "max-h-56 overflow-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-muted)] p-3 font-mono text-xs text-[color:var(--ssui-text-muted)]", children: JSON.stringify(entry.metadata, null, 2) }))] })] }, entry.id)))] })] }));
22
+ return (_jsxs(SettingsSection, { action: _jsx("span", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: loading ? "Loading..." : `${entries.length} events` }), description: description, icon: icon, title: title, children: [filters.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: filters.map((filter) => (
23
+ // The shared small select rather than a local class string. This
24
+ // was a hand-rolled `<select>` only because `Select` had no size
25
+ // that fitted an inline filter row; it has one now, and keeping the
26
+ // copy meant a second control drifting away from the first — it had
27
+ // already lost the focus ring and the chevron.
28
+ _jsx(Select, { "aria-label": filter.ariaLabel, className: "min-w-32", onChange: (event) => filter.onChange(event.target.value), size: "sm", value: filter.value, children: filter.options.map((option) => (_jsx("option", { value: option.value, children: option.label }, option.value))) }, filter.id))) })), error && _jsx("div", { className: "mt-2 text-xs text-red-600", children: error }), _jsxs("div", { className: "mt-3 divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)]", children: [entries.length === 0 && _jsx("div", { className: "px-3 py-6 text-center text-sm text-[color:var(--ssui-text-muted)]", children: emptyState }), entries.map((entry) => (_jsxs("details", { children: [_jsxs("summary", { className: "grid cursor-pointer list-none gap-2 px-3 py-3 md:grid-cols-[11rem_minmax(0,1fr)_8rem_8rem]", children: [_jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: formatTimestamp(entry.timestamp) }), _jsxs("div", { className: "min-w-0", children: [_jsx("div", { className: "truncate text-sm font-medium", children: entry.title }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-1 text-xs text-[color:var(--ssui-text-muted)]", children: [entry.actorLabel && _jsx("span", { className: "truncate font-medium text-[color:var(--ssui-text)]", children: entry.actorLabel }), (entry.badges ?? []).map((badge) => (_jsx("span", { children: badge }, badge)))] })] }), _jsx("div", { className: "text-xs text-[color:var(--ssui-text-muted)]", children: entry.outcome }), _jsx("div", { className: "flex flex-wrap gap-1", children: (entry.targetIds ?? []).slice(0, 2).map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsx("span", { className: targetBadgeClass, children: "Record" }) }, targetId))) })] }), _jsxs("div", { className: "space-y-3 border-t border-[color:var(--ssui-border)] px-3 py-3", children: [entry.details && entry.details.length > 0 && (_jsx("div", { className: "grid gap-2 text-xs text-[color:var(--ssui-text-muted)] md:grid-cols-3", children: entry.details.map((detail) => (_jsxs("div", { children: [detail.label, ": ", detail.value] }, detail.label))) })), entry.targetIds && entry.targetIds.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-1.5", children: entry.targetIds.map((targetId) => (_jsx(React.Fragment, { children: renderTargetLink ? renderTargetLink(targetId) : _jsxs("span", { className: targetBadgeClass, children: ["Record ", targetId] }) }, targetId))) })), entry.metadata !== undefined && entry.metadata !== null && (_jsx("pre", { className: "max-h-56 overflow-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-muted)] p-3 font-mono text-xs text-[color:var(--ssui-text-muted)]", children: JSON.stringify(entry.metadata, null, 2) }))] })] }, entry.id)))] })] }));
24
29
  }
@@ -9,7 +9,7 @@ import { useFormFieldProps } from "./form-field.js";
9
9
  * support and platform semantics come from the browser, and only the box itself
10
10
  * needs theming. Participates in `FormField` wiring like the other controls.
11
11
  */
12
- export const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx("input", { ref: ref, type: "checkbox", className: cn("size-4 shrink-0 cursor-pointer rounded-[calc(var(--ssui-radius)/2)] border border-[color:var(--ssui-border)] accent-[color:var(--ssui-primary)] transition-shadow 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:opacity-50", className), ...useFormFieldProps(props) })));
12
+ export const Checkbox = React.forwardRef(({ className, ...props }, ref) => (_jsx("input", { ref: ref, type: "checkbox", className: cn("size-4 shrink-0 cursor-pointer rounded-[calc(var(--ssui-radius)/2)] border border-[color:var(--ssui-border-control,var(--ssui-border))] accent-[color:var(--ssui-primary)] transition-shadow 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:opacity-50", className), ...useFormFieldProps(props) })));
13
13
  Checkbox.displayName = "Checkbox";
14
14
  const CheckboxGroupContext = React.createContext(null);
15
15
  /**
@@ -1,4 +1,5 @@
1
1
  import * as React from "react";
2
+ export type ChoiceGroupVariant = "card" | "list";
2
3
  export interface ChoiceCardGroupProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
3
4
  /** Form field name shared by the underlying radios. */
4
5
  name?: string;
@@ -9,13 +10,32 @@ export interface ChoiceCardGroupProps extends Omit<React.HTMLAttributes<HTMLDivE
9
10
  onValueChange?: (value: string) => void;
10
11
  /** Accessible label for the radio group. */
11
12
  "aria-label"?: string;
13
+ /**
14
+ * How the group is drawn. Defaults to `"card"`.
15
+ *
16
+ * `"card"` boxes every option and lays them out in a responsive two-column
17
+ * grid — the selection-card treatment, for a choice that earns that weight.
18
+ * `"list"` draws one bordered container with divided rows inside it.
19
+ *
20
+ * The two are not interchangeable and the reason to have both is that boxing
21
+ * *only the chosen* option is the third convention, and it is the one that
22
+ * does not work: it reads as that row being the only live control on the
23
+ * page. So either every option gets a border or none does. Boxing none is
24
+ * right for an ordinary settings question, but leaves the options floating
25
+ * with nothing saying they are one question — a pair of them in a two-column
26
+ * grid drifts far enough apart to read as two unrelated fields. The
27
+ * container answers that without spending a border per option.
28
+ */
29
+ variant?: ChoiceGroupVariant;
12
30
  }
13
31
  /**
14
- * Radio group rendered as selectable bordered cards. Uses native radio
15
- * inputs, so form participation and arrow-key navigation between cards come
16
- * from the browser. Children are `ChoiceCard`s.
32
+ * Radio group rendered as selectable cards or as a bordered list. Uses native
33
+ * radio inputs, so form participation and arrow-key navigation between options
34
+ * come from the browser. Children are `ChoiceCard`s, which read the variant
35
+ * from this group rather than taking it themselves — a `ChoiceCard` outside a
36
+ * group is already an error, so the context is where the variant belongs.
17
37
  */
18
- export declare function ChoiceCardGroup({ name, value, defaultValue, onValueChange, className, children, ...props }: ChoiceCardGroupProps): import("react/jsx-runtime").JSX.Element;
38
+ export declare function ChoiceCardGroup({ name, value, defaultValue, onValueChange, variant, className, children, ...props }: ChoiceCardGroupProps): import("react/jsx-runtime").JSX.Element;
19
39
  export interface ChoiceCardProps extends Omit<React.LabelHTMLAttributes<HTMLLabelElement>, "title"> {
20
40
  /** Value submitted when this card is selected. */
21
41
  value: string;
@@ -26,8 +46,10 @@ export interface ChoiceCardProps extends Omit<React.LabelHTMLAttributes<HTMLLabe
26
46
  disabled?: boolean;
27
47
  }
28
48
  /**
29
- * One selectable card inside a `ChoiceCardGroup`: a bordered label wrapping a
30
- * native radio plus title and supporting text. The selected card gets the
31
- * emphasized border and muted fill.
49
+ * One option inside a `ChoiceCardGroup`: a label wrapping a native radio plus
50
+ * title and supporting text. In the card variant it is a bordered box whose
51
+ * selected state is an emphasized border and muted fill; in the list variant
52
+ * it is a divided row that carries no border of its own and marks selection
53
+ * with a fill.
32
54
  */
33
55
  export declare function ChoiceCard({ value, title, description, disabled, className, children, ...props }: ChoiceCardProps): import("react/jsx-runtime").JSX.Element;
@@ -4,11 +4,13 @@ import * as React from "react";
4
4
  import { cn } from "../utils/cn.js";
5
5
  const ChoiceCardGroupContext = React.createContext(null);
6
6
  /**
7
- * Radio group rendered as selectable bordered cards. Uses native radio
8
- * inputs, so form participation and arrow-key navigation between cards come
9
- * from the browser. Children are `ChoiceCard`s.
7
+ * Radio group rendered as selectable cards or as a bordered list. Uses native
8
+ * radio inputs, so form participation and arrow-key navigation between options
9
+ * come from the browser. Children are `ChoiceCard`s, which read the variant
10
+ * from this group rather than taking it themselves — a `ChoiceCard` outside a
11
+ * group is already an error, so the context is where the variant belongs.
10
12
  */
11
- export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, className, children, ...props }) {
13
+ export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, variant = "card", className, children, ...props }) {
12
14
  const generatedName = React.useId();
13
15
  const [uncontrolledValue, setUncontrolledValue] = React.useState(defaultValue);
14
16
  const selected = value ?? uncontrolledValue;
@@ -20,13 +22,27 @@ export function ChoiceCardGroup({ name, value, defaultValue, onValueChange, clas
20
22
  setUncontrolledValue(next);
21
23
  onValueChange?.(next);
22
24
  },
23
- }), [name, generatedName, selected, value, onValueChange]);
24
- return (_jsx("div", { role: "radiogroup", className: cn("grid gap-2 md:grid-cols-2", className), ...props, children: _jsx(ChoiceCardGroupContext.Provider, { value: contextValue, children: children }) }));
25
+ variant,
26
+ }), [name, generatedName, selected, value, onValueChange, variant]);
27
+ return (_jsx("div", { role: "radiogroup", className: cn(variant === "list"
28
+ ? // Deliberately *not* `overflow-hidden`, which is the obvious way to
29
+ // stop the first and last row's fills overhanging the container's
30
+ // radius and is the wrong one. A Tailwind ring is a box-shadow drawn
31
+ // outside the element's border box, and a list row fills this
32
+ // container's padding box exactly — so clipping here amputates the
33
+ // row's own focus ring: both vertical sides on every row, plus the
34
+ // top of the first and the bottom of the last. A keyboard user
35
+ // tabbing in would see a stray horizontal line easily read as the
36
+ // `divide-y` rule. The rows round their own outer corners instead.
37
+ "divide-y divide-[color:var(--ssui-border)] rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)]"
38
+ : "grid gap-2 md:grid-cols-2", className), ...props, children: _jsx(ChoiceCardGroupContext.Provider, { value: contextValue, children: children }) }));
25
39
  }
26
40
  /**
27
- * One selectable card inside a `ChoiceCardGroup`: a bordered label wrapping a
28
- * native radio plus title and supporting text. The selected card gets the
29
- * emphasized border and muted fill.
41
+ * One option inside a `ChoiceCardGroup`: a label wrapping a native radio plus
42
+ * title and supporting text. In the card variant it is a bordered box whose
43
+ * selected state is an emphasized border and muted fill; in the list variant
44
+ * it is a divided row that carries no border of its own and marks selection
45
+ * with a fill.
30
46
  */
31
47
  export function ChoiceCard({ value, title, description, disabled, className, children, ...props }) {
32
48
  const group = React.useContext(ChoiceCardGroupContext);
@@ -34,7 +50,71 @@ export function ChoiceCard({ value, title, description, disabled, className, chi
34
50
  throw new Error("ChoiceCard must be rendered inside a ChoiceCardGroup");
35
51
  }
36
52
  const selected = group.value === value;
37
- return (_jsxs("label", { className: cn("flex cursor-pointer gap-2 rounded-[var(--ssui-radius)] border px-3 py-2 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", selected
38
- ? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
39
- : "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", disabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx("input", { checked: selected, className: "mt-0.5 size-4 shrink-0 accent-current focus-visible:outline-none", disabled: disabled, name: group.name, onChange: () => group.setValue(value), type: "radio", value: value }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-medium", children: title }), description ? (_jsx("span", { className: "block text-xs text-[color:var(--ssui-text-subtle)]", children: description })) : null, children] })] }));
53
+ const list = group.variant === "list";
54
+ // Keep the radio's *default* in step with its current state, so a form reset
55
+ // lands on what is selected now rather than on what was selected first.
56
+ //
57
+ // This is not defensive coding; it is the difference between a settings form
58
+ // that reports the truth and one that lies. React resets a `<form action={fn}>`
59
+ // once the action resolves, and a reset restores every control to its content
60
+ // attribute. For a controlled radio React only ever writes the `checked`
61
+ // *property*, so the attribute keeps whatever the first render put there — and
62
+ // after a successful save the group snaps back to the option that was selected
63
+ // when the page loaded, while the surrounding state, the hidden inputs, and the
64
+ // database all hold the new one. The save worked; only the control disagrees,
65
+ // which is worse than a visible failure.
66
+ //
67
+ // `defaultChecked` is the property backing that attribute, so assigning it is
68
+ // the direct way to say "this is what a reset should return to".
69
+ const inputRef = React.useRef(null);
70
+ React.useEffect(() => {
71
+ if (inputRef.current)
72
+ inputRef.current.defaultChecked = selected;
73
+ }, [selected]);
74
+ return (_jsxs("label", { className: cn("flex cursor-pointer gap-2 transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-[color:var(--ssui-focus-ring)]", list
75
+ ? // No border: the container supplies it, and the dividers between
76
+ // rows are its `divide-y`. Selection is the fill plus the radio plus
77
+ // the title's weight — deliberately not a border, because the border
78
+ // here groups the options rather than marking one of them.
79
+ //
80
+ // The first and last rows do round their outer corners, so their
81
+ // fills square off against the container's radius. That is the job
82
+ // `overflow-hidden` on the container would have done, moved here
83
+ // because clipping there would cut the focus ring these rows draw
84
+ // outside themselves.
85
+ "items-start gap-3 p-3 first:rounded-t-[var(--ssui-radius)] last:rounded-b-[var(--ssui-radius)]"
86
+ : "rounded-[var(--ssui-radius)] border px-3 py-2", list
87
+ ? selected
88
+ ? // An opaque fill, not an alpha of one: a translucent tint over
89
+ // the surface composites to within a value or two of the hover
90
+ // highlight, which puts the chosen row and a merely pointed-at
91
+ // one at the same weight.
92
+ "bg-[color:var(--ssui-accent-subtle)] text-[color:var(--ssui-text)]"
93
+ : // Hover is scoped to unchosen rows. The hover overlay sits
94
+ // *below* the selected fill, so applying it unscoped would
95
+ // visibly dim the chosen row when you point at it.
96
+ "text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]"
97
+ : selected
98
+ ? "border-[color:var(--ssui-border-strong)] bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
99
+ : "border-[color:var(--ssui-border)] text-[color:var(--ssui-text-muted)] hover:bg-[color:var(--ssui-overlay-hover)]", disabled && "cursor-not-allowed opacity-50", className), ...props, children: [_jsx("input", { ref: inputRef, checked: selected,
100
+ // `accent-current` so the dot is the row's own foreground rather than
101
+ // the user agent's blue, which matters more in the list variant where
102
+ // the radio is the primary marker rather than one signal among several.
103
+ className: "mt-0.5 size-4 shrink-0 accent-current focus-visible:outline-none", disabled: disabled, name: group.name, onChange: () => group.setValue(value), type: "radio", value: value }), _jsxs("span", { className: "min-w-0", children: [_jsx("span", { className: "block text-sm font-medium", children: title }), description ? (
104
+ // Same size as the title and one step down in colour, not two. A
105
+ // description here is the content that decides the choice — often a
106
+ // full sentence — so it is read, not glanced at, and `text-subtle`
107
+ // put it below the contrast floor on the row most likely to be read:
108
+ // against the selected row's `--ssui-accent-subtle` fill it measured
109
+ // 2.87:1 in a consuming app's light theme and 3.77:1 in its dark
110
+ // one, where 12px text wants 4.5:1. Unselected rows fared little
111
+ // better at 3.31:1 light. `--ssui-text-muted` clears the floor on every one of
112
+ // those pairings with room to spare.
113
+ //
114
+ // 14px rather than 12px because the three-way split this component
115
+ // replaced across its consumers ran 14px, 12px, and 11px, and
116
+ // normalising on the smallest silently shrank the one caller whose
117
+ // descriptions are whole sentences. A caller wanting a terser hint
118
+ // can still pass `children` and size it itself.
119
+ _jsx("span", { className: "block text-sm text-[color:var(--ssui-text-muted)]", children: description })) : null, children] })] }));
40
120
  }
@@ -10,6 +10,16 @@ export interface ComboboxOption {
10
10
  * monospaced; an option may carry both.
11
11
  */
12
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;
13
23
  /**
14
24
  * Optional heading this option sits under. Consecutive options declaring the
15
25
  * same group collapse into one heading, exactly as `<optgroup>` does — the
@@ -35,8 +45,60 @@ export interface ComboboxProps {
35
45
  filter?: (option: ComboboxOption, query: string) => boolean;
36
46
  placeholder?: string;
37
47
  disabled?: boolean;
38
- /** Message shown when no option matches. Defaults to "No matches". */
48
+ /**
49
+ * Message shown when no option matches. Defaults to "No matches". Not shown
50
+ * while a create entry is offered — the popup is offering an action, so
51
+ * saying there is nothing to do contradicts the row underneath it.
52
+ */
39
53
  emptyMessage?: React.ReactNode;
54
+ /**
55
+ * Offer the typed query as a new option. When set, a trailing create entry
56
+ * joins the listbox whenever the trimmed query is non-empty and equals no
57
+ * option's label or value; choosing it calls this instead of
58
+ * `onValueChange`.
59
+ *
60
+ * The query arrives trimmed and otherwise raw. Normalization — casing,
61
+ * separators, a leading `#` — is the consumer's, because it is a product
62
+ * rule about what the created thing is called, not something this control
63
+ * can know.
64
+ */
65
+ onCreate?: (query: string) => void;
66
+ /** Label for the create entry. Defaults to `Create "<query>"`. */
67
+ createLabel?: (query: string) => React.ReactNode;
68
+ /**
69
+ * Decide whether an existing option already covers the typed query, which is
70
+ * what suppresses the create entry. Defaults to case-insensitive equality
71
+ * against an option's `label` or `value`.
72
+ *
73
+ * The default is case-*insensitive* rather than exact because the offer is
74
+ * only useful if it agrees with what `onCreate` will do, and casing is the
75
+ * cheapest way for the two to disagree: with `urgent` in the list, typing
76
+ * `Urgent` under an exact comparison offers "Create Urgent", the consumer
77
+ * lowercases it on create, and the user has taken an action that either did
78
+ * nothing or made a duplicate.
79
+ *
80
+ * Casing is only the common half. A consumer whose normalization does more —
81
+ * hyphenating spaces, stripping a leading `#` — owns the rest, and this is
82
+ * where it says so, because the component cannot guess the rule it was
83
+ * deliberately not given in {@link ComboboxProps.onCreate}.
84
+ */
85
+ isDuplicate?: (query: string, option: ComboboxOption) => boolean;
86
+ /**
87
+ * Leave the popup open after a choice is committed, clearing the query for
88
+ * the next one instead of closing.
89
+ *
90
+ * For the caller whose control adds to a set rather than setting a value — a
91
+ * tag picker, an attendee list — where closing after each pick makes adding
92
+ * three things cost three round trips through opening the control. Such a
93
+ * caller typically pairs this with `value=""`, since a control that holds no
94
+ * selection has nothing to display once the choice has been applied
95
+ * elsewhere.
96
+ *
97
+ * Off by default: for an ordinary single-select, staying open after the one
98
+ * choice has been made is the wrong behaviour, and closing is what says the
99
+ * choice registered.
100
+ */
101
+ keepOpenOnSelect?: boolean;
40
102
  id?: string;
41
103
  name?: string;
42
104
  "aria-label"?: string;
@@ -1,15 +1,34 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { ChevronDown } from "lucide-react";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { ChevronDown, Plus } from "lucide-react";
4
4
  import * as React from "react";
5
5
  import { cn } from "../utils/cn.js";
6
6
  import { useFormFieldProps } from "./form-field.js";
7
+ /**
8
+ * The create entry's stand-in `value`. It exists only so the entry can travel
9
+ * through the same `ComboboxOption` shape as every real option — sharing the
10
+ * index space, the `data-index` lookup, and the `aria-activedescendant` id —
11
+ * rather than needing a parallel keyboard path beside the list. `commit`
12
+ * routes on it and it never reaches `onValueChange`.
13
+ */
14
+ const CREATE_ENTRY_VALUE = "__ssui_combobox_create__";
7
15
  function defaultFilter(option, query) {
8
16
  const normalized = query.trim().toLowerCase().replaceAll(" ", "_");
9
17
  if (!normalized)
10
18
  return true;
11
19
  return (option.value.toLowerCase().includes(normalized) ||
12
- option.label.toLowerCase().replaceAll(" ", "_").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
+ function defaultIsDuplicate(query, option) {
29
+ const normalized = query.toLowerCase();
30
+ return (option.value.toLowerCase() === normalized ||
31
+ option.label.toLowerCase() === normalized);
13
32
  }
14
33
  /**
15
34
  * Filterable single-select following the WAI-ARIA combobox pattern: a text
@@ -18,24 +37,85 @@ function defaultFilter(option, query) {
18
37
  * option, Escape closes, and losing focus closes without committing a
19
38
  * partial value. Selection submits via a hidden input when `name` is set.
20
39
  */
21
- export const Combobox = React.forwardRef(({ options, value, onValueChange, filter = defaultFilter, placeholder, disabled, emptyMessage = "No matches", className, name, ...ariaProps }, ref) => {
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) => {
22
41
  const fieldProps = useFormFieldProps(ariaProps);
23
42
  const generatedId = React.useId();
24
43
  const id = fieldProps.id ?? generatedId;
25
44
  const listboxId = `${id}-listbox`;
45
+ const currentValueId = `${id}-current-value`;
26
46
  const [open, setOpen] = React.useState(false);
27
47
  const [query, setQuery] = React.useState(null);
28
48
  const [activeIndex, setActiveIndex] = React.useState(-1);
29
49
  const listRef = React.useRef(null);
30
50
  const selectedOption = options.find((option) => option.value === value);
31
- const filtered = query === null
32
- ? options
33
- : options.filter((option) => filter(option, query));
34
- const inputValue = query ?? selectedOption?.label ?? "";
35
- const activeOption = activeIndex >= 0 ? filtered[activeIndex] : undefined;
51
+ // The predicate runs even with nothing typed, against an empty query. It used to be
52
+ // skipped entirely in that state, on the reasoning that "no query" means "show
53
+ // everything" — true of a predicate that only *searches*, and false of one that also
54
+ // *excludes*. A consumer hiding options it must not offer (an already-applied tag, a
55
+ // record already named) had its rule silently ignored for exactly as long as the field
56
+ // was empty, which is the state the popup opens in. `defaultFilter` returns true for
57
+ // an empty query, so nothing changes for a consumer that only searches.
58
+ const filtered = options.filter((option) => filter(option, query ?? ""));
59
+ // What the field shows: the live query while searching, the committed option's
60
+ // own text otherwise. `inputLabel` lets an option carry a fuller form for the
61
+ // collapsed field than the listbox row needs.
62
+ const committedLabel = selectedOption?.inputLabel ?? selectedOption?.label ?? "";
63
+ const inputValue = query ?? committedLabel;
64
+ // The committed value moves into the placeholder while searching, which is a
65
+ // presentation trick with two costs to pay off.
66
+ //
67
+ // Colour: a placeholder is styled `--ssui-text-subtle`, which is the pairing
68
+ // this release retired from `ChoiceCard`'s description for measuring 2.87:1.
69
+ // That is the right weight for a hint the user is meant to type over and the
70
+ // wrong one for the answer to "what is this set to right now?", so the value
71
+ // takes `--ssui-text-muted` and only a true placeholder stays subtle.
72
+ //
73
+ // Announcement: screen-reader handling of placeholder text is inconsistent
74
+ // and never guaranteed, so with the box cleared on focus a non-sighted user
75
+ // could lose the current value entirely. `aria-describedby` gives it a
76
+ // programmatic home that does not depend on how the placeholder is treated.
77
+ const showingValueAsPlaceholder = open && committedLabel !== "";
78
+ const describedBy = [fieldProps["aria-describedby"], showingValueAsPlaceholder ? currentValueId : null]
79
+ .filter(Boolean)
80
+ .join(" ") || undefined;
81
+ // Offered only when there is something to create that is not already there:
82
+ // a "Create X" row sitting above an existing X is an offer to do nothing.
83
+ // The comparison is against the whole option list rather than the filtered
84
+ // one, because a match hidden by a consumer's own `filter` is still an
85
+ // existing option. `isDuplicate` decides what counts as a match, and its
86
+ // default is case-insensitive — see the prop's own note for why exact
87
+ // equality is the wrong default here.
88
+ const trimmedQuery = (query ?? "").trim();
89
+ const createEntry = onCreate !== undefined &&
90
+ trimmedQuery.length > 0 &&
91
+ !options.some((option) => isDuplicate(trimmedQuery, option))
92
+ ? { value: CREATE_ENTRY_VALUE, label: trimmedQuery }
93
+ : null;
94
+ // The one list the keyboard model runs on. Appending the create entry here
95
+ // rather than rendering it as a footer is the whole of its integration:
96
+ // wrap-around, `activeIndex`, scroll-into-view and `aria-activedescendant`
97
+ // all keep working with no branch for it.
98
+ const navigable = createEntry ? [...filtered, createEntry] : filtered;
99
+ const activeOption = activeIndex >= 0 ? navigable[activeIndex] : undefined;
36
100
  function openList() {
37
101
  setOpen(true);
38
102
  setActiveIndex(-1);
103
+ // Focusing starts a search, and a search starts empty.
104
+ //
105
+ // Leaving the committed label in the box made the field read as editable
106
+ // text holding the setting: a caret landed in the middle of
107
+ // "America/New_York", typing appended to it, and the result matched
108
+ // nothing. The user had to notice their own value was in the way and clear
109
+ // it before the control would do the one thing it exists to do. It also
110
+ // invited the reasonable belief that the text was the value and could be
111
+ // corrected in place, which it never was — an uncommitted query is
112
+ // discarded on close.
113
+ //
114
+ // Empty on focus keeps the two modes distinct: unfocused the field displays
115
+ // a value, focused it takes a query. The value is not lost while searching —
116
+ // it becomes the placeholder below — and it cannot be edited into something
117
+ // that was never a choice.
118
+ setQuery("");
39
119
  }
40
120
  function close() {
41
121
  setOpen(false);
@@ -43,17 +123,30 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
43
123
  setActiveIndex(-1);
44
124
  }
45
125
  function commit(option) {
46
- onValueChange?.(option.value);
126
+ if (option.value === CREATE_ENTRY_VALUE) {
127
+ onCreate?.(option.label);
128
+ }
129
+ else {
130
+ onValueChange?.(option.value);
131
+ }
132
+ if (keepOpenOnSelect) {
133
+ // Back to the state a freshly opened popup is in — empty query, nothing
134
+ // active — rather than closed. `setQuery("")` and not `setQuery(null)`:
135
+ // null means "show the committed value", and this control is mid-search.
136
+ setQuery("");
137
+ setActiveIndex(-1);
138
+ return;
139
+ }
47
140
  close();
48
141
  }
49
142
  function moveActive(delta) {
50
- if (filtered.length === 0)
143
+ if (navigable.length === 0)
51
144
  return;
52
145
  const next = activeIndex < 0
53
146
  ? delta === 1
54
147
  ? 0
55
- : filtered.length - 1
56
- : (activeIndex + delta + filtered.length) % filtered.length;
148
+ : navigable.length - 1
149
+ : (activeIndex + delta + navigable.length) % navigable.length;
57
150
  setActiveIndex(next);
58
151
  const element = listRef.current?.querySelector(`[data-index="${next}"]`);
59
152
  // Arriving at the first option of a group brings that group's heading into
@@ -126,27 +219,36 @@ export const Combobox = React.forwardRef(({ options, value, onValueChange, filte
126
219
  ? "bg-[color:var(--ssui-surface-muted)] text-[color:var(--ssui-text)]"
127
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));
128
221
  }
222
+ 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));
224
+ }
129
225
  return (_jsxs("div", { className: cn("relative", className), onBlur: (event) => {
130
226
  if (!event.currentTarget.contains(event.relatedTarget)) {
131
227
  close();
132
228
  }
133
- }, children: [_jsx("input", { ...fieldProps, ref: ref, id: id, role: "combobox", type: "text", "aria-autocomplete": "list", "aria-controls": listboxId, "aria-expanded": open, "aria-haspopup": "listbox", "aria-activedescendant": open && activeOption ? `${id}-option-${activeOption.value}` : undefined, autoComplete: "off", className: "flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", disabled: disabled, onChange: (event) => {
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",
230
+ // Content, not a hint. `cn` is tailwind-merge, so this replaces the
231
+ // subtle placeholder colour above rather than racing it.
232
+ showingValueAsPlaceholder &&
233
+ "placeholder:text-[color:var(--ssui-text-muted)]"), disabled: disabled, onChange: (event) => {
134
234
  setQuery(event.target.value);
135
235
  setOpen(true);
136
236
  setActiveIndex(-1);
137
- }, onFocus: openList, onKeyDown: handleKeyDown, placeholder: placeholder, value: inputValue }), _jsx("button", { "aria-label": "Show options", className: "absolute right-1 top-1 grid size-8 place-items-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)] disabled:pointer-events-none disabled:opacity-50", disabled: disabled, onClick: () => {
138
- setQuery(null);
237
+ }, onFocus: openList, onKeyDown: handleKeyDown,
238
+ // While searching, the committed value takes the placeholder's place, so
239
+ // emptying the box to type never hides what is currently set.
240
+ placeholder: open && committedLabel ? committedLabel : placeholder, value: inputValue }), _jsx("button", { "aria-label": "Show options", className: "absolute right-1 top-1 grid size-8 place-items-center rounded-[var(--ssui-radius-sm)] text-[color:var(--ssui-text-subtle)] transition-colors hover:bg-[color:var(--ssui-overlay-hover)] hover:text-[color:var(--ssui-text)] disabled:pointer-events-none disabled:opacity-50", disabled: disabled, onClick: () => {
139
241
  openList();
140
242
  document.getElementById(id)?.focus();
141
- }, tabIndex: -1, type: "button", children: _jsx(ChevronDown, { "aria-hidden": "true", className: "size-4" }) }), name ? _jsx("input", { name: name, type: "hidden", value: value ?? "" }) : null, open ? (_jsx("div", { className: "absolute z-50 mt-1 max-h-64 w-full overflow-y-auto rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface-elevated)] p-1 shadow-[var(--ssui-shadow-md)]", id: listboxId, ref: listRef, role: "listbox", children: rows.length > 0 ? (rows.map((row, rowIndex) => row.kind === "option" ? (renderOption(row.entry)) : (
142
- // A run is wrapped in `role="group"` labelled by its heading —
143
- // the APG grouped-listbox shape. Without it the grouping is
144
- // conveyed to sighted users only: a roleless heading is not in
145
- // the listbox's content model, so a screen-reader user hears a
146
- // flat list of options and never learns which kind each is.
147
- // The heading takes `role="presentation"` so it stays out of
148
- // that content model while `aria-labelledby` still names the
149
- // group from its text.
150
- _jsxs("div", { "aria-labelledby": `${id}-group-${rowIndex}`, role: "group", children: [_jsx("div", { className: cn("px-2 pb-0.5 pt-4 text-xs font-medium uppercase tracking-wide text-[color:var(--ssui-text-subtle)]", rowIndex === 0 && "pt-1"), id: `${id}-group-${rowIndex}`, role: "presentation", children: row.group }), row.entries.map(renderOption)] }, `group-${rowIndex}`)))) : (_jsx("div", { className: "px-2 py-1.5 text-sm text-[color:var(--ssui-text-subtle)]", children: emptyMessage })) })) : null] }));
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] }));
151
253
  });
152
254
  Combobox.displayName = "Combobox";
@@ -2,5 +2,5 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../utils/cn.js";
4
4
  import { useFormFieldProps } from "./form-field.js";
5
- export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
5
+ export const Input = React.forwardRef(({ className, type = "text", ...props }, ref) => (_jsx("input", { ref: ref, type: type, className: cn("flex h-10 w-full rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
6
6
  Input.displayName = "Input";
@@ -1,5 +1,12 @@
1
1
  import * as React from "react";
2
- export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
2
+ export type SelectSize = "sm" | "md";
3
+ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "size"> {
4
+ /**
5
+ * Control scale. Defaults to `"md"`, which matches `Input`. `"sm"` matches
6
+ * `Button size="sm"`, which is the reason it exists: a select in an inline
7
+ * row of small buttons.
8
+ */
9
+ size?: SelectSize;
3
10
  }
4
11
  /**
5
12
  * Styled native `<select>` matching the shared `Input` look, with a trailing
@@ -3,10 +3,17 @@ import { ChevronDown } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { cn } from "../utils/cn.js";
5
5
  import { useFormFieldProps } from "./form-field.js";
6
+ // Height, text, padding and chevron inset are held together per size rather
7
+ // than split across rules, because the chevron's `right` has to track the
8
+ // select's right padding — separate them and they drift.
9
+ const SIZE_CLASSES = {
10
+ md: { select: "h-10 py-2 pl-3 pr-9 text-sm", chevron: "right-3 size-4" },
11
+ sm: { select: "h-8 py-1 pl-2.5 pr-7 text-xs", chevron: "right-2 size-3.5" },
12
+ };
6
13
  /**
7
14
  * Styled native `<select>` matching the shared `Input` look, with a trailing
8
15
  * chevron. Native on purpose: correct mobile behavior and form participation
9
16
  * with zero positioning code. Participates in `FormField` wiring.
10
17
  */
11
- export const Select = React.forwardRef(({ className, children, ...props }, ref) => (_jsxs("div", { className: cn("relative", className), children: [_jsx("select", { ref: ref, className: "flex h-10 w-full cursor-pointer appearance-none rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] py-2 pl-3 pr-9 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors 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", ...useFormFieldProps(props), children: children }), _jsx(ChevronDown, { "aria-hidden": "true", className: "pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ssui-text-subtle)]" })] })));
18
+ 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) })] })));
12
19
  Select.displayName = "Select";
@@ -2,5 +2,5 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../utils/cn.js";
4
4
  import { useFormFieldProps } from "./form-field.js";
5
- export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border)] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
5
+ export const Textarea = React.forwardRef(({ className, rows = 4, ...props }, ref) => (_jsx("textarea", { ref: ref, rows: rows, className: cn("flex min-h-24 w-full resize-y rounded-[var(--ssui-radius)] border border-[color:var(--ssui-border-control,var(--ssui-border))] bg-[color:var(--ssui-surface)] px-3 py-2 text-sm text-[color:var(--ssui-text)] shadow-sm transition-colors placeholder:text-[color:var(--ssui-text-subtle)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)] disabled:cursor-not-allowed disabled:bg-[color:var(--ssui-surface-muted)] disabled:opacity-70", className), ...useFormFieldProps(props) })));
6
6
  Textarea.displayName = "Textarea";
@@ -1,4 +1,5 @@
1
1
  import type { TimeZoneOption } from "../utils/time-zones.js";
2
+ import type { ChoiceGroupVariant } from "./choice-card.js";
2
3
  export type TimeZoneMode = "auto" | "fixed";
3
4
  export interface TimeZoneFieldProps {
4
5
  /** Initial mode. Defaults to "auto". */
@@ -17,14 +18,22 @@ export interface TimeZoneFieldProps {
17
18
  options?: TimeZoneOption[];
18
19
  /** Accessible label for the mode choice. Defaults to "Time zone mode". */
19
20
  "aria-label"?: string;
21
+ /**
22
+ * How the mode choice is drawn — passed straight to `ChoiceCardGroup`.
23
+ * Defaults to `"card"`. Use `"list"` where the surrounding form treats a
24
+ * choice group as one bordered container of rows; see `ChoiceCardGroupProps`
25
+ * for why the two are not interchangeable.
26
+ */
27
+ variant?: ChoiceGroupVariant;
20
28
  className?: string;
21
29
  }
22
30
  /**
23
31
  * Composite time-zone preference control: an auto/fixed mode choice rendered
24
- * as `ChoiceCard`s (auto shows the detected browser zone inline) plus a
32
+ * as a `ChoiceCardGroup` in either variant (auto shows the detected browser
33
+ * zone inline) plus a
25
34
  * filterable zone `Combobox` shown only in fixed mode. Search matches zone
26
35
  * names with spaces or underscores and offset text ("GMT+09"). Submits
27
36
  * through hidden inputs — `timeZoneMode`, `timeZone`, `browserTimeZone` —
28
37
  * for server-action form handling.
29
38
  */
30
- export declare function TimeZoneField({ defaultMode, defaultTimeZone, onChange, options, className, "aria-label": ariaLabel, }: TimeZoneFieldProps): import("react/jsx-runtime").JSX.Element;
39
+ export declare function TimeZoneField({ defaultMode, defaultTimeZone, onChange, options, variant, className, "aria-label": ariaLabel, }: TimeZoneFieldProps): import("react/jsx-runtime").JSX.Element;
@@ -5,6 +5,24 @@ import { cn } from "../utils/cn.js";
5
5
  import { detectBrowserTimeZone, formatTimeZoneOffset, timeZoneOptions, } from "../utils/time-zones.js";
6
6
  import { ChoiceCard, ChoiceCardGroup } from "./choice-card.js";
7
7
  import { Combobox } from "./combobox.js";
8
+ /**
9
+ * One shape for both zone sources — the consumer's `options` and the built IANA
10
+ * list — so a field added to it is added once.
11
+ *
12
+ * `inputLabel` is the reason this is worth a function: the offset is part of
13
+ * which zone this is rather than decoration on the row, so it has to survive
14
+ * into the collapsed field. Without it a chosen zone shows as a bare IANA name
15
+ * and the reader has to reopen the list to check they picked the offset they
16
+ * meant.
17
+ */
18
+ function toComboboxOption(zone) {
19
+ return {
20
+ value: zone.value,
21
+ label: zone.label,
22
+ trailing: zone.offset,
23
+ inputLabel: `${zone.label} (${zone.offset})`,
24
+ };
25
+ }
8
26
  function matchesZoneQuery(option, query) {
9
27
  const normalized = query.trim().toLowerCase();
10
28
  if (!normalized)
@@ -19,13 +37,14 @@ function matchesZoneQuery(option, query) {
19
37
  }
20
38
  /**
21
39
  * Composite time-zone preference control: an auto/fixed mode choice rendered
22
- * as `ChoiceCard`s (auto shows the detected browser zone inline) plus a
40
+ * as a `ChoiceCardGroup` in either variant (auto shows the detected browser
41
+ * zone inline) plus a
23
42
  * filterable zone `Combobox` shown only in fixed mode. Search matches zone
24
43
  * names with spaces or underscores and offset text ("GMT+09"). Submits
25
44
  * through hidden inputs — `timeZoneMode`, `timeZone`, `browserTimeZone` —
26
45
  * for server-action form handling.
27
46
  */
28
- export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange, options, className, "aria-label": ariaLabel = "Time zone mode", }) {
47
+ export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange, options, variant = "card", className, "aria-label": ariaLabel = "Time zone mode", }) {
29
48
  const [mode, setMode] = React.useState(defaultMode);
30
49
  const [browserTimeZone, setBrowserTimeZone] = React.useState("UTC");
31
50
  const [fixedTimeZone, setFixedTimeZone] = React.useState(defaultTimeZone ?? "");
@@ -41,21 +60,12 @@ export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange,
41
60
  // component's lifetime. Consumer-provided `options` skip the build entirely.
42
61
  const builtOptionsRef = React.useRef(null);
43
62
  const comboboxOptions = React.useMemo(() => {
44
- if (options) {
45
- return options.map((zone) => ({
46
- value: zone.value,
47
- label: zone.label,
48
- trailing: zone.offset,
49
- }));
50
- }
63
+ if (options)
64
+ return options.map(toComboboxOption);
51
65
  if (mode !== "fixed")
52
66
  return [];
53
67
  if (!builtOptionsRef.current) {
54
- builtOptionsRef.current = timeZoneOptions().map((zone) => ({
55
- value: zone.value,
56
- label: zone.label,
57
- trailing: zone.offset,
58
- }));
68
+ builtOptionsRef.current = timeZoneOptions().map(toComboboxOption);
59
69
  }
60
70
  return builtOptionsRef.current;
61
71
  }, [options, mode]);
@@ -68,5 +78,5 @@ export function TimeZoneField({ defaultMode = "auto", defaultTimeZone, onChange,
68
78
  setFixedTimeZone(next.timeZone);
69
79
  onChange?.({ mode: nextMode, timeZone: nextZone });
70
80
  }
71
- return (_jsxs("div", { className: cn("space-y-3", className), children: [_jsx("input", { name: "timeZoneMode", type: "hidden", value: mode }), _jsx("input", { name: "timeZone", type: "hidden", value: fixedTimeZone }), _jsx("input", { name: "browserTimeZone", type: "hidden", value: browserTimeZone }), _jsxs(ChoiceCardGroup, { "aria-label": ariaLabel, onValueChange: (value) => update({ mode: value }), value: mode, children: [_jsx(ChoiceCard, { description: _jsxs(_Fragment, { children: ["Use the browser time zone", _jsxs("span", { className: "mt-0.5 block font-mono text-[color:var(--ssui-text-muted)]", children: [browserTimeZone, " (", formatTimeZoneOffset(browserTimeZone), ")"] })] }), title: "Auto", value: "auto" }), _jsx(ChoiceCard, { description: "Always use one time zone", title: "Fixed", value: "fixed" })] }), mode === "fixed" ? (_jsx(Combobox, { "aria-label": "Fixed time zone", emptyMessage: "No matching time zones", filter: matchesZoneQuery, onValueChange: (value) => update({ timeZone: value }), options: comboboxOptions, placeholder: "America/New_York", value: fixedTimeZone })) : null] }));
81
+ return (_jsxs("div", { className: cn("space-y-3", className), children: [_jsx("input", { name: "timeZoneMode", type: "hidden", value: mode }), _jsx("input", { name: "timeZone", type: "hidden", value: fixedTimeZone }), _jsx("input", { name: "browserTimeZone", type: "hidden", value: browserTimeZone }), _jsxs(ChoiceCardGroup, { "aria-label": ariaLabel, onValueChange: (value) => update({ mode: value }), value: mode, variant: variant, children: [_jsx(ChoiceCard, { description: _jsxs(_Fragment, { children: ["Use the browser time zone", _jsxs("span", { className: "mt-0.5 block font-mono text-[color:var(--ssui-text-muted)]", children: [browserTimeZone, " (", formatTimeZoneOffset(browserTimeZone), ")"] })] }), title: "Auto", value: "auto" }), _jsx(ChoiceCard, { description: "Always use one time zone", title: "Fixed", value: "fixed" })] }), mode === "fixed" ? (_jsx(Combobox, { "aria-label": "Fixed time zone", emptyMessage: "No matching time zones", filter: matchesZoneQuery, onValueChange: (value) => update({ timeZone: value }), options: comboboxOptions, placeholder: "America/New_York", value: fixedTimeZone })) : null] }));
72
82
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.10.4",
3
+ "version": "0.11.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,
@@ -72,12 +72,13 @@
72
72
  "typescript": "^5.8.3",
73
73
  "vitest": "^3.2.4"
74
74
  },
75
+ "gitHead": "544931dd34ff3f738a58cf9e7ad161fb8fa8763f",
75
76
  "scripts": {
76
77
  "build": "pnpm clean && tsc -p tsconfig.build.json && node scripts/copy-styles.mjs",
77
78
  "dev": "node scripts/copy-styles.mjs && tsc -p tsconfig.build.json --watch",
78
79
  "check:brand": "node scripts/check-brand-neutrality.mjs",
79
80
  "clean": "rimraf dist coverage dist-pack *.tgz",
80
- "pack:local": "pnpm build && mkdir -p dist-pack && pnpm pack --pack-destination dist-pack",
81
+ "pack:local": "pnpm build && mkdir -p dist-pack && pnpm pack --out dist-pack/forwardreach-saas-ui-local.tgz",
81
82
  "test": "vitest run && pnpm check:brand",
82
83
  "typecheck": "tsc --noEmit"
83
84
  }