@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.
Files changed (48) hide show
  1. package/conventions/AGENTS.md +9 -3
  2. package/conventions/docs/add-an-entity.md +45 -4
  3. package/conventions/docs/cell-patterns.md +59 -9
  4. package/conventions/docs/design-language.md +81 -1
  5. package/conventions/docs/timed-screens.md +13 -0
  6. package/dist/components/app/app-shell-bottom-bar.d.ts +11 -0
  7. package/dist/components/app/app-shell-bottom-bar.js +49 -0
  8. package/dist/components/app/app-shell-nav.d.ts +41 -0
  9. package/dist/components/app/app-shell-nav.js +40 -8
  10. package/dist/components/app/app-shell.d.ts +15 -4
  11. package/dist/components/app/app-shell.js +21 -7
  12. package/dist/components/app/data-table.d.ts +22 -2
  13. package/dist/components/app/data-table.js +29 -7
  14. package/dist/components/app/line-chart.d.ts +26 -0
  15. package/dist/components/app/line-chart.js +59 -0
  16. package/dist/components/app/list-editor-rows.d.ts +18 -0
  17. package/dist/components/app/list-editor-rows.js +23 -0
  18. package/dist/components/app/list-editor.d.ts +25 -3
  19. package/dist/components/app/list-editor.js +61 -5
  20. package/dist/components/app/markdown.d.ts +17 -2
  21. package/dist/components/app/markdown.js +84 -11
  22. package/dist/components/app/milkdown-editor.d.ts +1 -1
  23. package/dist/components/app/milkdown-editor.js +22 -10
  24. package/dist/components/app/prose-look.d.ts +35 -6
  25. package/dist/components/app/prose-look.js +27 -6
  26. package/dist/components/app/prose-table-scroll.d.ts +33 -0
  27. package/dist/components/app/prose-table-scroll.js +87 -0
  28. package/dist/components/app/rich-text-editor.d.ts +17 -0
  29. package/dist/components/app/rich-text-editor.js +5 -0
  30. package/dist/components/app/rich-text-node-views.d.ts +6 -3
  31. package/dist/components/app/rich-text-node-views.js +108 -5
  32. package/dist/components/app/widget-card.d.ts +11 -1
  33. package/dist/components/app/widget-card.js +19 -3
  34. package/dist/components/ui/collapsible.d.ts +5 -0
  35. package/dist/components/ui/collapsible.js +12 -0
  36. package/dist/hooks/use-muted.d.ts +4 -10
  37. package/dist/hooks/use-muted.js +4 -67
  38. package/dist/hooks/use-remembered-flag.d.ts +18 -0
  39. package/dist/hooks/use-remembered-flag.js +78 -0
  40. package/dist/index.d.ts +4 -2
  41. package/dist/index.js +3 -2
  42. package/dist/lib/line-chart-layout.d.ts +23 -0
  43. package/dist/lib/line-chart-layout.js +44 -0
  44. package/dist/lib/markdown-top-heading.d.ts +2 -0
  45. package/dist/lib/markdown-top-heading.js +21 -0
  46. package/dist/testing/index.d.ts +16 -4
  47. package/dist/testing/index.js +5 -3
  48. package/package.json +3 -2
@@ -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`. If you
39
- believe one is needed, write the case in `docs/BLOCKERS.md` and stop.
40
- (Enforced: `ui-foundation check-deps` in verify.)
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), and a summary
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). With fields edited in place, copy `e2e/a11y.spec.ts`'s
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
- cell: ({ getValue }) => {
379
- const value = getValue<string | null>()
380
- return value == null ? emptyCell : <span className="tabular-nums">{priceFormatter.format(Number(value))}</span>
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
- - `tabular-nums` gives every digit the same width, so values line up down
385
- the column even when left-aligned.
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
- - **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
392
- supports `'center'`, and a right-aligned cell under a left-aligned header
393
- looks broken. Stay left-aligned until a real column needs
394
- `align: 'right'`, then raise it for the foundation.
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). Each
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.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
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 entry of nav) {
65
- for (const item of isNavSection(entry) ? entry.items : [entry]) {
66
- const path = navPathname(item.to);
67
- if (matches(path, item.end, pathname) && path.length > bestLength) {
68
- best = item;
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;