@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 +174 -34
- package/dist/components/audit-log.js +8 -3
- package/dist/components/checkbox.js +1 -1
- package/dist/components/choice-card.d.ts +29 -7
- package/dist/components/choice-card.js +92 -12
- package/dist/components/combobox.d.ts +63 -1
- package/dist/components/combobox.js +128 -26
- package/dist/components/input.js +1 -1
- package/dist/components/select.d.ts +8 -1
- package/dist/components/select.js +8 -1
- package/dist/components/textarea.js +1 -1
- package/dist/components/time-zone-field.d.ts +11 -2
- package/dist/components/time-zone-field.js +25 -15
- package/package.json +3 -2
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
|
-
-
|
|
8
|
-
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
workspace and shells out to `pnpm publish
|
|
41
|
-
`NPM_CONFIG_*` environment variables into its configuration
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 {
|
|
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) => (
|
|
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
|
|
15
|
-
* inputs, so form participation and arrow-key navigation between
|
|
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
|
|
30
|
-
*
|
|
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
|
|
8
|
-
* inputs, so form participation and arrow-key navigation between
|
|
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
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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 (
|
|
143
|
+
if (navigable.length === 0)
|
|
51
144
|
return;
|
|
52
145
|
const next = activeIndex < 0
|
|
53
146
|
? delta === 1
|
|
54
147
|
? 0
|
|
55
|
-
:
|
|
56
|
-
: (activeIndex + delta +
|
|
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",
|
|
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,
|
|
138
|
-
|
|
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 ? (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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";
|
package/dist/components/input.js
CHANGED
|
@@ -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
|
|
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
|
|
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 `
|
|
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 `
|
|
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(
|
|
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(
|
|
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.
|
|
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 --
|
|
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
|
}
|