@tristan2828/ui-foundation 3.12.0 → 3.14.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/README.md +1 -1
- package/conventions/docs/add-an-entity.md +57 -12
- package/conventions/docs/cell-patterns.md +369 -17
- package/conventions/docs/design-language.md +23 -4
- package/conventions/docs/entity-plan-template.md +21 -9
- package/dist/components/app/edit-in-place-store.d.ts +76 -0
- package/dist/components/app/edit-in-place-store.js +159 -0
- package/dist/components/app/edit-in-place.d.ts +88 -0
- package/dist/components/app/edit-in-place.js +10 -0
- package/dist/components/app/editable-value.d.ts +23 -0
- package/dist/components/app/editable-value.js +176 -0
- package/dist/components/app/entity-view.d.ts +10 -1
- package/dist/components/app/entity-view.js +20 -7
- package/dist/components/app/milkdown-editor.d.ts +2 -0
- package/dist/components/app/milkdown-editor.js +221 -0
- package/dist/components/app/multi-reference.d.ts +5 -1
- package/dist/components/app/multi-reference.js +2 -2
- package/dist/components/app/rich-text-editor.d.ts +20 -0
- package/dist/components/app/rich-text-editor.js +25 -0
- package/dist/components/app/stage-circle.d.ts +20 -0
- package/dist/components/app/stage-circle.js +30 -0
- package/dist/components/ui/badge.d.ts +1 -1
- package/dist/components/ui/field.js +2 -2
- package/dist/components/ui/toggle.d.ts +8 -0
- package/dist/components/ui/toggle.js +33 -0
- package/dist/hooks/use-record-update.d.ts +14 -1
- package/dist/hooks/use-record-update.js +10 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lib/markdown-merge.d.ts +19 -0
- package/dist/lib/markdown-merge.js +196 -0
- package/dist/testing/index.d.ts +18 -1
- package/dist/testing/index.js +21 -2
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ than installing this by hand:
|
|
|
18
18
|
|
|
19
19
|
| Import | What |
|
|
20
20
|
|---|---|
|
|
21
|
-
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView`, `EntityForm`, `Markdown`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
21
|
+
| `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
|
|
22
22
|
| `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
|
|
23
23
|
| `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
|
|
24
24
|
| `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
|
|
@@ -168,7 +168,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
168
168
|
`src/routes/<entity>/<entity>-schema.ts` (zod schema mirroring
|
|
169
169
|
`<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
|
|
170
170
|
functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
|
|
171
|
-
the shape
|
|
171
|
+
the shape. A Markdown long text is checked with `refine`, never
|
|
172
|
+
trimmed, like Widget's `description`: a trim rewrites text nobody
|
|
173
|
+
touched, a leading indent that makes a code block or the text's
|
|
174
|
+
own ending)
|
|
172
175
|
- `src/routes/widgets/widget-format.ts` →
|
|
173
176
|
`src/routes/<entity>/<entity>-format.ts` (the lookups and formatters
|
|
174
177
|
both the columns and the view render values with: badge variant maps,
|
|
@@ -189,6 +192,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
189
192
|
`src/routes/<entity>/<entity>-quick-actions.tsx`, **only when the
|
|
190
193
|
plan's "View screen" lists `Quick actions`**: one component per
|
|
191
194
|
control the plan names (below).
|
|
195
|
+
- `src/routes/widgets/widget-fields.tsx` →
|
|
196
|
+
`src/routes/<entity>/<entity>-fields.tsx`: **one control per field
|
|
197
|
+
the plan edits in place** (`Edit in place`), shared by the form
|
|
198
|
+
(through a `Controller`) and the view (through `editInPlace`), and
|
|
199
|
+
the Markdown long text's `RichTextEditor` (on the form whether or
|
|
200
|
+
not it edits in place). Every other field's control stays inline in
|
|
201
|
+
the form, as `widget-form.tsx`'s Category, Tags and Available From
|
|
202
|
+
do. No `Edit in place` line and no Markdown long text: no fields
|
|
203
|
+
file.
|
|
192
204
|
- `src/routes/widgets/delete-widget-action.tsx` →
|
|
193
205
|
`src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
|
|
194
206
|
view's header: the confirm dialog, then `onDeleted` leaves for the
|
|
@@ -200,7 +212,8 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
200
212
|
view (below).
|
|
201
213
|
|
|
202
214
|
**The view** is the read-only page for one record, at `/<entity>/:id`
|
|
203
|
-
(apart from the plan's quick actions, if it
|
|
215
|
+
(apart from the plan's quick actions and fields edited in place, if it
|
|
216
|
+
lists any).
|
|
204
217
|
`<EntityView>` owns its loading skeleton, its not-found state (another
|
|
205
218
|
user's record is the same plain 404, with a way back to the list and
|
|
206
219
|
no retry) and its error state with retry. The screen supplies what the
|
|
@@ -261,8 +274,7 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
261
274
|
breaks).
|
|
262
275
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
263
276
|
names**, each a control that saves on its own (cell pattern 17,
|
|
264
|
-
`widget-quick-actions.tsx`). A `
|
|
265
|
-
`Select` showing its badge (`WidgetStatusSelect`); a `yes/no` is a
|
|
277
|
+
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
266
278
|
`Switch` with its words (`WidgetInStockSwitch`); a sub-record list's
|
|
267
279
|
yes/no item field is a checkbox per item in a named group
|
|
268
280
|
(`ChecklistItems`), whose change is a function of the record
|
|
@@ -273,9 +285,29 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
273
285
|
`content`): `EntityView` needs nothing new. **With no `Quick actions`
|
|
274
286
|
line, every value is read-only** and there is no quick-actions file:
|
|
275
287
|
the view is exactly the read-only one above.
|
|
276
|
-
- **
|
|
277
|
-
|
|
278
|
-
|
|
288
|
+
- **Edit in place: only the fields the plan's `Edit in place` line
|
|
289
|
+
names** (cell pattern 18). Each becomes `editInPlace({ kind, value,
|
|
290
|
+
control, schema, save })` (`widgetEdits` in `widget-view.tsx`):
|
|
291
|
+
`value` is what the form's control starts from, `control` is the
|
|
292
|
+
shared control from `<entity>-fields.tsx` given the props it's
|
|
293
|
+
handed (`readOnly={props.disabled}`, `aria-label={props.label}`,
|
|
294
|
+
`aria-describedby={props.describedBy}`), `schema` is the form's own
|
|
295
|
+
rule (`<entity>FormSchema.shape.<field>`), and `save` is
|
|
296
|
+
`useEdit<Entity>Field(id).mutateAsync({ <field>: value })` (copy
|
|
297
|
+
`useEditWidgetField`: the single-field save with `optimistic: false,
|
|
298
|
+
toastOnError: false`). `kind` follows the control: `text` (one line;
|
|
299
|
+
a number or integer too), `long-text` (Markdown, `RichTextEditor`
|
|
300
|
+
with `autoFocus` and `onProblem={props.onProblem}`), `choice` (a
|
|
301
|
+
select or a single reference: its list opens with the field,
|
|
302
|
+
`defaultOpen`, non-modal, `onChange={(v) => props.commit(v)}`,
|
|
303
|
+
`onOpenChange={(open) => !open && props.cancel()}`; a yes/no switch
|
|
304
|
+
commits on change), `multi` (a multi reference; `isEqual` comparing
|
|
305
|
+
ids as a set). A field gets `edit`, a `content` section gets `edit`,
|
|
306
|
+
the title gets `titleEdit={{ label, ...edit }}`, and a header badge
|
|
307
|
+
wraps itself in `<EditableValue label edit layout="inline">`. The
|
|
308
|
+
shown value stays exactly as before. **With no `Edit in place` line,
|
|
309
|
+
nothing is editable in place** and none of this is copied. Never a
|
|
310
|
+
computed field or a sub-record list.
|
|
279
311
|
|
|
280
312
|
**The table opens the view, and the form returns to it.** The title
|
|
281
313
|
column's link is the way in (a `yes/no` marked `toggle` stays in its
|
|
@@ -373,7 +405,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
373
405
|
save answers if the plan has one, a refused save (an MSW override
|
|
374
406
|
answering 422 with the reason on the field) goes back with the toast,
|
|
375
407
|
the "Saving" spinner shows while nothing is disabled, and, for a
|
|
376
|
-
list, two quick changes both land.
|
|
408
|
+
list, two quick changes both land. Fields edited in place get their
|
|
409
|
+
own spec (copy `e2e/widget-edit-in-place.spec.ts`): only the plan's
|
|
410
|
+
fields offer it; each kind saves (Enter, a pick, Ctrl/Cmd+Enter,
|
|
411
|
+
leaving) with the PATCH it should send and "Saving…" while it waits;
|
|
412
|
+
unchanged sends nothing; Esc restores; the schema's refusal is never
|
|
413
|
+
sent; a 422 (an MSW override, the reason on the field) and a 500 stay
|
|
414
|
+
open with the draft and the reason; opening a second field saves the
|
|
415
|
+
first, or doesn't open if that fails; and leaving the page with
|
|
416
|
+
unsaved text asks first. Add it to `mobile-chrome`'s `testMatch`. If
|
|
377
417
|
the plan has Markdown long text, a test forces a record whose text has
|
|
378
418
|
a heading, a list, a table, a link and raw HTML, and asserts the HTML
|
|
379
419
|
shows as text. Add the view's spec to `playwright.config.ts`'s
|
|
@@ -394,7 +434,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
394
434
|
`/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
|
|
395
435
|
of a full record and of the sparse one to its `viewRoutes` (the table
|
|
396
436
|
page is picked up from the sidebar automatically; the form and the view
|
|
397
|
-
aren't in it).
|
|
437
|
+
aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
|
|
438
|
+
"editing in place" block for them: a field open, saving and refused,
|
|
439
|
+
the rich-text editor, an open list, and the leave prompt, in both
|
|
440
|
+
themes.
|
|
398
441
|
10. **`npm run verify`.** Fix until it passes. Then stop — do not add
|
|
399
442
|
anything beyond what this list covers; note ideas in the app's
|
|
400
443
|
backlog instead.
|
|
@@ -404,9 +447,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
404
447
|
- `widget-quick-actions.tsx`, its `quick action: …` specs and
|
|
405
448
|
`useSaveWidgetField` unless the plan has a `Quick actions` line (or,
|
|
406
449
|
for the hook, a `yes/no` marked `toggle`). Copy only the controls for
|
|
407
|
-
the fields the line names: a plan naming only its
|
|
408
|
-
|
|
409
|
-
|
|
450
|
+
the fields the line names: a plan naming only its yes/no gets the
|
|
451
|
+
switch, and the checklist's checkboxes are left out.
|
|
452
|
+
- `widgetEdits`, `useEditWidgetField`, `widget-edit-in-place.spec.ts` and
|
|
453
|
+
the in-place a11y block unless the plan has an `Edit in place` line,
|
|
454
|
+
and then only for the fields it names.
|
|
410
455
|
|
|
411
456
|
- The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
|
|
412
457
|
button group and `visibleColumns`) unless the plan's List screen lists
|
|
@@ -8,9 +8,14 @@ per-entity, and only the shape is reusable. Copy the markup into your
|
|
|
8
8
|
Patterns 1–10 were each chosen for a real column, from a page of
|
|
9
9
|
alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
|
|
10
10
|
Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
|
|
11
|
-
to its view)
|
|
12
|
-
references: Widget's In Stock and Name columns and its
|
|
13
|
-
and their specs pin them.
|
|
11
|
+
to its view), 17 (quick action on the view) and 18 (editable value) are
|
|
12
|
+
the template's own references: Widget's In Stock and Name columns and its
|
|
13
|
+
view render them, and their specs pin them. Pattern 19 (stage circle)
|
|
14
|
+
and the multi-value variant of 4, the dependency list in 10 and the
|
|
15
|
+
pressed icon in 15 were each built for a real app's screen first; the
|
|
16
|
+
package's Storybook renders them (`app/StageCircle`,
|
|
17
|
+
`patterns/CellPatterns`), and its checks pin their accessibility and
|
|
18
|
+
contrast in both themes. The other patterns from 11 on are
|
|
14
19
|
marked **Unproven**: written ahead of a real
|
|
15
20
|
column, so no app has tested them on real data yet. Prefer a proven
|
|
16
21
|
pattern when one fits. When you use an unproven one, say so in the
|
|
@@ -109,6 +114,63 @@ narrow.
|
|
|
109
114
|
cell, absent in an isolated Storybook story.
|
|
110
115
|
- Encode the value in the glyph's **shape** (solid / half / slashed /
|
|
111
116
|
dashed), not its colour.
|
|
117
|
+
- **Give the trigger a focus ring**
|
|
118
|
+
(`rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50`):
|
|
119
|
+
it's focusable, and a span has none of its own.
|
|
120
|
+
|
|
121
|
+
### Several values: the icons side by side, every name in one tooltip
|
|
122
|
+
|
|
123
|
+
A multi value (several categories on one record) where each value has
|
|
124
|
+
its own icon and colour slot, and the column is narrow. The package's
|
|
125
|
+
Storybook renders it (`patterns/CellPatterns`, "Icons with one tooltip").
|
|
126
|
+
|
|
127
|
+
```tsx
|
|
128
|
+
// values: the row's categories, `undefined` for one still loading.
|
|
129
|
+
const names = values.map((value) => value?.name ?? '…').join(', ')
|
|
130
|
+
if (values.length === 0) return emptyCell
|
|
131
|
+
|
|
132
|
+
<Tooltip>
|
|
133
|
+
<TooltipTrigger
|
|
134
|
+
render={
|
|
135
|
+
<span role="img" aria-label={names} tabIndex={0}
|
|
136
|
+
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
|
|
137
|
+
}
|
|
138
|
+
>
|
|
139
|
+
{values.map((value, index) => {
|
|
140
|
+
if (!value) return <span key={index} className="text-muted-foreground">…</span>
|
|
141
|
+
const Icon = CATEGORY_ICON[value.icon]
|
|
142
|
+
return <Icon key={value.id} className={`size-4 shrink-0 ${CATEGORY_COLOR[value.color]}`} />
|
|
143
|
+
})}
|
|
144
|
+
</TooltipTrigger>
|
|
145
|
+
<TooltipContent>{names}</TooltipContent>
|
|
146
|
+
</Tooltip>
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
- **One tooltip on the whole group**, listing every name in the record's
|
|
150
|
+
order ("Home, Finance"), not one per icon: one tab stop per cell, and
|
|
151
|
+
the names read as one value.
|
|
152
|
+
- **The trigger is the `role="img"` span**, named by that same list. The
|
|
153
|
+
icons inside are presentational (children of an `img`), so they need no
|
|
154
|
+
`aria-hidden` of their own. Read-only: never a button.
|
|
155
|
+
- **Each icon tinted with its value's colour slot**
|
|
156
|
+
([`design-language.md`](design-language.md) "Categorical colour"), the
|
|
157
|
+
icon's shape telling values apart in greyscale.
|
|
158
|
+
- **A value still loading shows "…"**, in the cell and in the names,
|
|
159
|
+
never a bare id.
|
|
160
|
+
- **Where there's room** (the record's view), each icon sits beside its
|
|
161
|
+
name instead (pattern 2 per value, in a `flex flex-wrap gap-x-3 gap-y-1`
|
|
162
|
+
list), with no tooltip.
|
|
163
|
+
|
|
164
|
+
**Testing note.** With several tooltips on a page, one that's closing
|
|
165
|
+
stays in the DOM for a moment beside the one opening. A spec that reads
|
|
166
|
+
the open tooltip must select `[data-slot=tooltip-content][data-open]`;
|
|
167
|
+
the bare `[data-slot=tooltip-content]` can match the closing one, and
|
|
168
|
+
reads the wrong text:
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
await page.getByRole('img', { name: 'Home, Finance', exact: true }).focus()
|
|
172
|
+
await expect(page.locator('[data-slot=tooltip-content][data-open]')).toHaveText('Home, Finance')
|
|
173
|
+
```
|
|
112
174
|
|
|
113
175
|
## 5. Enum → tone-mapped badge
|
|
114
176
|
|
|
@@ -116,6 +178,11 @@ A many-valued enum that collapses to good / neutral / bad. **The grouping
|
|
|
116
178
|
is the decision, not the style** — see
|
|
117
179
|
[`design-language.md`](design-language.md#mapping-an-enum-to-tones).
|
|
118
180
|
|
|
181
|
+
An **ordered** status (a lifecycle every record moves through toward
|
|
182
|
+
*done*) reads better as a stage circle beside the word (pattern 19);
|
|
183
|
+
the tone badge stays right for a status with no order, or one whose end
|
|
184
|
+
isn't *done*.
|
|
185
|
+
|
|
119
186
|
Keep a separate short-label map when the API strings are full sentences
|
|
120
187
|
(`"Yes – Full Crossplay"` → `Full`): read once in a dropdown they're right,
|
|
121
188
|
read down 25 rows under a column header they're noise.
|
|
@@ -232,6 +299,40 @@ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
|
|
|
232
299
|
can't be sorted server-side without a backend change
|
|
233
300
|
(`enableSorting: false`).
|
|
234
301
|
|
|
302
|
+
### A second example: what a row depends on
|
|
303
|
+
|
|
304
|
+
The records a row waits on (parts it needs, a date that has to pass,
|
|
305
|
+
other records that have to finish), summarised in the cell, listed in
|
|
306
|
+
the popover. The package's Storybook renders it
|
|
307
|
+
(`patterns/CellPatterns`, "Dependency list").
|
|
308
|
+
|
|
309
|
+
- **The cell: a computed summary.** "Ready" when nothing holds the row,
|
|
310
|
+
else the count of what does ("1 hold", "2 holds"). Compute it where the
|
|
311
|
+
data is: the backend, when the column sorts or filters on it.
|
|
312
|
+
- **The popover: every related record, grouped by kind**, each kind
|
|
313
|
+
under a small heading (`type-caption text-muted-foreground`) in its own
|
|
314
|
+
labelled group, in a fixed order. Each record keeps its kind's glyph
|
|
315
|
+
(`aria-hidden`, beside the name), so the kinds stay apart in
|
|
316
|
+
greyscale.
|
|
317
|
+
- **Held reads dark, with its reason; clear reads muted.** A held record
|
|
318
|
+
is `text-foreground` with why after its name ("Bulb: unavailable",
|
|
319
|
+
"Delivery day: hasn't happened", or the blocking record's status);
|
|
320
|
+
a clear one is `text-muted-foreground` with a visually hidden ": clear",
|
|
321
|
+
so a screen reader hears the state the colour shows. No tone: a hold
|
|
322
|
+
is the normal state of waiting, not an error, and the count already
|
|
323
|
+
says how many.
|
|
324
|
+
- **Name the popup**: `<PopoverContent aria-label={`What ${name} depends on`}>`.
|
|
325
|
+
It's a `dialog`, and axe fails one with no name.
|
|
326
|
+
- **A row that links nothing has nothing to open**: render the summary as
|
|
327
|
+
plain text (`<span>Ready</span>`), not a button. A button that opens an
|
|
328
|
+
empty popover is a dead end.
|
|
329
|
+
- **The trigger reads as the value**, with a dotted underline
|
|
330
|
+
(`underline decoration-dotted underline-offset-4`) and a focus ring,
|
|
331
|
+
so it looks openable without becoming a column of buttons.
|
|
332
|
+
- The trigger's name starts with the summary it shows
|
|
333
|
+
(`aria-label={`${summary}: what ${name} depends on`}`), so it's still
|
|
334
|
+
found by its visible text, and says which row.
|
|
335
|
+
|
|
235
336
|
## 11. Number or currency — *Unproven*
|
|
236
337
|
|
|
237
338
|
A count, a quantity, a price.
|
|
@@ -358,6 +459,91 @@ now", "happened"). Widget's In Stock column is the reference
|
|
|
358
459
|
- **Cost:** one click changes data, with no confirmation. Only for values
|
|
359
460
|
that are cheap to flip back; never for anything destructive.
|
|
360
461
|
|
|
462
|
+
### A variant: a pressed icon inside another value's cell
|
|
463
|
+
|
|
464
|
+
A yes/no that belongs to another value rather than having a column of
|
|
465
|
+
its own: a "focus" flag that only exists while the status is "doing", a
|
|
466
|
+
"pinned" mark on an open item. It's a small icon button inside the owning
|
|
467
|
+
value's cell, beside its word. The package's Storybook renders it
|
|
468
|
+
(`patterns/CellPatterns`, "Pressed icon in a cell").
|
|
469
|
+
|
|
470
|
+
**Pick it over the Switch** when the yes/no only means something while
|
|
471
|
+
another value allows it, or when a Switch and its words would crowd that
|
|
472
|
+
value's cell. A yes/no that stands on its own keeps its own column and
|
|
473
|
+
the Switch.
|
|
474
|
+
|
|
475
|
+
```tsx
|
|
476
|
+
// Off: faint. On: the destructive text tone, filled. The primitive's
|
|
477
|
+
// pressed background is dropped: the icon carries the value.
|
|
478
|
+
const FLAG_TOGGLE_CLASS =
|
|
479
|
+
'text-muted-foreground/80 hover:text-muted-foreground aria-pressed:bg-transparent ' +
|
|
480
|
+
'aria-pressed:text-destructive-text'
|
|
481
|
+
|
|
482
|
+
// <entity>-focus-toggle.tsx, rendered inside the status cell
|
|
483
|
+
export function FocusToggle({ item }: { item: Item }) {
|
|
484
|
+
const save = useSaveItemField(item.id) // useRecordUpdate, like Widget's useSaveWidgetField
|
|
485
|
+
return (
|
|
486
|
+
<>
|
|
487
|
+
<Toggle size="icon-xs" pressed={item.focus} aria-label={`Focus: ${item.name}`}
|
|
488
|
+
className={FLAG_TOGGLE_CLASS} onPressedChange={(focus) => save.mutate({ focus })}>
|
|
489
|
+
<FlagIcon className="group-aria-pressed/toggle:fill-current" />
|
|
490
|
+
</Toggle>
|
|
491
|
+
<span className="inline-flex size-3.5 shrink-0">
|
|
492
|
+
{save.isPending && <Spinner aria-label="Saving" className="size-3.5 text-muted-foreground" />}
|
|
493
|
+
</span>
|
|
494
|
+
</>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// The status cell: the word stays, the toggle only while the status allows it.
|
|
499
|
+
<span className="inline-flex items-center gap-1.5">
|
|
500
|
+
<StageCircle {...STATUS_STAGE[item.status]} />
|
|
501
|
+
{STATUS_LABEL[item.status]}
|
|
502
|
+
{item.status === 'doing' && <FocusToggle item={item} />}
|
|
503
|
+
</span>
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
- **`Toggle` from the package** (`/ui/toggle`, shadcn's, on Base UI), at
|
|
507
|
+
`size="icon-xs"`: a 24px target, the smallest WCAG 2.2 allows, which
|
|
508
|
+
keeps the row's height. It's `aria-pressed` by itself.
|
|
509
|
+
- **Named for the row, not the state**: `Focus: <record name>`. The
|
|
510
|
+
pressed state is announced on its own; a name that changes with it
|
|
511
|
+
("Unfocus") reads twice.
|
|
512
|
+
- **The icon carries the value.** Filled when on
|
|
513
|
+
(`group-aria-pressed/toggle:fill-current`), outline when off, so it
|
|
514
|
+
reads in greyscale. Pick a glyph with an area to fill (`FlagIcon`,
|
|
515
|
+
`StarIcon`, `PinIcon`), not a line.
|
|
516
|
+
- **Faint, but never below 3:1.** Off is `text-muted-foreground/80`, the
|
|
517
|
+
faintest step that still clears WCAG's 3:1 non-text minimum in light
|
|
518
|
+
mode (3.2:1; dark is 5.2:1). `/70` measures 2.7:1, and a control a
|
|
519
|
+
reader can't see fails them. Hover lifts it to full
|
|
520
|
+
`muted-foreground`. axe doesn't measure icons, so the package's
|
|
521
|
+
Storybook glyph check does.
|
|
522
|
+
- **Tone: `destructive-text`, read as "flagged", not "error".** A flag
|
|
523
|
+
claims attention ahead of the rest of the row, and the destructive
|
|
524
|
+
tone is the one with that pull. The other tones are taken where it
|
|
525
|
+
sits: `info` is the in-progress status it belongs to (an info flag
|
|
526
|
+
beside an info stage disappears), `success` reads as done, and
|
|
527
|
+
`warning` says something may be wrong. It stays honest because it's
|
|
528
|
+
rare (a flag nearly every row carries is the "about a third" limit
|
|
529
|
+
broken) and because no word beside it says error. If a flag is common,
|
|
530
|
+
or does mean "something's wrong", it's an enum with a verdict
|
|
531
|
+
(pattern 5) instead.
|
|
532
|
+
- **It saves on its own, through `useRecordUpdate`**, like the Switch: the
|
|
533
|
+
new value shows at once, and a refusal (a cap the server enforces)
|
|
534
|
+
puts it back with the toast giving the server's reason. The hook does
|
|
535
|
+
both; add nothing.
|
|
536
|
+
- **The "Saving" slot is always there**, after the toggle, so nothing
|
|
537
|
+
moves while it saves, and nothing is disabled.
|
|
538
|
+
- **Only rendered while the owning value allows it.** When the status
|
|
539
|
+
moves on, the toggle goes; what the server does with the flag then (it
|
|
540
|
+
clears it, or keeps it for next time) is the plan's rule, and shows
|
|
541
|
+
through the record it returns.
|
|
542
|
+
- **On the record's view** it's the same component beside the owning
|
|
543
|
+
value in the header, as a quick action (pattern 17): the plan names it
|
|
544
|
+
under `Quick actions`. There its name is the field's label alone
|
|
545
|
+
(`Focus`), like the view's other controls.
|
|
546
|
+
|
|
361
547
|
## 16. Title linking to the record's view
|
|
362
548
|
|
|
363
549
|
The column that names a record (its name or title), when the entity has a
|
|
@@ -397,23 +583,25 @@ cell: ({ row }) => (
|
|
|
397
583
|
|
|
398
584
|
## 17. Quick action on the view
|
|
399
585
|
|
|
400
|
-
A value on the record's view that changes right where it's shown,
|
|
401
|
-
its own the moment it changes, without the form: a
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
586
|
+
A value on the record's view that changes right where it's shown, with one
|
|
587
|
+
click, saved on its own the moment it changes, without the form: a yes/no
|
|
588
|
+
flipped, a sub-record item ticked. Widget's view is the reference
|
|
589
|
+
(`src/routes/widgets/widget-quick-actions.tsx`): In Stock in the header,
|
|
590
|
+
Checklist items ticked in place.
|
|
405
591
|
|
|
406
592
|
**Read-only is the default.** A value on the view is a control only when
|
|
407
|
-
the plan's "View screen" names it under `Quick actions
|
|
408
|
-
renders as its table cell does, and
|
|
409
|
-
is for a value people change often
|
|
410
|
-
|
|
411
|
-
|
|
593
|
+
the plan's "View screen" names it under `Quick actions` (or `Edit in
|
|
594
|
+
place`, pattern 18); everything else renders as its table cell does, and
|
|
595
|
+
changes through Edit. A quick action is for a value people change often
|
|
596
|
+
and can change back as easily: never anything destructive. Anything you
|
|
597
|
+
type or pick from a list (text, a status) edits in place instead
|
|
598
|
+
(pattern 18); a status was a quick action in 3.12, and moved there in
|
|
599
|
+
3.13.
|
|
412
600
|
|
|
413
601
|
| The plan's field | The control | Accessible name |
|
|
414
602
|
|---|---|---|
|
|
415
|
-
| `single choice`, in the header | A `Select` (`size="sm"`) whose value and options render as the field's tone-mapped `Badge` (pattern 5), in place of the badge | The field's label: `Status` |
|
|
416
603
|
| `yes/no`, in the header or a field row | A `Switch` (`size="sm"`) with the value in words beside it, `aria-hidden` (pattern 15) | The field's label: `In stock` |
|
|
604
|
+
| A `yes/no` that belongs to another value (pattern 15's variant), beside that value in the header | The same pressed icon `Toggle` as in the table's cell | The field's label: `Focus` |
|
|
417
605
|
| A sub-record list's `yes/no` item field | Each item a `Checkbox` inside a `<label>` with the item's text, so the text ticks it too; the list in one `role="group"` named for the list, described by the done-count | `Done: <item text>` (a visually hidden `Done: ` inside the label) |
|
|
418
606
|
|
|
419
607
|
```tsx
|
|
@@ -452,12 +640,176 @@ const save = useSaveWidgetField(widget.id) // one per control
|
|
|
452
640
|
- **Saving shows, and blocks nothing.** Each control keeps its own
|
|
453
641
|
"Saving" spinner in a slot that's always there (nothing moves), and
|
|
454
642
|
nothing is disabled: not the other controls, not this one, not Edit.
|
|
455
|
-
- **Keep the value's look.** The
|
|
456
|
-
|
|
457
|
-
|
|
643
|
+
- **Keep the value's look.** The yes/no keeps its words; a ticked item
|
|
644
|
+
keeps the muted text a done item had. The control only adds the
|
|
645
|
+
affordance.
|
|
458
646
|
- **One component per control, in its own file** (`<entity>-quick-actions.tsx`),
|
|
459
647
|
so each hook lives in a component and the view's sections stay plain
|
|
460
648
|
data.
|
|
461
649
|
- **Cost:** one click changes data, with no confirmation, and the server
|
|
462
650
|
may still say no. Keep the list short: a view where every value is a
|
|
463
651
|
control reads as a form.
|
|
652
|
+
|
|
653
|
+
## 18. Editable value
|
|
654
|
+
|
|
655
|
+
A value on the record's view that turns into **the form's own control**
|
|
656
|
+
where it's shown, and saves when you leave it: a title, a status, a
|
|
657
|
+
price, notes, a list of linked records. Widget's view is the reference
|
|
658
|
+
(`src/routes/widgets/widget-view.tsx`): Name (the title), Status (its
|
|
659
|
+
header badge), Price, Description (rich text) and Extra Categories, with
|
|
660
|
+
the controls in `widget-fields.tsx`, the same ones the form uses.
|
|
661
|
+
|
|
662
|
+
The rule behind every part of it: **nothing typed is ever lost.**
|
|
663
|
+
|
|
664
|
+
**Which values.** Only those the plan's "View screen" names under `Edit
|
|
665
|
+
in place`; read-only is the default, and a value not named renders
|
|
666
|
+
exactly as it did. Single values only: text, long text, a single choice
|
|
667
|
+
(optional ones with their "not set" option), an integer or a rating, a
|
|
668
|
+
yes/no, a single or multi reference. Never a computed field, and never a
|
|
669
|
+
sub-record list (adding, removing and reordering items stays on the
|
|
670
|
+
form). The view's Edit button and the form stay.
|
|
671
|
+
|
|
672
|
+
**At rest: says it's editable without shouting.** The value reads as it
|
|
673
|
+
always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
|
|
674
|
+
a real button ("Edit <label>"), so Tab reaches it and Enter opens the
|
|
675
|
+
field, and its focus ring tints the value too. On a touch screen the
|
|
676
|
+
pencil always shows. A click anywhere on the value opens it, except on a
|
|
677
|
+
link inside it (a reference to a record with a view, a link in the
|
|
678
|
+
notes), which still navigates.
|
|
679
|
+
|
|
680
|
+
**Open: the form's control, nothing else.** The same labels, choices and
|
|
681
|
+
pickers as `EntityForm`; the field's label is its accessible name. No
|
|
682
|
+
Save button, no Undo:
|
|
683
|
+
|
|
684
|
+
| Kind (`editInPlace({ kind })`) | Saves on | Esc |
|
|
685
|
+
|---|---|---|
|
|
686
|
+
| `text`: one line | Enter, or leaving it | puts the saved value back |
|
|
687
|
+
| `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line) | the same |
|
|
688
|
+
| `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
|
|
689
|
+
| `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
|
|
690
|
+
|
|
691
|
+
Leaving means clicking or tabbing anywhere else. Unchanged closes with no
|
|
692
|
+
request; a value the form's schema refuses never leaves the browser and
|
|
693
|
+
shows the schema's message. Opening another value saves the open one
|
|
694
|
+
first; if that save fails, the open one stays and the other doesn't
|
|
695
|
+
open.
|
|
696
|
+
|
|
697
|
+
**Saving: still open, read-only, and says so.** Under the control,
|
|
698
|
+
"Saving…" with a spinner (`role="status"`) until the server agrees, then
|
|
699
|
+
it closes on the saved value. **Not optimistic**: the value only shows as
|
|
700
|
+
saved once it is, and the view shows the record the server returned (a
|
|
701
|
+
side effect included). The rest of the page stays usable.
|
|
702
|
+
|
|
703
|
+
**Refused: still open, with exactly what was typed and why.** The reason
|
|
704
|
+
under the control in the destructive text tone (`FieldError`), word for
|
|
705
|
+
word: a 422's field error as `EntityForm` shows it, or the error's
|
|
706
|
+
message (a 500, no answer). The control is `aria-invalid` and described
|
|
707
|
+
by the reason. Nothing reverts on its own, and there's no toast. Leaving
|
|
708
|
+
again retries; Esc gives up.
|
|
709
|
+
|
|
710
|
+
**Leaving the page** (a link, Back) while a value holds unsaved text asks
|
|
711
|
+
"Discard your changes?" first (Keep editing / Discard); closing the tab
|
|
712
|
+
asks through the browser. A link press doesn't save the field on its
|
|
713
|
+
way out: the prompt decides.
|
|
714
|
+
|
|
715
|
+
```tsx
|
|
716
|
+
const editField = useEditWidgetField(widget.id) // optimistic: false, toastOnError: false
|
|
717
|
+
const price = editInPlace({
|
|
718
|
+
kind: 'text',
|
|
719
|
+
value: widget.price, // as the form's control holds it
|
|
720
|
+
schema: widgetFormSchema.shape.price, // the form's own rule
|
|
721
|
+
save: (price) => editField.mutateAsync({ price }),
|
|
722
|
+
control: (props) => (
|
|
723
|
+
<WidgetPriceInput id={props.id} value={props.value} onChange={props.onChange}
|
|
724
|
+
invalid={props.invalid} readOnly={props.disabled}
|
|
725
|
+
aria-label={props.label} aria-describedby={props.describedBy} />
|
|
726
|
+
),
|
|
727
|
+
})
|
|
728
|
+
// A field: { label: 'Price', value: <…as the table shows it…>, edit: price }
|
|
729
|
+
// The title: <EntityView titleEdit={{ label: 'Name', ...name }} />
|
|
730
|
+
// A badge: <EditableValue label="Status" edit={status} layout="inline"><Badge>…</Badge></EditableValue>
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
- **One control per field, shared with the form** (`<entity>-fields.tsx`):
|
|
734
|
+
each takes `value`/`onChange` and `readOnly`, and the form wraps it in a
|
|
735
|
+
`Controller`. Never a second, in-place-only control.
|
|
736
|
+
- **Long text written as Markdown edits as rich text** (`RichTextEditor`):
|
|
737
|
+
formatted where it's read, saved as Markdown with every block the person
|
|
738
|
+
didn't touch kept exactly as written. On the form too, so it's one
|
|
739
|
+
control.
|
|
740
|
+
- **The save is the single-field save** (`useRecordUpdate` with
|
|
741
|
+
`optimistic: false, toastOnError: false`, the entity's
|
|
742
|
+
`useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
|
|
743
|
+
- **Keep the value's look at rest**: the badge stays a badge, the price
|
|
744
|
+
stays formatted. Only the open state looks like a form.
|
|
745
|
+
- **Cost:** a value that looks like text but edits on click can surprise.
|
|
746
|
+
Keep it to the fields the plan names, and leave the form for everything
|
|
747
|
+
else.
|
|
748
|
+
|
|
749
|
+
## 19. Stage circle for an ordered status
|
|
750
|
+
|
|
751
|
+
A status that's an ordered lifecycle (each record moves through the same
|
|
752
|
+
stages, in the same order, toward one that means *done*), with one or
|
|
753
|
+
more exit states off the side. A small circle beside the word fills as
|
|
754
|
+
the record moves on. The package ships it: `StageCircle`, rendered with
|
|
755
|
+
every stage and tone in its Storybook (`app/StageCircle`).
|
|
756
|
+
|
|
757
|
+
| Stage (`stage`) | Drawn as | Say it for |
|
|
758
|
+
|---|---|---|
|
|
759
|
+
| `0` | a dashed outline | the first stage: not started |
|
|
760
|
+
| `1`, `2`, `3` | the outline with a quarter, a half, three quarters filled | a stage under way, in order |
|
|
761
|
+
| `'complete'` | a solid circle with a tick | the final stage: done |
|
|
762
|
+
| `'exit'` | the outline, struck through | off the lifecycle: dropped, cancelled |
|
|
763
|
+
|
|
764
|
+
```tsx
|
|
765
|
+
import { StageCircle, type StageCircleStage, type StageCircleTone } from '@tristan2828/ui-foundation'
|
|
766
|
+
|
|
767
|
+
// <entity>-format.ts: typed by the enum, so a new value can't go unmapped.
|
|
768
|
+
const STATUS_STAGE: Record<ItemStatus, { stage: StageCircleStage; tone?: StageCircleTone }> = {
|
|
769
|
+
idea: { stage: 0 },
|
|
770
|
+
considering: { stage: 1 },
|
|
771
|
+
doing: { stage: 2, tone: 'info' },
|
|
772
|
+
done: { stage: 'complete', tone: 'success' },
|
|
773
|
+
dropped: { stage: 'exit' },
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
cell: ({ getValue }) => {
|
|
777
|
+
const status = getValue() as ItemStatus
|
|
778
|
+
return (
|
|
779
|
+
<span className="inline-flex items-center gap-1.5">
|
|
780
|
+
<StageCircle {...STATUS_STAGE[status]} />
|
|
781
|
+
{STATUS_LABEL[status]}
|
|
782
|
+
</span>
|
|
783
|
+
)
|
|
784
|
+
}
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
- **The word stays**, in plain `text-foreground`. The circle is
|
|
788
|
+
`aria-hidden`; the word is what's read, and what a reader who doesn't
|
|
789
|
+
know the shapes yet goes by.
|
|
790
|
+
- **The shape carries the stage, in greyscale.** Dashed, filling, solid,
|
|
791
|
+
struck through: the stage reads from the shape alone, and the tone only
|
|
792
|
+
reinforces it.
|
|
793
|
+
- **Tone it like pattern 5:** the stage in progress is `info` ("notice
|
|
794
|
+
this"), complete is `success`, and the rest stay `muted` (the default).
|
|
795
|
+
The tones are the `*-text` shades, so the circle clears 3:1 on the page
|
|
796
|
+
in both themes, measured by the package's glyph check. An exit is
|
|
797
|
+
muted: dropped is an end, not a failure. Give it `destructive` only if
|
|
798
|
+
the exit really is bad news (failed, rejected).
|
|
799
|
+
- **Map the stages in order**, the first `0`, the final `'complete'`, the
|
|
800
|
+
ones between `1`–`3` in order. Two middle stages fill a quarter and a
|
|
801
|
+
half; three, up to three quarters. More than three stages between
|
|
802
|
+
first and done is more than a fill can tell apart: use the tone badge.
|
|
803
|
+
- **Stage circle or tone badge (pattern 5)?** The circle is for an
|
|
804
|
+
*ordered* status: every record goes the same way, and "how far along
|
|
805
|
+
toward done" is the question. A status with no order (`active` /
|
|
806
|
+
`paused` / `blocked`), or one whose end isn't *done*, keeps pattern 5.
|
|
807
|
+
Widget's Status is the second kind: `draft` → `active` → `archived` is
|
|
808
|
+
an order in time, but `archived` is retirement, not completion, and its
|
|
809
|
+
good state is the middle one. A tick on `archived` would say "done"
|
|
810
|
+
where nothing was finished, so it stays a tone badge.
|
|
811
|
+
- **On the record's view** it's the same markup in the header, beside the
|
|
812
|
+
title; editing the status in place (pattern 18) wraps it in
|
|
813
|
+
`<EditableValue>` like a badge.
|
|
814
|
+
- **Cost:** a reader learns the shapes once. Until then the word does the
|
|
815
|
+
work, which is why it stays.
|
|
@@ -82,6 +82,24 @@ Two rules that keep this honest:
|
|
|
82
82
|
greyscale screenshot and a printed page all lose it. The label text
|
|
83
83
|
usually carries the meaning already; where a cell is a glyph rather than
|
|
84
84
|
a word, vary the *shape* (filled / half / slashed) too, not just the hue.
|
|
85
|
+
The package's `StageCircle` does this for an ordered status: dashed,
|
|
86
|
+
filling, solid with a tick, struck through
|
|
87
|
+
([`cell-patterns.md`](cell-patterns.md) pattern 19).
|
|
88
|
+
|
|
89
|
+
## Glyphs and icons: 3:1
|
|
90
|
+
|
|
91
|
+
An icon that carries meaning on its own (a stage circle, a category's
|
|
92
|
+
icon, a pressed flag) must clear WCAG's **3:1 non-text minimum** against
|
|
93
|
+
the background behind it, in both themes. axe measures text only, so the
|
|
94
|
+
package's Storybook has a glyph check of its own: every `<svg>` in its
|
|
95
|
+
design-language stories, its ink (with any opacity modifier painted over
|
|
96
|
+
its background) against what's behind it. Every `*-text` tone, every
|
|
97
|
+
category slot and `muted-foreground` clear it as they are. An opacity
|
|
98
|
+
modifier is where it goes wrong: `text-muted-foreground/80` is the
|
|
99
|
+
faintest step that still clears it in light mode (3.2:1), and `/70`
|
|
100
|
+
doesn't (2.7:1). A deliberately faint icon that carries nothing on its
|
|
101
|
+
own (an "empty" star beside a rating's filled ones, pattern 9) may go
|
|
102
|
+
lower; say so in a comment.
|
|
85
103
|
|
|
86
104
|
## Categorical colour
|
|
87
105
|
|
|
@@ -119,10 +137,11 @@ Four things about them:
|
|
|
119
137
|
`text-foreground`, so readability never depends on the hue. A slot used
|
|
120
138
|
as a background needs its own contrast check, which nothing here does
|
|
121
139
|
for you.
|
|
122
|
-
- **
|
|
123
|
-
non-text minimum in both themes (
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
- **The glyph check re-measures them.** All eight clear the 3:1
|
|
141
|
+
non-text minimum in both themes (recorded in `theme.css`). axe checks
|
|
142
|
+
text contrast, not icon contrast, so the package's Storybook glyph check
|
|
143
|
+
draws an icon in each slot (`patterns/CellPatterns`, "Icons with one
|
|
144
|
+
tooltip") and fails a change that takes one below 3:1.
|
|
126
145
|
|
|
127
146
|
If you find yourself wanting a ninth, that is usually a sign the column
|
|
128
147
|
should be showing a shape or a label rather than more colours.
|