@tristan2828/ui-foundation 4.33.0 → 4.35.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 +33 -3
- package/conventions/docs/cell-patterns.md +51 -9
- package/conventions/docs/design-language.md +65 -1
- package/conventions/docs/reminders.md +3 -3
- 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 +1 -1
- package/dist/components/app/data-table.js +10 -4
- package/dist/components/app/line-chart.d.ts +26 -0
- package/dist/components/app/line-chart.js +59 -0
- package/dist/components/app/markdown.d.ts +9 -2
- package/dist/components/app/markdown.js +13 -9
- package/dist/components/app/milkdown-editor.d.ts +1 -1
- package/dist/components/app/milkdown-editor.js +7 -4
- package/dist/components/app/prose-look.d.ts +13 -4
- package/dist/components/app/prose-look.js +8 -5
- package/dist/components/app/rich-text-editor.d.ts +8 -1
- package/dist/components/app/widget-card.d.ts +11 -1
- package/dist/components/app/widget-card.js +19 -3
- 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 +3 -2
- package/dist/index.js +2 -1
- 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
|
|
@@ -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).
|
|
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
|
-
|
|
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
|
|
@@ -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.
|
|
@@ -13,8 +13,8 @@ to the device, where the app's service worker shows it.
|
|
|
13
13
|
| `usePushPermission()` | the package | Whether this device gets reminders, and `enable`/`disable` |
|
|
14
14
|
| `AllowReminders` | the package | The control: says what reminders are, then asks, on a click |
|
|
15
15
|
| `/push/public-key`, `/push/subscriptions` | `openapi.yaml`, the foundation's part (optional) | The VAPID key to subscribe with; this device's subscription saved or dropped |
|
|
16
|
-
| `
|
|
17
|
-
| `
|
|
16
|
+
| `foundation_backend.push` | `ui-foundation-backend` | `send_push(session, user_id, title, body, url)`: encrypted (RFC 8291), signed (VAPID), to every device the user allowed |
|
|
17
|
+
| `foundation_backend.reminders`, `reminders` | `ui-foundation-backend` | A reminder row (when, which days, the text, the path), and the `send-reminders` job that sends each one when it's due |
|
|
18
18
|
|
|
19
19
|
Off until the backend turns it on (`PUSH_ENABLED`, `docs/deploy.md`
|
|
20
20
|
"Reminders (Web Push)"). An app without reminders leaves `/push/*` out of
|
|
@@ -82,4 +82,4 @@ reads `mock` and never subscribes: screens show the `mock` state, and a
|
|
|
82
82
|
Playwright spec checks it the same way. The rest is the backend's: its
|
|
83
83
|
tests decrypt every message as a browser would. To try it end to end, run
|
|
84
84
|
the real backend with push on, allow reminders in the browser, then
|
|
85
|
-
`
|
|
85
|
+
`python -m foundation_backend.scripts.send_test_push <email>` (from `backend/`).
|
|
@@ -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;
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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>> = {
|