@tristan2828/ui-foundation 4.33.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 (37) hide show
  1. package/conventions/AGENTS.md +9 -3
  2. package/conventions/docs/add-an-entity.md +33 -3
  3. package/conventions/docs/cell-patterns.md +51 -9
  4. package/conventions/docs/design-language.md +65 -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 +1 -1
  13. package/dist/components/app/data-table.js +10 -4
  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/markdown.d.ts +9 -2
  17. package/dist/components/app/markdown.js +13 -9
  18. package/dist/components/app/milkdown-editor.d.ts +1 -1
  19. package/dist/components/app/milkdown-editor.js +7 -4
  20. package/dist/components/app/prose-look.d.ts +13 -4
  21. package/dist/components/app/prose-look.js +8 -5
  22. package/dist/components/app/rich-text-editor.d.ts +8 -1
  23. package/dist/components/app/widget-card.d.ts +11 -1
  24. package/dist/components/app/widget-card.js +19 -3
  25. package/dist/hooks/use-muted.d.ts +4 -10
  26. package/dist/hooks/use-muted.js +4 -67
  27. package/dist/hooks/use-remembered-flag.d.ts +18 -0
  28. package/dist/hooks/use-remembered-flag.js +78 -0
  29. package/dist/index.d.ts +3 -2
  30. package/dist/index.js +2 -1
  31. package/dist/lib/line-chart-layout.d.ts +23 -0
  32. package/dist/lib/line-chart-layout.js +44 -0
  33. package/dist/lib/markdown-top-heading.d.ts +2 -0
  34. package/dist/lib/markdown-top-heading.js +21 -0
  35. package/dist/testing/index.d.ts +16 -4
  36. package/dist/testing/index.js +5 -3
  37. 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
@@ -449,7 +471,8 @@ is a filled-in example while the Widgets demo is still here).
449
471
  breaks). Give `<Markdown>` and `RichTextEditor` a `headingLevel` for
450
472
  where they sit: the default (3) in an `EntityView` section,
451
473
  `headingLevel={2}` for a `RichTextEditor` on a form straight under
452
- the page's `<h1>`.
474
+ the page's `<h1>`. Text that's imported or pasted from another
475
+ tool (often starting at `##`) also gets `headingFrom="text"`.
453
476
  - **Quick actions: only the fields the plan's `Quick actions` line
454
477
  names**, each a control that saves on its own (cell pattern 17,
455
478
  `widget-quick-actions.tsx`). A `yes/no` is a
@@ -580,7 +603,10 @@ is a filled-in example while the Widgets demo is still here).
580
603
  app's name alone). For a list that should open on one of its saved
581
604
  views, `to` carries that view's query string
582
605
  (`/widgets?status=active`): the entry is still matched on its path, so
583
- 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.
584
610
  8. **Add Playwright specs for all three screens**, one test per state
585
611
  (`loading`, `empty`, `error`, `validation`, `success`), forced through
586
612
  MSW overrides — `forceMswOverride`, `waitForMswReady` and the
@@ -662,7 +688,11 @@ is a filled-in example while the Widgets demo is still here).
662
688
  of a full record and of the sparse one to its `viewRoutes` (the table
663
689
  page comes from `src/nav.ts`, which the suite is given as `nav`: each
664
690
  nav page is a test of its own, so the suite's time per test stays flat
665
- 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
666
696
  "editing in place" block for them: a field open, saving and refused,
667
697
  the rich-text editor, an open list, a list edited in place (an item
668
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,14 +412,23 @@ 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
@@ -396,10 +442,6 @@ cell: ({ getValue }) => {
396
442
  each value under its column in that column's alignment and tabular
397
443
  figures, above a 2px border. A hidden column's value isn't shown, and
398
444
  the row is hidden while loading, on error and when empty.
399
- - **Right-aligning isn't available yet.** `DataTable`'s `meta.align` only
400
- supports `'center'`, and a right-aligned cell under a left-aligned header
401
- looks broken. Stay left-aligned until a real column needs
402
- `align: 'right'`, then raise it for the foundation.
403
445
  - The currency code comes from the data or the plan, never a guess. The
404
446
  template's `USD` is the demo's.
405
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
@@ -275,7 +294,11 @@ renders as: 2 straight under the page's `<h1>` (a form, or a page that is
275
294
  the note), 3 under a section's `<h2>` (the default). `##` is one below it
276
295
  and `###` two below, never past `<h6>`, so the outline never skips a
277
296
  level (axe's heading-order). Only the element moves: each level keeps its
278
- look, and the editor saves `#` as `#`.
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.
279
302
 
280
303
  A table wider than the text scrolls sideways. While it does, its box is a
281
304
  tab stop and a region named "Table, scrolls sideways: " and its first
@@ -411,6 +434,12 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
411
434
  default) and names the card's region, so the page's outline and its
412
435
  landmarks list every widget. Give each widget on a page a different
413
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.
414
443
  - **A row is one line:** its text is a link (`to` for a route in the
415
444
  app, `href` for an external address, which opens in a new tab with the
416
445
  external-link mark) or plain text, cut with an ellipsis, the whole of
@@ -518,3 +547,38 @@ under its own header.
518
547
  region in its place, so one appearing or clearing later is announced.
519
548
  Elsewhere, put it in a status region that is rendered before it
520
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;
@@ -4,7 +4,8 @@ import { useTheme } from 'next-themes';
4
4
  import { useEffect, useId, useState } from 'react';
5
5
  import { Link, Navigate, Outlet, useLocation } from 'react-router';
6
6
  import { useAuth } from '../../auth/use-auth.js';
7
- import { activeNavTo, navGroups, navPageLabel } from './app-shell-nav.js';
7
+ import { BottomBar, BOTTOM_BAR_PADDING } from './app-shell-bottom-bar.js';
8
+ import { activeNavTo, childrenOpen, navGroups, navPageLabel, withChildren, } from './app-shell-nav.js';
8
9
  import { versionHref, versionLabel } from './app-shell-version.js';
9
10
  import { ErrorState } from './error-state.js';
10
11
  import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
@@ -13,7 +14,7 @@ import { useShellDocumentTitle } from '../../hooks/use-document-title.js';
13
14
  import { cn } from 'cn';
14
15
  import { Button } from '../ui/button.js';
15
16
  import { Skeleton } from '../ui/skeleton.js';
16
- import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
17
+ import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
17
18
  import { Toaster } from '../ui/sonner.js';
18
19
  import { TooltipProvider } from '../ui/tooltip.js';
19
20
  // Must match SIDEBAR_COOKIE_NAME in ../ui/sidebar.tsx, which writes the
@@ -52,10 +53,23 @@ function VersionEntry({ version }) {
52
53
  // One group's entries. A plain Link, not NavLink: NavLink marks every
53
54
  // matching entry current, and only the most specific one should be
54
55
  // (activeNavTo).
56
+ //
57
+ // An entry's children (#239) are a list inside its item, so they're read
58
+ // as its pages, shown while you're in them (childrenOpen) and otherwise
59
+ // in the page but hidden, as a folded section's are. The icon rail has no
60
+ // room for them: there the entry looks current while one of them is, and
61
+ // its tooltip names them.
55
62
  function NavMenu({ items, activeTo }) {
56
- return (_jsx(SidebarMenu, { children: items.map(({ to, label, icon: Icon }) => {
63
+ const { state, isMobile } = useSidebar();
64
+ const rail = state === 'collapsed' && !isMobile;
65
+ return (_jsx(SidebarMenu, { children: items.map((item) => {
66
+ const { to, label, icon: Icon, children } = item;
57
67
  const isActive = to === activeTo;
58
- return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
68
+ const childActive = children?.some((child) => child.to === activeTo) ?? false;
69
+ return (_jsxs(SidebarMenuItem, { children: [_jsxs(SidebarMenuButton, { isActive: isActive || (rail && childActive), tooltip: children?.length ? `${label}: ${children.map((child) => child.label).join(', ')}` : label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }), children && children.length > 0 && (_jsx(SidebarMenuSub, { hidden: !childrenOpen(item, activeTo), children: children.map(({ to: childTo, label: childLabel, icon: ChildIcon }) => {
70
+ const isChildActive = childTo === activeTo;
71
+ return (_jsx(SidebarMenuSubItem, { children: _jsxs(SidebarMenuSubButton, { isActive: isChildActive, render: _jsx(Link, { to: childTo, "aria-current": isChildActive ? 'page' : undefined }), children: [_jsx(ChildIcon, {}), _jsx("span", { children: childLabel })] }) }, childTo));
72
+ }) }))] }, to));
59
73
  }) }));
60
74
  }
61
75
  // Whether a collapsible section is open, remembered per browser by its
@@ -88,7 +102,7 @@ function CollapsibleNavGroup({ group, activeTo }) {
88
102
  const { state, isMobile } = useSidebar();
89
103
  const contentId = useId();
90
104
  const [stored, setStored] = useState(() => storedSectionOpen(group.label));
91
- const holdsCurrent = group.items.some((item) => item.to === activeTo);
105
+ const holdsCurrent = withChildren(group.items).some((item) => item.to === activeTo);
92
106
  // Arriving in a folded section unfolds it, and it stays unfolded after.
93
107
  if (holdsCurrent && stored !== true)
94
108
  setStored(true);
@@ -110,7 +124,7 @@ function CollapsibleNavGroup({ group, activeTo }) {
110
124
  function AppShellSkeleton() {
111
125
  return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
112
126
  }
113
- export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen = true, version }) {
127
+ export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarOpen = true, version, bottomBar, }) {
114
128
  const { user, status, error, retry, logout } = useAuth();
115
129
  const location = useLocation();
116
130
  const reporter = useErrorReporter();
@@ -146,5 +160,5 @@ export function AppShell({ title, logo: Logo, nav, sidebarExtra, defaultSidebarO
146
160
  // (AuthProvider), so this stays a deliberate logout: a
147
161
  // clean /login, never the previous user's page as `from`.
148
162
  logout().catch(() => { });
149
- }, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: "flex-1 p-6", children: _jsx(Outlet, {}) })] })] }), _jsx(Toaster, { position: "top-center" })] }));
163
+ }, children: _jsx(LogOutIcon, {}) })] })] })] }) }), _jsxs(SidebarInset, { className: "min-w-0", children: [_jsxs("header", { className: "flex h-14 shrink-0 items-center gap-2 border-b border-border px-4", children: [_jsx(SidebarTrigger, {}), _jsx("div", { className: "flex-1" }), _jsx(ThemeToggle, {})] }), _jsx("div", { className: cn('flex-1 p-6', bottomBar?.length && BOTTOM_BAR_PADDING), children: _jsx(Outlet, {}) })] }), bottomBar && bottomBar.length > 0 && _jsx(BottomBar, { items: bottomBar, nav: nav, activeTo: activeTo })] }), _jsx(Toaster, { position: "top-center" })] }));
150
164
  }
@@ -9,7 +9,7 @@ export type SortingState = {
9
9
  }[];
10
10
  declare module '@tanstack/table-core' {
11
11
  interface ColumnMeta<TFeatures extends TableFeatures, TData extends RowData, TValue extends CellData = CellData> {
12
- align?: 'center';
12
+ align?: 'center' | 'right';
13
13
  }
14
14
  }
15
15
  export type DataTableProps<TData extends Record<string, unknown>> = {