@tristan2828/ui-foundation 4.32.0 → 4.34.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/conventions/AGENTS.md +9 -3
- package/conventions/docs/add-an-entity.md +45 -4
- package/conventions/docs/cell-patterns.md +59 -9
- package/conventions/docs/design-language.md +81 -1
- package/conventions/docs/timed-screens.md +13 -0
- package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
- package/dist/components/app/app-shell-bottom-bar.js +49 -0
- package/dist/components/app/app-shell-nav.d.ts +41 -0
- package/dist/components/app/app-shell-nav.js +40 -8
- package/dist/components/app/app-shell.d.ts +15 -4
- package/dist/components/app/app-shell.js +21 -7
- package/dist/components/app/data-table.d.ts +22 -2
- package/dist/components/app/data-table.js +29 -7
- package/dist/components/app/line-chart.d.ts +26 -0
- package/dist/components/app/line-chart.js +59 -0
- package/dist/components/app/list-editor-rows.d.ts +18 -0
- package/dist/components/app/list-editor-rows.js +23 -0
- package/dist/components/app/list-editor.d.ts +25 -3
- package/dist/components/app/list-editor.js +61 -5
- package/dist/components/app/markdown.d.ts +17 -2
- package/dist/components/app/markdown.js +84 -11
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +22 -10
- package/dist/components/app/prose-look.d.ts +35 -6
- package/dist/components/app/prose-look.js +27 -6
- package/dist/components/app/prose-table-scroll.d.ts +33 -0
- package/dist/components/app/prose-table-scroll.js +87 -0
- package/dist/components/app/rich-text-editor.d.ts +17 -0
- package/dist/components/app/rich-text-editor.js +5 -0
- package/dist/components/app/rich-text-node-views.d.ts +6 -3
- package/dist/components/app/rich-text-node-views.js +108 -5
- package/dist/components/app/widget-card.d.ts +11 -1
- package/dist/components/app/widget-card.js +19 -3
- package/dist/components/ui/collapsible.d.ts +5 -0
- package/dist/components/ui/collapsible.js +12 -0
- package/dist/hooks/use-muted.d.ts +4 -10
- package/dist/hooks/use-muted.js +4 -67
- package/dist/hooks/use-remembered-flag.d.ts +18 -0
- package/dist/hooks/use-remembered-flag.js +78 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/lib/line-chart-layout.d.ts +23 -0
- package/dist/lib/line-chart-layout.js +44 -0
- package/dist/lib/markdown-top-heading.d.ts +2 -0
- package/dist/lib/markdown-top-heading.js +21 -0
- package/dist/testing/index.d.ts +16 -4
- package/dist/testing/index.js +5 -3
- package/package.json +3 -2
package/conventions/AGENTS.md
CHANGED
|
@@ -35,9 +35,12 @@ is right. Do not disable, skip, or work around it.
|
|
|
35
35
|
that also writes a primitive the package ships (a dependency of the one
|
|
36
36
|
you asked for), delete the copy and import the package's. (Enforced: lint
|
|
37
37
|
rejects `@/components/ui/<name>` for any primitive the package ships.)
|
|
38
|
-
- NEVER add a dependency that is not in `deps-allowlist.json
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
- NEVER add a dependency that is not in `deps-allowlist.json`, npm or
|
|
39
|
+
Python (its `python` list, for `backend/pyproject.toml`, each pinned
|
|
40
|
+
with `==`). If you believe one is needed, write the case in
|
|
41
|
+
`docs/BLOCKERS.md` and stop. (Enforced: `ui-foundation check-deps` in
|
|
42
|
+
verify; `python -m foundation_backend.scripts.check_deps` in
|
|
43
|
+
verify:backend.)
|
|
41
44
|
- NEVER hand-write an API type. All types come from `src/api/schema.d.ts`,
|
|
42
45
|
generated from `openapi.yaml`. If a type is missing, run `npm run gen:api`.
|
|
43
46
|
(Enforced: codegen diff in verify.)
|
|
@@ -92,6 +95,9 @@ Every screen has one Playwright test per state, forced via MSW overrides
|
|
|
92
95
|
## Design Language
|
|
93
96
|
Colour, tone, badges, cell patterns and density follow
|
|
94
97
|
`docs/foundation/design-language.md` and `docs/foundation/cell-patterns.md`.
|
|
98
|
+
The long docs in `docs/foundation/` open with an index of their sections
|
|
99
|
+
and steps, each with the lines it spans: read the index, then only the
|
|
100
|
+
lines you need.
|
|
95
101
|
A pattern the foundation lacks is worth raising there: the design language
|
|
96
102
|
grows in the package so every app gets it, with axe contrast coverage in
|
|
97
103
|
both themes.
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Add an Entity
|
|
2
2
|
|
|
3
|
+
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
|
+
## Index
|
|
5
|
+
|
|
6
|
+
- [The reference files](#the-reference-files) · lines 37–59
|
|
7
|
+
- [Before anything: the entity plan](#before-anything-the-entity-plan) · lines 61–702
|
|
8
|
+
- Step 1: Add `<Entity>` to `openapi.yaml` · lines 99–106
|
|
9
|
+
- Step 2: `npm run gen:api` · lines 107–108
|
|
10
|
+
- Step 3: Add gateway tests in `tests/gateway/<entity>.test.ts`, derived from the spec · lines 109–120
|
|
11
|
+
- Step 4: Add MSW handlers in `src/mocks/<entity>.ts` · lines 121–130
|
|
12
|
+
- Step 5: Add a gateway module in `src/api/gateway/<entity>.ts` · lines 131–135
|
|
13
|
+
- Step 6: Copy the reference files, one for one, not just the two screens · lines 136–598
|
|
14
|
+
- Step 7: Register routes · lines 599–609
|
|
15
|
+
- Step 8: Add Playwright specs for all three screens · lines 610–684
|
|
16
|
+
- Step 9: Register the nav entry and route names · lines 685–699
|
|
17
|
+
- Step 10: `npm run verify`. · lines 700–702
|
|
18
|
+
- [What not to copy](#what-not-to-copy) · lines 704–730
|
|
19
|
+
- [After a commit, in the backend](#after-a-commit-in-the-backend) · lines 732–749
|
|
20
|
+
- [Deleting](#deleting) · lines 751–780
|
|
21
|
+
- [Fields only a script writes](#fields-only-a-script-writes) · lines 782–803
|
|
22
|
+
|
|
23
|
+
<!-- /index -->
|
|
24
|
+
|
|
3
25
|
The most-repeated task in this system: add a full CRUD entity — spec, mocks,
|
|
4
26
|
gateway, tests, table, view, form, routes — following the patterns in
|
|
5
27
|
`src/routes/widgets/`. This file is the only copy of the steps, written for
|
|
@@ -152,7 +174,15 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
152
174
|
`<ListEditor>` inside a `FieldSet`/`FieldLegend` on the form, each row's
|
|
153
175
|
controls named by position ("Item 2 text"), a per-item `FieldError`
|
|
154
176
|
(a server 422 at `["body", "checklist", 2, "text"]` arrives as
|
|
155
|
-
`fieldErrors["checklist.2.text"]` and binds to that row)
|
|
177
|
+
`fieldErrors["checklist.2.text"]` and binds to that row). A list whose
|
|
178
|
+
items are rich (five or six fields each) and run to ten or more also
|
|
179
|
+
passes `summary={(i) => …}`, one line per item that opens to its
|
|
180
|
+
fields, one at a time, and `invalid={(i) => !!errors.<list>?.[i]}` so
|
|
181
|
+
an item the save refuses opens to show why. A list whose items repeat
|
|
182
|
+
with small changes passes
|
|
183
|
+
``onDuplicate={(i) => insert(i + 1, { ...getValues(`<list>.${i}`) })}``,
|
|
184
|
+
leaving out any saved id; the copy lands after its original and opens.
|
|
185
|
+
Then a summary
|
|
156
186
|
in the table, never the items (an item with a `reference` field
|
|
157
187
|
copies Pairings, below) — and a `computed` field copies
|
|
158
188
|
Progress (`checklistState`): `readOnly` in `openapi.yaml` and absent
|
|
@@ -438,7 +468,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
438
468
|
(`> [!NOTE]`) and links, links opening in a new tab; raw HTML shows
|
|
439
469
|
as the characters typed, never as markup. The app needs no Markdown dependency of its own. Plain
|
|
440
470
|
long text renders as text (`whitespace-pre-line` keeps its line
|
|
441
|
-
breaks).
|
|
471
|
+
breaks). Give `<Markdown>` and `RichTextEditor` a `headingLevel` for
|
|
472
|
+
where they sit: the default (3) in an `EntityView` section,
|
|
473
|
+
`headingLevel={2}` for a `RichTextEditor` on a form straight under
|
|
474
|
+
the page's `<h1>`. Text that's imported or pasted from another
|
|
475
|
+
tool (often starting at `##`) also gets `headingFrom="text"`.
|
|
442
476
|
- **Quick actions: only the fields the plan's `Quick actions` line
|
|
443
477
|
names**, each a control that saves on its own (cell pattern 17,
|
|
444
478
|
`widget-quick-actions.tsx`). A `yes/no` is a
|
|
@@ -569,7 +603,10 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
569
603
|
app's name alone). For a list that should open on one of its saved
|
|
570
604
|
views, `to` carries that view's query string
|
|
571
605
|
(`/widgets?status=active`): the entry is still matched on its path, so
|
|
572
|
-
it stays current on the list and the records under it.
|
|
606
|
+
it stays current on the list and the records under it. A page that
|
|
607
|
+
belongs to a module already in the nav (its categories, its channels)
|
|
608
|
+
goes in that module's entry's `children`, not a new top-level entry:
|
|
609
|
+
it shows under the module while you're in it.
|
|
573
610
|
8. **Add Playwright specs for all three screens**, one test per state
|
|
574
611
|
(`loading`, `empty`, `error`, `validation`, `success`), forced through
|
|
575
612
|
MSW overrides — `forceMswOverride`, `waitForMswReady` and the
|
|
@@ -651,7 +688,11 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
651
688
|
of a full record and of the sparse one to its `viewRoutes` (the table
|
|
652
689
|
page comes from `src/nav.ts`, which the suite is given as `nav`: each
|
|
653
690
|
nav page is a test of its own, so the suite's time per test stays flat
|
|
654
|
-
as the app grows; the form and the view aren't in the nav).
|
|
691
|
+
as the app grows; the form and the view aren't in the nav). An app
|
|
692
|
+
with one a11y spec per module (`e2e/a11y-<module>.spec.ts`) adds them
|
|
693
|
+
to its module's file instead, which passes only that module's nav
|
|
694
|
+
entries with `checkSidebar: false`; one file keeps the whole nav and
|
|
695
|
+
the sidebar check. With fields edited in place, copy `e2e/a11y.spec.ts`'s
|
|
655
696
|
"editing in place" block for them: a field open, saving and refused,
|
|
656
697
|
the rich-text editor, an open list, a list edited in place (an item
|
|
657
698
|
open, one being added, a refused change), and the leave prompt, in
|
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Cell Patterns
|
|
2
2
|
|
|
3
|
+
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
|
+
## Index
|
|
5
|
+
|
|
6
|
+
- [The rules that apply to every cell](#the-rules-that-apply-to-every-cell) · lines 65–84
|
|
7
|
+
- [1. Plain text](#1-plain-text) · lines 86–93
|
|
8
|
+
- [2. Icon + label](#2-icon--label) · lines 95–118
|
|
9
|
+
- [3. Icon in a badge](#3-icon-in-a-badge) · lines 120–133
|
|
10
|
+
- [4. Icon only, label in a tooltip](#4-icon-only-label-in-a-tooltip) · lines 135–212
|
|
11
|
+
- [Several values: the icons side by side, every name in one tooltip](#several-values-the-icons-side-by-side-every-name-in-one-tooltip) · lines 160–212
|
|
12
|
+
- [5. Enum → tone-mapped badge](#5-enum--tone-mapped-badge) · lines 214–227
|
|
13
|
+
- [6. Multi-value enum, clustered](#6-multi-value-enum-clustered) · lines 229–241
|
|
14
|
+
- [7. Link as an outline button](#7-link-as-an-outline-button) · lines 243–264
|
|
15
|
+
- [8. Date, relative](#8-date-relative) · lines 266–303
|
|
16
|
+
- [9. Ordinal scale (bounded integer)](#9-ordinal-scale-bounded-integer) · lines 305–353
|
|
17
|
+
- [Two variants the package draws: a step bar and a star](#two-variants-the-package-draws-a-step-bar-and-a-star) · lines 323–353
|
|
18
|
+
- [10. Summary in the cell, detail in a popover](#10-summary-in-the-cell-detail-in-a-popover) · lines 355–405
|
|
19
|
+
- [A second example: what a row depends on](#a-second-example-what-a-row-depends-on) · lines 373–405
|
|
20
|
+
- [11. Number or currency — Unproven](#11-number-or-currency--unproven) · lines 407–447
|
|
21
|
+
- [12. Boolean](#12-boolean) · lines 449–479
|
|
22
|
+
- [13. Progress bar — Unproven](#13-progress-bar--unproven) · lines 481–517
|
|
23
|
+
- [The compact form: `ProgressRing`](#the-compact-form-progressring) · lines 501–517
|
|
24
|
+
- [14. Avatar and name — Unproven](#14-avatar-and-name--unproven) · lines 519–536
|
|
25
|
+
- [15. Yes/no you flip in the row](#15-yesno-you-flip-in-the-row) · lines 538–647
|
|
26
|
+
- [A variant: a pressed icon inside another value's cell](#a-variant-a-pressed-icon-inside-another-values-cell) · lines 564–647
|
|
27
|
+
- [16. Title linking to the record's view](#16-title-linking-to-the-records-view) · lines 649–684
|
|
28
|
+
- [17. Quick action on the view](#17-quick-action-on-the-view) · lines 686–753
|
|
29
|
+
- [18. Editable value](#18-editable-value) · lines 755–1170
|
|
30
|
+
- [A list of sub-items](#a-list-of-sub-items) · lines 1014–1071
|
|
31
|
+
- [In a table's rows](#in-a-tables-rows) · lines 1073–1158
|
|
32
|
+
- [A table of a few short columns: `width="content"`](#a-table-of-a-few-short-columns-widthcontent) · lines 1160–1170
|
|
33
|
+
- [19. Stage circle for an ordered status](#19-stage-circle-for-an-ordered-status) · lines 1172–1238
|
|
34
|
+
- [20. Count linking to the related records](#20-count-linking-to-the-related-records) · lines 1240–1304
|
|
35
|
+
- [21. Rows grouped under headings, and a pill filter](#21-rows-grouped-under-headings-and-a-pill-filter) · lines 1306–1371
|
|
36
|
+
- [22. A highlighted row, and rows that are done](#22-a-highlighted-row-and-rows-that-are-done) · lines 1373–1401
|
|
37
|
+
|
|
38
|
+
<!-- /index -->
|
|
39
|
+
|
|
3
40
|
How a column's value should *look*. Each entry is markup plus the bits a
|
|
4
41
|
first attempt gets wrong — **not components**: the lookup maps are always
|
|
5
42
|
per-entity, and only the shape is reusable. Copy the markup into your
|
|
@@ -375,23 +412,36 @@ A count, a quantity, a price.
|
|
|
375
412
|
// Module scope: one formatter per column, not one per cell render.
|
|
376
413
|
const priceFormatter = new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' })
|
|
377
414
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
415
|
+
{
|
|
416
|
+
accessorKey: 'price',
|
|
417
|
+
header: 'Price',
|
|
418
|
+
meta: { align: 'right' },
|
|
419
|
+
cell: ({ getValue }) => {
|
|
420
|
+
const value = getValue<string | null>()
|
|
421
|
+
return value == null ? emptyCell : <span className="tabular-nums">{priceFormatter.format(Number(value))}</span>
|
|
422
|
+
},
|
|
381
423
|
}
|
|
382
424
|
```
|
|
383
425
|
|
|
384
|
-
-
|
|
385
|
-
the column
|
|
426
|
+
- **Money and counts are right-aligned** (`meta: { align: 'right' }`), with
|
|
427
|
+
`tabular-nums`, so the units line up down the column and with the
|
|
428
|
+
`totals` under it. The header's label is right-aligned too, a sort
|
|
429
|
+
arrow on its left, so its right edge sits on the values'. A number
|
|
430
|
+
that's an identifier or a year (an order number, 2024) isn't a
|
|
431
|
+
quantity: leave it left-aligned like text.
|
|
386
432
|
- **Zero is a value, not an empty cell.** Test `value == null`, never
|
|
387
433
|
falsiness, or `0` renders as an em dash.
|
|
388
434
|
- An API `format: decimal` arrives as a **string**. `Number()` is exact
|
|
389
435
|
enough to display (about 15 significant digits). Never add up money in
|
|
390
436
|
floats on the client: totals come from the backend.
|
|
391
|
-
- **
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
`
|
|
437
|
+
- **A column's total goes in `DataTable`'s `totals`**:
|
|
438
|
+
``totals={data && { label: `Total · ${data.total} items`, cells: { price: usd(data.totals.price) } }}``.
|
|
439
|
+
One formatted value per column id, from the list endpoint beside
|
|
440
|
+
`total`, so it covers every matching row, not only the page. It renders
|
|
441
|
+
as a footer row: the label in a row header in the first visible column,
|
|
442
|
+
each value under its column in that column's alignment and tabular
|
|
443
|
+
figures, above a 2px border. A hidden column's value isn't shown, and
|
|
444
|
+
the row is hidden while loading, on error and when empty.
|
|
395
445
|
- The currency code comes from the data or the plan, never a guess. The
|
|
396
446
|
template's `USD` is the demo's.
|
|
397
447
|
- **Cost:** none to speak of. It's the plain-text pattern with formatting.
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Design Language
|
|
2
2
|
|
|
3
|
+
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
|
+
## Index
|
|
5
|
+
|
|
6
|
+
- [Semantic tones](#semantic-tones) · lines 28–56
|
|
7
|
+
- [The three badge styles](#the-three-badge-styles) · lines 58–75
|
|
8
|
+
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 77–106
|
|
9
|
+
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 108–121
|
|
10
|
+
- [Logos and brand marks](#logos-and-brand-marks) · lines 123–167
|
|
11
|
+
- [Categorical colour](#categorical-colour) · lines 169–239
|
|
12
|
+
- [Typography](#typography) · lines 241–323
|
|
13
|
+
- [Written text (notes)](#written-text-notes) · lines 272–323
|
|
14
|
+
- [Density](#density) · lines 325–382
|
|
15
|
+
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 384–459
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 461–519
|
|
17
|
+
- [Notices](#notices) · lines 521–549
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 551–584
|
|
19
|
+
|
|
20
|
+
<!-- /index -->
|
|
21
|
+
|
|
3
22
|
The part of this foundation a consuming app cannot cheaply get right on
|
|
4
23
|
its own: color that carries meaning, checked for contrast in both themes.
|
|
5
24
|
Tokens and styles here **grow freely**, in the foundation package, so every
|
|
@@ -269,6 +288,24 @@ In text with a band, the rest (paragraphs, lists, tables, callouts,
|
|
|
269
288
|
words in the band rather than its edge; text with no band stays flush. The level reads at a glance: two bands of
|
|
270
289
|
different strength, then a label.
|
|
271
290
|
|
|
291
|
+
Headings in the text sit under where the text is in the page's outline.
|
|
292
|
+
`headingLevel` on `<Markdown>` and `RichTextEditor` is the HTML level `#`
|
|
293
|
+
renders as: 2 straight under the page's `<h1>` (a form, or a page that is
|
|
294
|
+
the note), 3 under a section's `<h2>` (the default). `##` is one below it
|
|
295
|
+
and `###` two below, never past `<h6>`, so the outline never skips a
|
|
296
|
+
level (axe's heading-order). Only the element moves: each level keeps its
|
|
297
|
+
look, and the editor saves `#` as `#`. Text pasted or imported from
|
|
298
|
+
another tool often has no `#` and starts at `##`; `headingFrom="text"`
|
|
299
|
+
puts the text's own highest heading at `headingLevel` instead, the rest
|
|
300
|
+
keeping their distance from it (the editor reads it from the text it
|
|
301
|
+
opens with). The text isn't rewritten.
|
|
302
|
+
|
|
303
|
+
A table wider than the text scrolls sideways. While it does, its box is a
|
|
304
|
+
tab stop and a region named "Table, scrolls sideways: " and its first
|
|
305
|
+
columns ("Item, Size, Colour, …"), so two such tables keep distinct
|
|
306
|
+
names; the arrow keys scroll it (in the editor Home and End too), with
|
|
307
|
+
the `ring` focus ring. A table that fits gets no tab stop and no region.
|
|
308
|
+
|
|
272
309
|
The same text has four colour tokens of its own: `--link` (`text-link`), a
|
|
273
310
|
link's colour, blue as well as underlined (5.59:1 on white, 8.90:1 on the
|
|
274
311
|
dark page); `--heading-band` (`bg-heading-band`), the `#` band, one step
|
|
@@ -330,7 +367,9 @@ set, not a `DataTable` prop:
|
|
|
330
367
|
- Under the hood, the table primitive's cells read `--table-cell-px`,
|
|
331
368
|
`--table-cell-py` and `--table-head-height`. `large` also redefines
|
|
332
369
|
Tailwind's own scale (`--spacing`, `--text-*`, `--radius`), the `--type-*`
|
|
333
|
-
roles and `--control-text-sm` (the small Button and Toggle).
|
|
370
|
+
roles and `--control-text-sm` (the small Button and Toggle).
|
|
371
|
+
`DataTable`'s totals row reads the body cells' tokens, so it is a row's
|
|
372
|
+
height at every density. Each
|
|
334
373
|
default is the value it always had, so a screen without `data-density`
|
|
335
374
|
doesn't change. The selectors are in `styles/theme.css`. A composite
|
|
336
375
|
reaches `compact` with the `density-compact:` variant (`styles/index.css`:
|
|
@@ -395,6 +434,12 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
|
|
|
395
434
|
default) and names the card's region, so the page's outline and its
|
|
396
435
|
landmarks list every widget. Give each widget on a page a different
|
|
397
436
|
title.
|
|
437
|
+
- **More widgets than a phone screen holds:** make the ones read less
|
|
438
|
+
often `collapsible`, with a `storageKey` per widget
|
|
439
|
+
(`"<app>:hub.<widget>"`) so a card folded stays folded on that device,
|
|
440
|
+
and `defaultCollapsed` for one that should start folded. The chevron
|
|
441
|
+
at the header's end is named by the title; the header's link stays
|
|
442
|
+
while folded.
|
|
398
443
|
- **A row is one line:** its text is a link (`to` for a route in the
|
|
399
444
|
app, `href` for an external address, which opens in a new tab with the
|
|
400
445
|
external-link mark) or plain text, cut with an ellipsis, the whole of
|
|
@@ -502,3 +547,38 @@ under its own header.
|
|
|
502
547
|
region in its place, so one appearing or clearing later is announced.
|
|
503
548
|
Elsewhere, put it in a status region that is rendered before it
|
|
504
549
|
changes.
|
|
550
|
+
|
|
551
|
+
## A value over time
|
|
552
|
+
|
|
553
|
+
`LineChart` (4.34) is the one chart: a single series on a record's page
|
|
554
|
+
(a price checked daily, a weight, a balance), oldest first, with an
|
|
555
|
+
optional dashed `target` and one point marked (`mark`: `'min'`, `'max'`
|
|
556
|
+
or `'last'`) with its value.
|
|
557
|
+
|
|
558
|
+
```tsx
|
|
559
|
+
<LineChart
|
|
560
|
+
label="Price over time"
|
|
561
|
+
points={data.history.map(({ checkedOn, price }) => ({ x: checkedOn, y: Number(price) }))}
|
|
562
|
+
target={{ y: Number(data.targetPrice), label: 'Buy at' }}
|
|
563
|
+
mark="min"
|
|
564
|
+
formatY={usd}
|
|
565
|
+
formatX={(x) => formatPlainDate(String(x), { month: 'short', day: 'numeric' })}
|
|
566
|
+
isLoading={isLoading}
|
|
567
|
+
/>
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
- **Points sit apart by their x** when every x is a day (`'YYYY-MM-DD'`)
|
|
571
|
+
or a number, so a gap in the checks shows as a gap; otherwise they're
|
|
572
|
+
evenly spaced.
|
|
573
|
+
- **The colours are fixed:** the line in `info-text`, the target in
|
|
574
|
+
`success-text`, each clearing 3:1 on a card in both themes. Don't
|
|
575
|
+
restyle them; a chart that needs another meaning (a limit not to
|
|
576
|
+
cross) is a request for the foundation.
|
|
577
|
+
- **It owns its states:** a skeleton at `height` while loading, one
|
|
578
|
+
quiet line with no points (`emptyMessage`), a dot for one point.
|
|
579
|
+
- **A screen reader gets the values, not the picture:** the figure is
|
|
580
|
+
named by `label`, with a summary (first, last, lowest, highest, the
|
|
581
|
+
target) and a table of every point. Give it the same `formatY` and
|
|
582
|
+
`formatX` the page uses for those values elsewhere.
|
|
583
|
+
- One series and a few dozen points. Several series, zoom or tooltips
|
|
584
|
+
are a request for the foundation, not a hand-drawn chart in the app.
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Timed Screens
|
|
2
2
|
|
|
3
|
+
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
|
+
## Index
|
|
5
|
+
|
|
6
|
+
- [The view](#the-view) · lines 34–69
|
|
7
|
+
- [The clock](#the-clock) · lines 71–101
|
|
8
|
+
- [Sound](#sound) · lines 103–150
|
|
9
|
+
- [Background tabs](#background-tabs) · lines 152–206
|
|
10
|
+
- [Interruptions](#interruptions) · lines 186–206
|
|
11
|
+
- [Motion](#motion) · lines 208–212
|
|
12
|
+
- [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 214–368
|
|
13
|
+
|
|
14
|
+
<!-- /index -->
|
|
15
|
+
|
|
3
16
|
A screen that runs on its own for minutes: a paced timer, a step-by-step
|
|
4
17
|
player, a recipe step, anything followed from across the room. The
|
|
5
18
|
package has the parts, so a screen like this is assembled, not rebuilt:
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type BottomBarItem, type NavEntry } from './app-shell-nav.js';
|
|
2
|
+
/**
|
|
3
|
+
* What the shell pads its content's bottom by on a phone: its own p-6,
|
|
4
|
+
* plus the bar (4rem and its 1px border) and the phone's safe area.
|
|
5
|
+
*/
|
|
6
|
+
export declare const BOTTOM_BAR_PADDING = "max-md:pb-[calc(--spacing(22)+1px+env(safe-area-inset-bottom))]";
|
|
7
|
+
export declare function BottomBar({ items, nav, activeTo, }: {
|
|
8
|
+
items: readonly BottomBarItem[];
|
|
9
|
+
nav: readonly NavEntry[];
|
|
10
|
+
activeTo: string | undefined;
|
|
11
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
// AppShell's bottom bar on a phone (#240): Home and a few of the nav's
|
|
3
|
+
// sections in thumb reach, where the sidebar is a sheet behind the
|
|
4
|
+
// header's menu button. An item is a link, or a nav section by its label,
|
|
5
|
+
// which opens a sheet from the bottom listing that section's entries (and
|
|
6
|
+
// their children, under them). The sheet closes on navigation, as the
|
|
7
|
+
// sidebar's does. The item holding the current page is marked
|
|
8
|
+
// (`aria-current`). Shown below the md breakpoint only (the sidebar's own
|
|
9
|
+
// mobile breakpoint); the shell pads its content by the bar's height and
|
|
10
|
+
// the phone's safe area, so a page's last row clears it.
|
|
11
|
+
import { useState } from 'react';
|
|
12
|
+
import { Link, useLocation } from 'react-router';
|
|
13
|
+
import { bottomBarCurrent, bottomBarSection } from './app-shell-nav.js';
|
|
14
|
+
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../ui/sheet.js';
|
|
15
|
+
const ITEM_CLASS = 'flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-md px-1 type-caption text-muted-foreground ' +
|
|
16
|
+
'outline-none focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current]:text-foreground aria-[current]:font-medium ' +
|
|
17
|
+
'[&>svg]:size-5 [&>svg]:shrink-0';
|
|
18
|
+
const SHEET_LINK_CLASS = 'flex min-h-11 items-center gap-3 rounded-md px-3 type-body text-foreground outline-none ' +
|
|
19
|
+
'hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50 aria-[current=page]:bg-accent aria-[current=page]:font-medium ' +
|
|
20
|
+
'[&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-muted-foreground';
|
|
21
|
+
/**
|
|
22
|
+
* What the shell pads its content's bottom by on a phone: its own p-6,
|
|
23
|
+
* plus the bar (4rem and its 1px border) and the phone's safe area.
|
|
24
|
+
*/
|
|
25
|
+
export const BOTTOM_BAR_PADDING = 'max-md:pb-[calc(--spacing(22)+1px+env(safe-area-inset-bottom))]';
|
|
26
|
+
function SheetLinks({ items, activeTo }) {
|
|
27
|
+
return (_jsx("ul", { className: "flex flex-col gap-0.5", children: items.map(({ to, label, icon: Icon, children }) => (_jsxs("li", { children: [_jsxs(Link, { to: to, "aria-current": to === activeTo ? 'page' : undefined, className: SHEET_LINK_CLASS, children: [_jsx(Icon, {}), _jsx("span", { className: "truncate", children: label })] }), children && children.length > 0 && (_jsx("div", { className: "ml-5 border-l border-border pl-2", children: _jsx(SheetLinks, { items: children, activeTo: activeTo }) }))] }, to))) }));
|
|
28
|
+
}
|
|
29
|
+
export function BottomBar({ items, nav, activeTo, }) {
|
|
30
|
+
// The section open, and the location it was opened at: following a link
|
|
31
|
+
// in the sheet (or anywhere) moves to a new location, which closes it.
|
|
32
|
+
const [opened, setOpened] = useState(null);
|
|
33
|
+
const { key } = useLocation();
|
|
34
|
+
const openGroup = opened?.at === key ? opened.group : null;
|
|
35
|
+
const setOpenGroup = (group) => setOpened(group === null ? null : { group, at: key });
|
|
36
|
+
// At most five: a sixth wouldn't fit a phone's width with its label.
|
|
37
|
+
const shown = items.slice(0, 5);
|
|
38
|
+
const current = bottomBarCurrent(shown, nav, activeTo);
|
|
39
|
+
const section = openGroup === null ? undefined : bottomBarSection(nav, openGroup);
|
|
40
|
+
return (_jsxs(_Fragment, { children: [_jsx("nav", { "aria-label": "Bottom bar", "data-slot": "app-shell-bottom-bar", className: "fixed inset-x-0 bottom-0 z-20 border-t border-border bg-background pb-[env(safe-area-inset-bottom)] md:hidden", children: _jsx("ul", { className: "flex h-16 items-stretch gap-1 px-2 py-1.5", children: shown.map((item, index) => {
|
|
41
|
+
const Icon = item.icon;
|
|
42
|
+
const isCurrent = index === current;
|
|
43
|
+
if ('to' in item) {
|
|
44
|
+
return (_jsx("li", { className: "flex min-w-0 flex-1", children: _jsxs(Link, { to: item.to, "aria-current": isCurrent ? 'page' : undefined, className: ITEM_CLASS, children: [_jsx(Icon, { "aria-hidden": "true" }), _jsx("span", { className: "max-w-full truncate", children: item.label })] }) }, `to:${item.to}`));
|
|
45
|
+
}
|
|
46
|
+
const label = item.label ?? item.group;
|
|
47
|
+
return (_jsx("li", { className: "flex min-w-0 flex-1", children: _jsxs("button", { type: "button", "aria-haspopup": "dialog", "aria-expanded": openGroup === item.group, "aria-current": isCurrent ? 'true' : undefined, onClick: () => setOpenGroup(item.group), className: ITEM_CLASS, children: [_jsx(Icon, { "aria-hidden": "true" }), _jsx("span", { className: "max-w-full truncate", children: label })] }) }, `group:${item.group}`));
|
|
48
|
+
}) }) }), _jsx(Sheet, { open: section !== undefined, onOpenChange: (open) => !open && setOpenGroup(null), children: _jsx(SheetContent, { side: "bottom", className: "max-h-[80dvh] gap-2 pb-[calc(--spacing(4)+env(safe-area-inset-bottom))] md:hidden", children: section && (_jsxs(_Fragment, { children: [_jsx(SheetHeader, { className: "pb-0", children: _jsx(SheetTitle, { children: section.label }) }), _jsx("nav", { "aria-label": section.label, className: "overflow-y-auto px-2", children: _jsx(SheetLinks, { items: section.items, activeTo: activeTo }) })] })) }) })] }));
|
|
49
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
|
+
import type { IconComponent } from './icon-component.js';
|
|
2
3
|
export type NavItem = {
|
|
3
4
|
/**
|
|
4
5
|
* The route this entry links to. It may carry a query string, to open a
|
|
@@ -10,6 +11,15 @@ export type NavItem = {
|
|
|
10
11
|
icon: ComponentType;
|
|
11
12
|
/** Active only on an exact match (use for '/'); otherwise on any sub-path too. */
|
|
12
13
|
end?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A module's own pages, listed under this entry (one level deep): its
|
|
16
|
+
* channels under YouTube, its series under Comics. They show while the
|
|
17
|
+
* page is this entry or one of them (`open: 'current'`), or always
|
|
18
|
+
* (`'always'`). On the icon rail they're named in this entry's tooltip.
|
|
19
|
+
*/
|
|
20
|
+
children?: readonly NavItem[];
|
|
21
|
+
/** When `children` show: while you're in them (`'current'`, the default) or `'always'`. */
|
|
22
|
+
open?: 'current' | 'always';
|
|
13
23
|
};
|
|
14
24
|
/** A labelled group of sidebar entries: one module's pages, say. */
|
|
15
25
|
export type NavSection = {
|
|
@@ -42,6 +52,15 @@ export type NavGroup = {
|
|
|
42
52
|
* labelled group, and each run of plain items between them an unlabelled one.
|
|
43
53
|
*/
|
|
44
54
|
export declare function navGroups(nav: readonly NavEntry[]): NavGroup[];
|
|
55
|
+
/** Each item and, after it, its children: every page a list of items links. */
|
|
56
|
+
export declare function withChildren(items: readonly NavItem[]): NavItem[];
|
|
57
|
+
/** Every page in a nav, sections' items and children included, in order. */
|
|
58
|
+
export declare function navItems(nav: readonly NavEntry[]): NavItem[];
|
|
59
|
+
/**
|
|
60
|
+
* Whether an item's children show: always, or while the current page
|
|
61
|
+
* (`activeTo`) is the item or one of its children.
|
|
62
|
+
*/
|
|
63
|
+
export declare function childrenOpen(item: NavItem, activeTo: string | undefined): boolean;
|
|
45
64
|
/**
|
|
46
65
|
* The path a `to` names, without its query string or hash: what an entry is
|
|
47
66
|
* matched on, and what the a11y suite compares with the rendered sidebar.
|
|
@@ -65,3 +84,25 @@ export declare function activeNavTo(nav: readonly NavEntry[], pathname: string):
|
|
|
65
84
|
* itself, or on a page no entry covers.
|
|
66
85
|
*/
|
|
67
86
|
export declare function navPageLabel(nav: readonly NavEntry[], pathname: string): string | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* One item of `AppShell`'s `bottomBar`, the phone's bar of shortcuts: a
|
|
89
|
+
* page, or one of the nav's sections by its label, which opens a sheet of
|
|
90
|
+
* that section's entries. `label` is a shorter name for the bar
|
|
91
|
+
* ("Household" for "Home and money").
|
|
92
|
+
*/
|
|
93
|
+
export type BottomBarItem = {
|
|
94
|
+
to: string;
|
|
95
|
+
label: string;
|
|
96
|
+
icon: IconComponent;
|
|
97
|
+
} | {
|
|
98
|
+
group: string;
|
|
99
|
+
label?: string;
|
|
100
|
+
icon: IconComponent;
|
|
101
|
+
};
|
|
102
|
+
/** The nav's section with this label, if there is one. */
|
|
103
|
+
export declare function bottomBarSection(nav: readonly NavEntry[], label: string): NavSection | undefined;
|
|
104
|
+
/**
|
|
105
|
+
* Which bar item holds the current page (`activeTo`, from activeNavTo):
|
|
106
|
+
* the page's own link, or else the section it's in, children included.
|
|
107
|
+
*/
|
|
108
|
+
export declare function bottomBarCurrent(items: readonly BottomBarItem[], nav: readonly NavEntry[], activeTo: string | undefined): number | undefined;
|
|
@@ -30,6 +30,23 @@ export function navGroups(nav) {
|
|
|
30
30
|
groups.push({ items: run });
|
|
31
31
|
return groups;
|
|
32
32
|
}
|
|
33
|
+
/** Each item and, after it, its children: every page a list of items links. */
|
|
34
|
+
export function withChildren(items) {
|
|
35
|
+
return items.flatMap((item) => [item, ...(item.children ?? [])]);
|
|
36
|
+
}
|
|
37
|
+
/** Every page in a nav, sections' items and children included, in order. */
|
|
38
|
+
export function navItems(nav) {
|
|
39
|
+
return nav.flatMap((entry) => withChildren(isNavSection(entry) ? entry.items : [entry]));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether an item's children show: always, or while the current page
|
|
43
|
+
* (`activeTo`) is the item or one of its children.
|
|
44
|
+
*/
|
|
45
|
+
export function childrenOpen(item, activeTo) {
|
|
46
|
+
if (!item.children?.length)
|
|
47
|
+
return false;
|
|
48
|
+
return item.open === 'always' || item.to === activeTo || item.children.some((child) => child.to === activeTo);
|
|
49
|
+
}
|
|
33
50
|
/**
|
|
34
51
|
* The path a `to` names, without its query string or hash: what an entry is
|
|
35
52
|
* matched on, and what the a11y suite compares with the rendered sidebar.
|
|
@@ -40,7 +57,7 @@ export function navPathname(to) {
|
|
|
40
57
|
}
|
|
41
58
|
/** Every `to` in a nav, each once, in order: the pages its sidebar links. */
|
|
42
59
|
export function navTargets(nav) {
|
|
43
|
-
return [...new Set(nav
|
|
60
|
+
return [...new Set(navItems(nav).map((item) => item.to))];
|
|
44
61
|
}
|
|
45
62
|
function matches(path, end, pathname) {
|
|
46
63
|
if (end)
|
|
@@ -61,13 +78,11 @@ function matches(path, end, pathname) {
|
|
|
61
78
|
export function activeNavItem(nav, pathname) {
|
|
62
79
|
let best;
|
|
63
80
|
let bestLength = -1;
|
|
64
|
-
for (const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
bestLength = path.length;
|
|
70
|
-
}
|
|
81
|
+
for (const item of navItems(nav)) {
|
|
82
|
+
const path = navPathname(item.to);
|
|
83
|
+
if (matches(path, item.end, pathname) && path.length > bestLength) {
|
|
84
|
+
best = item;
|
|
85
|
+
bestLength = path.length;
|
|
71
86
|
}
|
|
72
87
|
}
|
|
73
88
|
return best;
|
|
@@ -85,3 +100,20 @@ export function navPageLabel(nav, pathname) {
|
|
|
85
100
|
const item = activeNavItem(nav, pathname);
|
|
86
101
|
return item && navPathname(item.to) !== '/' ? item.label : undefined;
|
|
87
102
|
}
|
|
103
|
+
/** The nav's section with this label, if there is one. */
|
|
104
|
+
export function bottomBarSection(nav, label) {
|
|
105
|
+
return nav.find((entry) => isNavSection(entry) && entry.label === label);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Which bar item holds the current page (`activeTo`, from activeNavTo):
|
|
109
|
+
* the page's own link, or else the section it's in, children included.
|
|
110
|
+
*/
|
|
111
|
+
export function bottomBarCurrent(items, nav, activeTo) {
|
|
112
|
+
if (activeTo === undefined)
|
|
113
|
+
return undefined;
|
|
114
|
+
const link = items.findIndex((item) => 'to' in item && item.to === activeTo);
|
|
115
|
+
if (link !== -1)
|
|
116
|
+
return link;
|
|
117
|
+
const group = items.findIndex((item) => 'group' in item && withChildren(bottomBarSection(nav, item.group)?.items ?? []).some((entry) => entry.to === activeTo));
|
|
118
|
+
return group === -1 ? undefined : group;
|
|
119
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
-
import { type NavEntry } from './app-shell-nav.js';
|
|
2
|
+
import { type BottomBarItem, type NavEntry } from './app-shell-nav.js';
|
|
3
3
|
import { type AppShellVersion } from './app-shell-version.js';
|
|
4
4
|
import type { IconComponent } from './icon-component.js';
|
|
5
|
-
export type { NavEntry, NavItem, NavSection } from './app-shell-nav.js';
|
|
5
|
+
export type { BottomBarItem, NavEntry, NavItem, NavSection } from './app-shell-nav.js';
|
|
6
6
|
export type { AppShellVersion } from './app-shell-version.js';
|
|
7
7
|
export type AppShellProps = {
|
|
8
8
|
/**
|
|
@@ -30,7 +30,8 @@ export type AppShellProps = {
|
|
|
30
30
|
* open a list on a saved view ('/tasks?status=open'). A section marked `collapsible` folds under its label,
|
|
31
31
|
* remembered per browser (`defaultOpen` for the first visit); the one
|
|
32
32
|
* holding the current page stays open, and the icon rail shows every
|
|
33
|
-
* section's items.
|
|
33
|
+
* section's items. An entry's `children` (a module's own pages) show
|
|
34
|
+
* under it while you're on one of them.
|
|
34
35
|
*/
|
|
35
36
|
nav: readonly NavEntry[];
|
|
36
37
|
/**
|
|
@@ -52,5 +53,15 @@ export type AppShellProps = {
|
|
|
52
53
|
* `@tristan2828/ui-foundation/vite` (`import.meta.env.VITE_BUILD_*`).
|
|
53
54
|
*/
|
|
54
55
|
version?: AppShellVersion;
|
|
56
|
+
/**
|
|
57
|
+
* A bar of shortcuts at the bottom of a phone's screen (below the md
|
|
58
|
+
* breakpoint), in thumb reach: at most five, each a page
|
|
59
|
+
* (`{ to, label, icon }`) or a nav section by its label
|
|
60
|
+
* (`{ group: 'Every day', icon }`, with an optional shorter `label`),
|
|
61
|
+
* which opens a sheet of that section's entries. The item holding the
|
|
62
|
+
* current page is marked. The header's menu button stays, for
|
|
63
|
+
* everything. The shell pads its content so nothing sits under the bar.
|
|
64
|
+
*/
|
|
65
|
+
bottomBar?: readonly BottomBarItem[];
|
|
55
66
|
};
|
|
56
|
-
export declare function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen, version }: AppShellProps): import("react").JSX.Element;
|
|
67
|
+
export declare function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen, version, bottomBar, }: AppShellProps): import("react").JSX.Element;
|