@forwardreach/saas-ui 0.10.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +253 -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 +60 -24
- package/dist/components/combobox.js +123 -71
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.js +2 -0
- package/dist/components/input.js +1 -1
- package/dist/components/option-list.d.ts +140 -0
- package/dist/components/option-list.js +142 -0
- package/dist/components/popover.d.ts +23 -0
- package/dist/components/popover.js +32 -0
- package/dist/components/role-menu.d.ts +7 -5
- package/dist/components/role-menu.js +7 -5
- package/dist/components/select-menu.d.ts +128 -0
- package/dist/components/select-menu.js +219 -0
- package/dist/components/select.d.ts +14 -1
- package/dist/components/select.js +14 -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 +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,29 +1,249 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- **`SelectMenu`: a single choice whose popup this package draws.** A native
|
|
8
|
+
`<select>` styled down to a line of text has exactly one piece of chrome left —
|
|
9
|
+
the platform's own popup — and it is the one piece CSS cannot reach. This is
|
|
10
|
+
the component for that case. It is additive in API — no existing export
|
|
11
|
+
changes shape — and the one existing call site that renders differently is
|
|
12
|
+
named below.
|
|
13
|
+
|
|
14
|
+
- **Two trigger presentations.** `variant="control"` (the default) is `Select`'s
|
|
15
|
+
bounded field with a chevron, in the same `sm`/`md` scale, so swapping one for
|
|
16
|
+
the other returns the same field with a themed popup. `variant="inline"` draws
|
|
17
|
+
no box at rest: the trigger is the label's own text at the size of the line it
|
|
18
|
+
sits in, with the hover surface, the chevron, and a focus ring without an
|
|
19
|
+
offset arriving on hover, focus, and while open. The inline variant is why the
|
|
20
|
+
component exists, and shipping it is what keeps a consumer from forcing a 40px
|
|
21
|
+
bordered control down to a 20px line through `className` and child selectors.
|
|
22
|
+
- **The same options `Combobox` takes**, groups included: consecutive options
|
|
23
|
+
declaring one group collapse under one heading, an ungrouped option ends the
|
|
24
|
+
run above it, and the component never reorders. The option list, its keyboard
|
|
25
|
+
model, and its group semantics are `Combobox`'s own, extracted into an
|
|
26
|
+
internal module both components render — `Combobox`'s suite passes unedited
|
|
27
|
+
across the extraction.
|
|
28
|
+
- **A filter once the list is long enough**, at `SELECT_MENU_SEARCH_THRESHOLD`
|
|
29
|
+
options (eight) and not before; `searchable` forces it either way. With a
|
|
30
|
+
filter, focus stays in the field while the list is navigated; without one, the
|
|
31
|
+
listbox itself holds focus. Both run the listbox pattern through
|
|
32
|
+
`aria-activedescendant`.
|
|
33
|
+
- **`emptyLabel`** offers clearing the value as a row that reports `""`.
|
|
34
|
+
**`placeholder`** is what the trigger shows when no option matches `value` — a
|
|
35
|
+
field holding nothing, or one holding a value the list no longer offers, which
|
|
36
|
+
is neither shown as chosen nor replaced by whatever happens to be first.
|
|
37
|
+
- **`defaultOpen`, `open`, `onOpenChange`**, for the consumer whose control
|
|
38
|
+
appears in response to an edit gesture: open the popup as the row enters edit
|
|
39
|
+
mode, so choosing costs one click rather than two, and read a close that
|
|
40
|
+
followed no `onValueChange` as a cancel.
|
|
41
|
+
- **Form participation and `FormField` wiring** exactly as `RoleMenu` does
|
|
42
|
+
them: a hidden input when `name` is set, nothing submitted while disabled, the
|
|
43
|
+
enclosing field's label naming the trigger, and `aria-label` composing purpose
|
|
44
|
+
with the current value.
|
|
45
|
+
|
|
46
|
+
**The option list is drawn to be read as the choice**, which is the one
|
|
47
|
+
visible change to an existing component: `Combobox` renders the same list, so
|
|
48
|
+
its popup picks all three corrections up. Options take the full text colour
|
|
49
|
+
rather than the muted one, which came from a popup that hangs under a field
|
|
50
|
+
holding the answer; where the popup *is* the choice, options dimmer than the
|
|
51
|
+
heading over them read as less available than the label for them. The current
|
|
52
|
+
option carries a check and a medium weight rather than a fill alone, because
|
|
53
|
+
that fill is `--ssui-surface-muted` on `--ssui-surface-elevated` — a clear step
|
|
54
|
+
in a light theme and almost none in a dark one. And the keyboard highlight now
|
|
55
|
+
wins the background over the selected fill, so it stays visible on the selected
|
|
56
|
+
row, which for `SelectMenu` is the row a popup opens on.
|
|
57
|
+
|
|
58
|
+
**A bounded popup starts at the field's own width** and grows only if an option
|
|
59
|
+
needs more, so the two read as one control. It used to carry a fixed minimum for
|
|
60
|
+
both variants, which made the popup of any field narrower than that minimum
|
|
61
|
+
overhang it. An inline trigger is as wide as its text, which is no width for a
|
|
62
|
+
list, so it keeps a floor of its own.
|
|
63
|
+
|
|
64
|
+
**`Popover`** — `Popover`, `PopoverTrigger`, `PopoverContent`, `PopoverAnchor`,
|
|
65
|
+
`PopoverClose`, `PopoverPortal` — is exported alongside it: the thin styled
|
|
66
|
+
wrapper `DropdownMenu` already has, on `@radix-ui/react-popover`, a new direct
|
|
67
|
+
dependency sharing its internals with the dialog and dropdown-menu packages
|
|
68
|
+
already here. `SelectMenu` is built on it rather than on `DropdownMenu` because a
|
|
69
|
+
menu's roving focus and typeahead both fight a filter field.
|
|
70
|
+
|
|
71
|
+
**What this does not do, on purpose.** It does not replace `Select`. On a phone
|
|
72
|
+
a native `<select>` opens the platform's own picker — thumb-sized, familiar,
|
|
73
|
+
outside the page — and `SelectMenu` cannot and will not. For a bounded field in
|
|
74
|
+
an ordinary form that is worth more than a themed popup, and `Select` stays the
|
|
75
|
+
default there. `docs/packages/saas-ui.md` now tables all four single-choice
|
|
76
|
+
controls — `Select`, `SelectMenu`, `Combobox`, `RoleMenu` — and which case each
|
|
77
|
+
answers, and the three older components' doc comments point at that table
|
|
78
|
+
rather than carrying partial copies of it. No multi-select: a chip-holding
|
|
79
|
+
trigger and checkable rows roughly double the API and the test matrix, and are
|
|
80
|
+
a change of their own.
|
|
81
|
+
|
|
82
|
+
## 0.11.0
|
|
83
|
+
|
|
84
|
+
### Minor Changes
|
|
85
|
+
|
|
86
|
+
- **Four additive form-primitive changes, every default unchanged.** Found by a
|
|
87
|
+
consumer adopting these components rather than by reading them; grouped because
|
|
88
|
+
they ship in one release, not because they are otherwise related.
|
|
89
|
+
|
|
90
|
+
- **`--ssui-border-control`.** A control's boundary is now separable from a
|
|
91
|
+
container's edge. Six boundaries read it — `Input`, `Textarea`, `Select`,
|
|
92
|
+
`Combobox`, `Checkbox`, and `AuditLog`'s filter selects — as
|
|
93
|
+
`var(--ssui-border-control,var(--ssui-border))`, the fallback pattern
|
|
94
|
+
`--ssui-switch-track` already uses. The package defines no value for it
|
|
95
|
+
anywhere, so **this is a visual no-op until a consumer sets it**, and setting
|
|
96
|
+
it is the whole of the opt-in.
|
|
97
|
+
|
|
98
|
+
WCAG 1.4.11 asks 3:1 of a boundary needed to identify an interactive control
|
|
99
|
+
and exempts a container's edge; those are two jobs and one token was doing
|
|
100
|
+
both, so raising a text field's edge meant raising eighteen container edges
|
|
101
|
+
with it. Worth being blunt rather than reassuring about what this does not
|
|
102
|
+
fix: the package's own `--ssui-border` is `#d0d5dd`, **1.47:1** on white, and
|
|
103
|
+
that is still what a control boundary resolves to if you do nothing. A
|
|
104
|
+
default that met the floor would darken every control in every consuming app
|
|
105
|
+
on a version bump, which is the decision this token exists to hand to the
|
|
106
|
+
consumer rather than make for them. See the token contract in
|
|
107
|
+
`docs/packages/saas-ui.md`.
|
|
108
|
+
|
|
109
|
+
- **`Select` gains `size`.** `"sm"` is 32px/12px with the chevron re-inset, to
|
|
110
|
+
sit in a row of `Button size="sm"`. `"md"` is today's styling verbatim and
|
|
111
|
+
the default. The name is taken over from the native `size` attribute, which
|
|
112
|
+
sets visible rows on a list-box select and is meaningless on the collapsed
|
|
113
|
+
control this styles.
|
|
114
|
+
|
|
115
|
+
- **`ChoiceCardGroup` gains `variant`.** `"list"` draws one bordered container
|
|
116
|
+
of divided rows — selection carried by a fill, the radio, and the title's
|
|
117
|
+
weight rather than a per-option border, with hover scoped to unchosen rows so
|
|
118
|
+
it cannot dim the chosen one. `"card"` remains the default and is unchanged.
|
|
119
|
+
Both shapes are legitimate; boxing _only_ the chosen option is the third
|
|
120
|
+
convention and the one that does not work. `TimeZoneField` takes the same
|
|
121
|
+
prop and passes it through.
|
|
122
|
+
|
|
123
|
+
- **`Combobox` gains `onCreate` / `createLabel` / `isDuplicate`.** When
|
|
124
|
+
`onCreate` is set, and the trimmed query matches no existing option, a create
|
|
125
|
+
entry joins the listbox as a real option — arrow-navigable,
|
|
126
|
+
Enter-committable, in the same index space — so no consumer needs a parallel
|
|
127
|
+
keyboard path to offer one row. It suppresses `emptyMessage`, and passes the
|
|
128
|
+
query to `onCreate` unnormalized, because what the created thing is called is
|
|
129
|
+
a product rule.
|
|
130
|
+
|
|
131
|
+
`isDuplicate` is what decides "no existing option", and it defaults to
|
|
132
|
+
case-insensitive equality on an option's `label` or `value`. Exact equality
|
|
133
|
+
would be the obvious default and is the wrong one: the offer is only honest
|
|
134
|
+
if it agrees with the normalization the consumer was just handed, and casing
|
|
135
|
+
is the cheapest way for the two to disagree — with `urgent` in the list,
|
|
136
|
+
typing `Urgent` would offer to create it, the consumer would lowercase it,
|
|
137
|
+
and the user has taken an action that did nothing or made a duplicate. Pass
|
|
138
|
+
your own when your rule goes further than casing.
|
|
139
|
+
|
|
140
|
+
Four more landed after a consumer reviewed the running app, and they are the
|
|
141
|
+
reason to take this release rather than the one before it:
|
|
142
|
+
|
|
143
|
+
- **`ChoiceCard` no longer misreports what was saved.** React resets a
|
|
144
|
+
`<form action={fn}>` once the action resolves, and a reset restores every
|
|
145
|
+
control to its content attribute. A controlled radio only ever had its
|
|
146
|
+
`checked` _property_ written, so after a successful save the group snapped
|
|
147
|
+
back to whatever was selected on first render, while the surrounding state,
|
|
148
|
+
the hidden inputs, and the stored value all held the new selection. The write
|
|
149
|
+
succeeded and only the control disagreed, which reads to a user as "my change
|
|
150
|
+
did not take". `defaultChecked` now tracks the selection.
|
|
151
|
+
|
|
152
|
+
- **`Combobox`'s field is a search box, not editable text.** It used to keep
|
|
153
|
+
the committed label in the input, so a caret landed inside it, typing
|
|
154
|
+
appended to it, and the result matched nothing — and it invited the belief
|
|
155
|
+
that the text was the value and could be corrected in place, which it never
|
|
156
|
+
was. Focus now starts an empty query and the committed value moves to the
|
|
157
|
+
placeholder, so it stays visible while searching and cannot be edited into
|
|
158
|
+
something that was never a choice.
|
|
159
|
+
|
|
160
|
+
- **`ComboboxOption.inputLabel`** — the text the field shows once an option is
|
|
161
|
+
committed, defaulting to `label`. Where `trailing` carries part of an
|
|
162
|
+
option's identity rather than decoration, the collapsed field used to drop
|
|
163
|
+
it. `TimeZoneField` uses it to keep a chosen zone's GMT offset on screen.
|
|
164
|
+
|
|
165
|
+
- **A consumer's `filter` now runs with an empty query** rather than being
|
|
166
|
+
skipped while nothing is typed. A predicate that only searches is
|
|
167
|
+
unaffected; one that _excludes_ had its rule ignored for exactly as long as
|
|
168
|
+
the field was empty, which is the state the popup opens in.
|
|
169
|
+
|
|
170
|
+
Two more came out of the same consumer looking at the running app a second
|
|
171
|
+
time, and both are visible changes to what this package already rendered:
|
|
172
|
+
|
|
173
|
+
- **`ChoiceCard`'s description is body text.** It was `text-xs` on
|
|
174
|
+
`--ssui-text-subtle`, a pairing that fails the contrast floor on the row
|
|
175
|
+
most likely to be read: measured against a selected row's
|
|
176
|
+
`--ssui-accent-subtle` fill in a consuming app, 2.87:1 light and 3.77:1
|
|
177
|
+
dark, with unselected rows at 3.31:1 light, where 12px body text wants
|
|
178
|
+
4.5:1. It is now `text-sm` on `--ssui-text-muted`, which clears the floor on
|
|
179
|
+
every one of those pairings. The size half matters independently: the local
|
|
180
|
+
treatments this component replaced across one consumer ran 14px, 12px, and
|
|
181
|
+
11px, and normalising on the smallest silently shrank the caller whose
|
|
182
|
+
descriptions are whole sentences. **This changes existing rendering** — a
|
|
183
|
+
caller wanting a terser hint can pass `children` and size it itself.
|
|
184
|
+
|
|
185
|
+
- **`Combobox` gains `keepOpenOnSelect`.** Off by default. When set, a commit
|
|
186
|
+
clears the query and leaves the popup open instead of closing, for the
|
|
187
|
+
picker that adds to a set rather than setting a value. Without it such a
|
|
188
|
+
caller pays a full open-the-control round trip per item, and — if it keeps
|
|
189
|
+
its own panel open around the control, as a tag picker does — is left
|
|
190
|
+
showing an empty field with no list beneath it, which reads as inert rather
|
|
191
|
+
than as ready for the next choice.
|
|
192
|
+
|
|
193
|
+
A review of the change closed four more gaps in it, three of them in the parts
|
|
194
|
+
it had shipped to improve accessibility:
|
|
195
|
+
|
|
196
|
+
- **The list variant no longer clips its own focus ring.** The container
|
|
197
|
+
carried `overflow-hidden` to keep the first and last row's fills inside its
|
|
198
|
+
radius. A Tailwind ring is a box-shadow drawn _outside_ the element, and a
|
|
199
|
+
list row has no border or radius of its own, so it fills the container's
|
|
200
|
+
padding box exactly — the clip amputated both vertical sides of every row's
|
|
201
|
+
focus indicator plus the outer edges of the first and last. A keyboard user
|
|
202
|
+
tabbing in saw one horizontal line, easily read as the divider. The rows now
|
|
203
|
+
round their own outer corners instead, which squares the fills off without
|
|
204
|
+
clipping anything.
|
|
205
|
+
|
|
206
|
+
- **The committed value is no longer styled as a hint.** Moving it into the
|
|
207
|
+
placeholder gave it `--ssui-text-subtle` — the pairing this same release
|
|
208
|
+
retired from `ChoiceCard`'s description for measuring 2.87:1. It now takes
|
|
209
|
+
`--ssui-text-muted` while a true placeholder stays subtle, and it is exposed
|
|
210
|
+
through `aria-describedby` as well, because screen-reader handling of
|
|
211
|
+
placeholder text is inconsistent and the field is empty while you search.
|
|
212
|
+
|
|
213
|
+
- **The default filter searches `inputLabel`.** An option showing
|
|
214
|
+
`Asia/Tokyo (GMT+09:00)` in the collapsed field was not findable by that
|
|
215
|
+
string once focus cleared the box — displayed but unsearchable.
|
|
216
|
+
|
|
217
|
+
- **`AuditLog`'s filters are the shared `Select`.** They were a hand-rolled
|
|
218
|
+
`<select>` on a local class string, which existed only because `Select` had
|
|
219
|
+
no size that fitted an inline filter row; `size="sm"` is that size. The copy
|
|
220
|
+
had already drifted — no focus ring, no chevron. **This changes existing
|
|
221
|
+
rendering**: the filters are 32px rather than 36px and gain the chevron and
|
|
222
|
+
the focus ring.
|
|
223
|
+
|
|
224
|
+
Existing behavior is otherwise unchanged: of the 174 tests predating this
|
|
225
|
+
release, the only two edited are the `TimeZoneField` assertions that pinned the
|
|
226
|
+
bare zone name in the collapsed field — the contract `inputLabel` deliberately
|
|
227
|
+
changes.
|
|
228
|
+
|
|
3
229
|
## 0.10.4
|
|
4
230
|
|
|
5
231
|
### Patch Changes
|
|
6
232
|
|
|
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.
|
|
233
|
+
- **No provenance attestation, and none is coming.** Like `0.10.3`, this release
|
|
234
|
+
existed to carry one and does not; contents remain identical to `0.10.2`.
|
|
19
235
|
|
|
20
|
-
|
|
21
|
-
|
|
236
|
+
npm refuses a provenance attestation whose GitHub source repository is
|
|
237
|
+
private, and this repository is private — the package being public on npm is
|
|
238
|
+
not sufficient, because the source repository is what an attestation has to
|
|
239
|
+
name. So no version of this package will carry one unless that changes. Use
|
|
240
|
+
the version lineage and the changelog to answer "which code is this?", and see
|
|
241
|
+
`future.md` for the packing-provenance work that remains open.
|
|
22
242
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
243
|
+
This was established the expensive way: a third attempt generated and signed a
|
|
244
|
+
sigstore bundle and the registry rejected it with `422 ... Unsupported GitHub
|
|
245
|
+
Actions source repository visibility: "private"`. That attempt published
|
|
246
|
+
nothing, so `0.10.4` is the current release.
|
|
27
247
|
|
|
28
248
|
## 0.10.3
|
|
29
249
|
|
|
@@ -32,22 +252,21 @@
|
|
|
32
252
|
- **No provenance attestation, despite what this release was for.** `0.10.3` was
|
|
33
253
|
published to be the first artifact carrying one, and it is not: the package
|
|
34
254
|
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.
|
|
255
|
+
`dist.attestations` for it. It is real and installable; it is simply the same
|
|
256
|
+
code as `0.10.2` under a number that bought nothing. See `0.10.4`.
|
|
257
|
+
|
|
258
|
+
Two mechanisms were tried before the real blocker surfaced, and both failed
|
|
259
|
+
silently, which is worth recording. `changeset publish` detects a pnpm
|
|
260
|
+
workspace and shells out to `pnpm publish`; pnpm does not read arbitrary
|
|
261
|
+
`NPM_CONFIG_*` environment variables into its configuration, and
|
|
262
|
+
libnpmpublish gates provenance on `provenance === true`, a strict boolean
|
|
263
|
+
identity check. So both `NPM_CONFIG_PROVENANCE=true` (this release) and
|
|
264
|
+
`provenance=true` in an rc file (`0.10.4`) were accepted by the shell and
|
|
265
|
+
ignored by the publisher, with nothing in the publish output to say so.
|
|
266
|
+
`pnpm config get provenance` even reports `true` for the environment variable
|
|
267
|
+
the publisher ignores, so it is not a usable pre-flight check.
|
|
268
|
+
|
|
269
|
+
Neither number is reissued.
|
|
51
270
|
|
|
52
271
|
## 0.10.2
|
|
53
272
|
|
|
@@ -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
|
}
|