@tristan2828/ui-foundation 4.38.0 → 4.40.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/docs/design-language.md +63 -14
- package/conventions/docs/timed-screens.md +49 -9
- package/dist/components/app/artwork.d.ts +43 -0
- package/dist/components/app/artwork.js +43 -0
- package/dist/components/app/data-table.d.ts +49 -1
- package/dist/components/app/data-table.js +64 -6
- package/dist/components/app/session-view.d.ts +17 -1
- package/dist/components/app/session-view.js +33 -2
- package/dist/hooks/use-table-layout.d.ts +31 -0
- package/dist/hooks/use-table-layout.js +67 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -1
- package/package.json +1 -1
|
@@ -3,20 +3,21 @@
|
|
|
3
3
|
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
|
-
- [Semantic tones](#semantic-tones) · lines
|
|
7
|
-
- [The three badge styles](#the-three-badge-styles) · lines
|
|
8
|
-
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines
|
|
9
|
-
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines
|
|
10
|
-
- [Logos and brand marks](#logos-and-brand-marks) · lines
|
|
11
|
-
- [Categorical colour](#categorical-colour) · lines
|
|
12
|
-
- [Typography](#typography) · lines
|
|
13
|
-
- [Written text (notes)](#written-text-notes) · lines
|
|
14
|
-
- [Density](#density) · lines
|
|
15
|
-
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines
|
|
16
|
-
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines
|
|
17
|
-
- [Notices](#notices) · lines
|
|
18
|
-
- [A value over time](#a-value-over-time) · lines
|
|
19
|
-
- [A list as a board](#a-list-as-a-board) · lines
|
|
6
|
+
- [Semantic tones](#semantic-tones) · lines 30–58
|
|
7
|
+
- [The three badge styles](#the-three-badge-styles) · lines 60–77
|
|
8
|
+
- [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 79–108
|
|
9
|
+
- [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 110–123
|
|
10
|
+
- [Logos and brand marks](#logos-and-brand-marks) · lines 125–169
|
|
11
|
+
- [Categorical colour](#categorical-colour) · lines 171–241
|
|
12
|
+
- [Typography](#typography) · lines 243–325
|
|
13
|
+
- [Written text (notes)](#written-text-notes) · lines 274–325
|
|
14
|
+
- [Density](#density) · lines 327–384
|
|
15
|
+
- [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 386–464
|
|
16
|
+
- [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 466–524
|
|
17
|
+
- [Notices](#notices) · lines 526–554
|
|
18
|
+
- [A value over time](#a-value-over-time) · lines 556–589
|
|
19
|
+
- [A list as a board](#a-list-as-a-board) · lines 591–642
|
|
20
|
+
- [A list as cards](#a-list-as-cards) · lines 644–690
|
|
20
21
|
|
|
21
22
|
<!-- /index -->
|
|
22
23
|
|
|
@@ -639,3 +640,51 @@ the table's.
|
|
|
639
640
|
columns share the width, at least 16rem each, and scroll sideways with
|
|
640
641
|
snap points; a phone shows one column at a time, with the edge of the
|
|
641
642
|
next one showing.
|
|
643
|
+
|
|
644
|
+
## A list as cards
|
|
645
|
+
|
|
646
|
+
`DataTable`'s `layouts` (4.39) draw the same rows as cards beside the
|
|
647
|
+
table: a thumbnail grid for a feed, covers for a collection, a dense line
|
|
648
|
+
each for clearing a backlog. They are layouts of one list, not other
|
|
649
|
+
pages: the toolbar, filters, saved views, paging and `groupBy` headings
|
|
650
|
+
are the table's in every one, and so are its loading, empty and error
|
|
651
|
+
states.
|
|
652
|
+
|
|
653
|
+
```tsx
|
|
654
|
+
const { layout, setLayout } = useTableLayout({
|
|
655
|
+
layouts: ['table', 'covers'],
|
|
656
|
+
defaultLayout: 'covers',
|
|
657
|
+
storageKey: 'my-app:books:layout',
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
<DataTable
|
|
661
|
+
{...tableProps}
|
|
662
|
+
layouts={[{ id: 'covers', label: 'Covers', icon: LayoutGridIcon, size: 'sm', aspect: '2:3', renderCard: (book) => <BookCard book={book} /> }]}
|
|
663
|
+
layout={layout}
|
|
664
|
+
onLayoutChange={setLayout}
|
|
665
|
+
/>
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
- **The switch** sits at the end of the toolbar, the table first (rename
|
|
669
|
+
it with `tableLayout`, "Rows" for a list-like table with no header).
|
|
670
|
+
The pick is in the URL (`?layout=`) and remembered per table on the
|
|
671
|
+
device, so name the `storageKey` for the list. A list opens on the
|
|
672
|
+
layout it is read in most: covers for something browsed by its art, the
|
|
673
|
+
table for something compared by its columns.
|
|
674
|
+
- **A grid's sizes** are the package's: `sm` for covers (three across on
|
|
675
|
+
a phone), `md` for thumbnails (two), `lg` for big ones (one). `list`
|
|
676
|
+
is one card per line in a bordered box, divided like rows.
|
|
677
|
+
- **The picture** is `Artwork`: a fixed shape (2:3 posters and books,
|
|
678
|
+
1:1 square art, 16:9 video), the record's image or a neutral
|
|
679
|
+
placeholder (the list's icon, and the title where most records have no
|
|
680
|
+
picture), so the grid's rows stay even. Its corners hold an
|
|
681
|
+
`ArtworkTag` (a length, "Live") and an `ArtworkToggle`, a round button
|
|
682
|
+
that marks the record (Read, Owned): dark on the picture while off,
|
|
683
|
+
success's fill while on.
|
|
684
|
+
- **The card holds what the row does**: the title as the link to the
|
|
685
|
+
record (cell pattern 16), the list's key line in muted text, then its
|
|
686
|
+
status, rating or quality as the table's own cells draw them, and the
|
|
687
|
+
same actions. A card leaves an empty value out rather than showing a
|
|
688
|
+
dash. Never an action only one layout has.
|
|
689
|
+
- `totals` and `highlightRow` are the table's alone. Sorting is the
|
|
690
|
+
toolbar's in a card layout (there is no header to click).
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
<!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
|
|
4
4
|
## Index
|
|
5
5
|
|
|
6
|
-
- [The view](#the-view) · lines
|
|
7
|
-
- [The
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
10
|
-
|
|
11
|
-
- [
|
|
12
|
-
- [
|
|
6
|
+
- [The view](#the-view) · lines 36–109
|
|
7
|
+
- [The dark stage](#the-dark-stage) · lines 73–109
|
|
8
|
+
- [The clock](#the-clock) · lines 111–141
|
|
9
|
+
- [Sound](#sound) · lines 143–190
|
|
10
|
+
- [Background tabs](#background-tabs) · lines 192–246
|
|
11
|
+
- [Interruptions](#interruptions) · lines 226–246
|
|
12
|
+
- [Motion](#motion) · lines 248–252
|
|
13
|
+
- [Testing a timed screen in Playwright](#testing-a-timed-screen-in-playwright) · lines 254–408
|
|
13
14
|
|
|
14
15
|
<!-- /index -->
|
|
15
16
|
|
|
@@ -19,7 +20,8 @@ package has the parts, so a screen like this is assembled, not rebuilt:
|
|
|
19
20
|
|
|
20
21
|
| Part | What it does |
|
|
21
22
|
|---|---|
|
|
22
|
-
| `SessionView` | The whole viewport over the app shell: a modal named by its title, focus kept inside, Esc closes it. Optional real fullscreen
|
|
23
|
+
| `SessionView` | The whole viewport over the app shell: a modal named by its title, focus kept inside, Esc closes it. Optional real fullscreen, a held wake lock, and `dark` for the dark stage |
|
|
24
|
+
| `SessionStage`, `sessionStageLit`, `sessionStageMain`, `sessionStageQuiet` | The stage under the header: a tone, a glow of it behind the centre, the time lit in it, quiet outline controls |
|
|
23
25
|
| `useSessionClock` | A clock that doesn't drift, with pause, resume and seek: `idle`, `running`, `done` |
|
|
24
26
|
| `useWakeLock` | Keeps the screen on while a session runs (`SessionView`'s `keepAwake` uses it) |
|
|
25
27
|
| `unlockAudio`, `chime`, `sequence`, `cancelCues`, `say`, `hush`, `vibrate` | Sound cues with no audio files, booked ahead where they must be on time |
|
|
@@ -29,7 +31,7 @@ package has the parts, so a screen like this is assembled, not rebuilt:
|
|
|
29
31
|
|
|
30
32
|
All of them are imported from `@tristan2828/ui-foundation`. The package's
|
|
31
33
|
Storybook has the whole thing working together: `app/SessionView`, the
|
|
32
|
-
Timer story.
|
|
34
|
+
Timer story, and the same run on the dark stage, DarkStage.
|
|
33
35
|
|
|
34
36
|
## The view
|
|
35
37
|
|
|
@@ -68,6 +70,44 @@ const primary = useRef<HTMLButtonElement>(null)
|
|
|
68
70
|
piece by piece. That fights the primitive's own classes on every
|
|
69
71
|
upgrade, and gets the details above wrong.
|
|
70
72
|
|
|
73
|
+
### The dark stage
|
|
74
|
+
|
|
75
|
+
For a screen followed in a dim room: always dark while it runs, whatever
|
|
76
|
+
the app's theme. A black stage, the time lit in the phase's colour with a
|
|
77
|
+
soft glow of it behind, everything else dimmed, the controls quiet
|
|
78
|
+
outlines.
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
<SessionView open onOpenChange={close} title="Timer" dark={darkStage}>
|
|
82
|
+
<SessionStage tone={darkStage ? phaseTone : 'neutral'} glow={darkStage}>
|
|
83
|
+
<div role="timer" className={cn('font-mono text-[17cqi]', darkStage && sessionStageLit)}>{time}</div>
|
|
84
|
+
<Button variant="outline" className={cn(darkStage && sessionStageQuiet)}>Skip</Button>
|
|
85
|
+
</SessionStage>
|
|
86
|
+
</SessionView>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- **`dark` scopes the dark tokens to the view.** It puts theme.css's
|
|
90
|
+
`.dark` on the view itself, so every token and `dark:` variant inside
|
|
91
|
+
is the dark theme's and the page under it keeps its own. A popup opened
|
|
92
|
+
from the view (a menu, a select) portals to `<body>` and keeps the app's
|
|
93
|
+
theme.
|
|
94
|
+
- **`SessionStage`** is the box the run draws in, filling the view below
|
|
95
|
+
the header. `tone` is the phase's (`neutral`, `success`, `info`,
|
|
96
|
+
`warning`, `destructive`); `glow` washes it behind the centre, faintly
|
|
97
|
+
for `neutral`. It sets `--stage-tone` and `--stage-tone-text` for
|
|
98
|
+
what's inside.
|
|
99
|
+
- **`sessionStageLit`** on the big time: the tone's text shade (white
|
|
100
|
+
for `neutral`) with a glow around the figures. **`sessionStageQuiet`**
|
|
101
|
+
on an outline `Button`: no fill, a faint border, dimmed text that
|
|
102
|
+
brightens on hover. **`sessionStageMain`** on the main control (Start,
|
|
103
|
+
Done, `variant="outline"`): an outline too, in the foreground colour.
|
|
104
|
+
- **Contrast is checked in dark only**, since the stage is never light:
|
|
105
|
+
the package's axe run covers the DarkStage story in both app themes. An
|
|
106
|
+
app's own Playwright runs axe on its screen with the stage on, under a
|
|
107
|
+
light and a dark app theme.
|
|
108
|
+
- **Make it a setting**, remembered per device like Mute, when the app's
|
|
109
|
+
own look is the default: off, the view and stage are as before.
|
|
110
|
+
|
|
71
111
|
## The clock
|
|
72
112
|
|
|
73
113
|
`useSessionClock({ running?, endsAt? })` returns
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { IconComponent } from './icon-component.js';
|
|
3
|
+
export type ArtworkAspect = '2:3' | '1:1' | '16:9';
|
|
4
|
+
export type ArtworkProps = {
|
|
5
|
+
/** The image's URL; null or undefined for the placeholder. */
|
|
6
|
+
src?: string | null;
|
|
7
|
+
alt?: string;
|
|
8
|
+
aspect: ArtworkAspect;
|
|
9
|
+
/** The placeholder's icon (the list's own: a book, a die); a picture icon when left out. */
|
|
10
|
+
placeholderIcon?: IconComponent;
|
|
11
|
+
/** Words under the placeholder's icon, often the title, for a grid where most records have no picture. */
|
|
12
|
+
placeholderLabel?: ReactNode;
|
|
13
|
+
topStart?: ReactNode;
|
|
14
|
+
topEnd?: ReactNode;
|
|
15
|
+
bottomStart?: ReactNode;
|
|
16
|
+
bottomEnd?: ReactNode;
|
|
17
|
+
/** Dims the picture (a watched video), never its overlays. */
|
|
18
|
+
dimmed?: boolean;
|
|
19
|
+
className?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function Artwork({ src, alt, aspect, placeholderIcon: PlaceholderIcon, placeholderLabel, topStart, topEnd, bottomStart, bottomEnd, dimmed, className, }: ArtworkProps): import("react").JSX.Element;
|
|
22
|
+
/**
|
|
23
|
+
* A small label on a picture (a length, "Live", "Short"): the page's
|
|
24
|
+
* foreground as its background, so it reads on any image in either theme.
|
|
25
|
+
*/
|
|
26
|
+
export declare function ArtworkTag({ children, className }: {
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
}): import("react").JSX.Element;
|
|
30
|
+
export type ArtworkToggleProps = {
|
|
31
|
+
/** What it marks, with the record ("Read: Saga"), for a screen reader and the tooltip. */
|
|
32
|
+
label: string;
|
|
33
|
+
pressed: boolean;
|
|
34
|
+
onPressedChange: (pressed: boolean) => void;
|
|
35
|
+
icon: IconComponent;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* A round button on a picture that marks the record (Read, Owned): dark
|
|
40
|
+
* on the picture while off, success's solid fill while on, so it reads on
|
|
41
|
+
* any image. Goes in one of Artwork's corners.
|
|
42
|
+
*/
|
|
43
|
+
export declare function ArtworkToggle({ label, pressed, onPressedChange, icon: Icon, disabled }: ArtworkToggleProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// A card's picture (DataTable's card layouts): a box of a fixed shape, a
|
|
3
|
+
// poster's 2:3, a square cover's 1:1 or a video's 16:9, holding the
|
|
4
|
+
// record's image, or a neutral placeholder when it has none (or it fails
|
|
5
|
+
// to load), so a grid of cards keeps its rows even. Overlays sit on top:
|
|
6
|
+
// `topStart`/`topEnd`/`bottomStart`/`bottomEnd` corners for a badge, a
|
|
7
|
+
// length, or a round button such as Read or Owned.
|
|
8
|
+
//
|
|
9
|
+
// The image is decorative by default (`alt=""`): the card's title, beside
|
|
10
|
+
// it, already names the record. Give `alt` only when the picture says
|
|
11
|
+
// something the card doesn't.
|
|
12
|
+
import { useState } from 'react';
|
|
13
|
+
import { ImageIcon } from 'lucide-react';
|
|
14
|
+
import { cn } from 'cn';
|
|
15
|
+
const ASPECT_CLASS = {
|
|
16
|
+
'2:3': 'aspect-[2/3]',
|
|
17
|
+
'1:1': 'aspect-square',
|
|
18
|
+
'16:9': 'aspect-video',
|
|
19
|
+
};
|
|
20
|
+
const CORNER_CLASS = 'absolute z-10 flex items-center gap-1';
|
|
21
|
+
export function Artwork({ src, alt = '', aspect, placeholderIcon: PlaceholderIcon = ImageIcon, placeholderLabel, topStart, topEnd, bottomStart, bottomEnd, dimmed, className, }) {
|
|
22
|
+
// The src that failed, so a new one gets its own try.
|
|
23
|
+
const [failed, setFailed] = useState(null);
|
|
24
|
+
const showImage = Boolean(src) && failed !== src;
|
|
25
|
+
return (_jsxs("div", { "data-slot": "artwork", "data-aspect": aspect, className: cn('relative w-full overflow-hidden rounded-md bg-muted', ASPECT_CLASS[aspect], className), children: [showImage ? (_jsx("img", { src: src ?? undefined, alt: alt, loading: "lazy", decoding: "async", onError: () => setFailed(src ?? null), className: cn('size-full object-cover', dimmed && 'opacity-55') })) : (_jsxs("div", { "data-slot": "artwork-placeholder", className: cn('flex size-full flex-col items-center justify-center gap-2 p-3 text-center text-muted-foreground', dimmed && 'opacity-55'), children: [_jsx(PlaceholderIcon, { "aria-hidden": "true", className: "size-6 shrink-0" }), placeholderLabel && (_jsx("span", { className: "type-caption line-clamp-3 text-balance text-foreground", children: placeholderLabel }))] })), topStart && _jsx("div", { className: cn(CORNER_CLASS, 'top-1.5 left-1.5'), children: topStart }), topEnd && _jsx("div", { className: cn(CORNER_CLASS, 'top-1.5 right-1.5'), children: topEnd }), bottomStart && _jsx("div", { className: cn(CORNER_CLASS, 'bottom-1.5 left-1.5'), children: bottomStart }), bottomEnd && _jsx("div", { className: cn(CORNER_CLASS, 'right-1.5 bottom-1.5'), children: bottomEnd })] }));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* A small label on a picture (a length, "Live", "Short"): the page's
|
|
29
|
+
* foreground as its background, so it reads on any image in either theme.
|
|
30
|
+
*/
|
|
31
|
+
export function ArtworkTag({ children, className }) {
|
|
32
|
+
return (_jsx("span", { "data-slot": "artwork-tag", className: cn('rounded-sm bg-foreground/85 px-1 font-mono text-[0.6875rem] leading-4 font-medium text-background tabular-nums', className), children: children }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A round button on a picture that marks the record (Read, Owned): dark
|
|
36
|
+
* on the picture while off, success's solid fill while on, so it reads on
|
|
37
|
+
* any image. Goes in one of Artwork's corners.
|
|
38
|
+
*/
|
|
39
|
+
export function ArtworkToggle({ label, pressed, onPressedChange, icon: Icon, disabled }) {
|
|
40
|
+
return (_jsx("button", { type: "button", "aria-pressed": pressed, "aria-label": label, title: label, disabled: disabled, onClick: () => onPressedChange(!pressed), className: cn('grid size-8 place-items-center rounded-full outline-none focus-visible:ring-3 focus-visible:ring-ring/50', 'disabled:pointer-events-none disabled:opacity-50', pressed
|
|
41
|
+
? 'bg-success text-success-foreground hover:bg-success/90'
|
|
42
|
+
: 'bg-foreground/85 text-background hover:bg-foreground'), children: _jsx(Icon, { "aria-hidden": "true", className: "size-4" }) }));
|
|
43
|
+
}
|
|
@@ -2,6 +2,8 @@ import { type ReactNode } from 'react';
|
|
|
2
2
|
import type { CellData, RowData, TableFeatures } from '@tanstack/react-table';
|
|
3
3
|
import { type LegacyColumnDef } from '@tanstack/react-table/legacy';
|
|
4
4
|
import type { AppError } from '../../api/contracts.js';
|
|
5
|
+
import type { ArtworkAspect } from './artwork.js';
|
|
6
|
+
import type { IconComponent } from './icon-component.js';
|
|
5
7
|
import { type RowGroup } from './table-groups.js';
|
|
6
8
|
export type SortingState = {
|
|
7
9
|
id: string;
|
|
@@ -107,6 +109,52 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
|
|
|
107
109
|
* has come) for no row.
|
|
108
110
|
*/
|
|
109
111
|
totals?: DataTableTotals | null;
|
|
112
|
+
/**
|
|
113
|
+
* Card layouts besides the table (a thumbnail grid, covers, one line
|
|
114
|
+
* each), and a switch between them at the end of the toolbar, the table
|
|
115
|
+
* first. Each draws a row with its own `renderCard`; paging, `groupBy`
|
|
116
|
+
* headings and the loading, empty and error states hold in every one.
|
|
117
|
+
* The columns still define the table, and sorting is the toolbar's in a
|
|
118
|
+
* card layout. `totals` and `highlightRow` are the table's alone.
|
|
119
|
+
*/
|
|
120
|
+
layouts?: readonly DataTableCardLayout<TData>[];
|
|
121
|
+
/**
|
|
122
|
+
* The layout showing: `'table'` or a card layout's id, from
|
|
123
|
+
* useTableLayout (the URL's `?layout=`, then the one remembered for this
|
|
124
|
+
* table, then its default). An id the table doesn't have shows the table.
|
|
125
|
+
* Left out, the switch keeps its own state, starting on the table.
|
|
126
|
+
*/
|
|
127
|
+
layout?: string;
|
|
128
|
+
onLayoutChange?: (layout: string) => void;
|
|
129
|
+
/** The table's own entry in the switch: "Table" with a table icon unless set ("Rows", for a list-like table). */
|
|
130
|
+
tableLayout?: {
|
|
131
|
+
label?: string;
|
|
132
|
+
icon?: IconComponent;
|
|
133
|
+
};
|
|
134
|
+
};
|
|
135
|
+
/** One of DataTable's card layouts (`layouts`). */
|
|
136
|
+
export type DataTableCardLayout<TData> = {
|
|
137
|
+
/** In the URL (`?layout=covers`) and remembered, so keep it short and stable. */
|
|
138
|
+
id: string;
|
|
139
|
+
/** The switch's word for it ("Covers"); its accessible name on a phone, where only the icon shows. */
|
|
140
|
+
label: string;
|
|
141
|
+
icon: IconComponent;
|
|
142
|
+
/** A row as a card. Everything it holds is the app's: the picture (Artwork), the title link, the actions. */
|
|
143
|
+
renderCard: (row: TData) => ReactNode;
|
|
144
|
+
/**
|
|
145
|
+
* `grid` (the default): cards side by side, as many across as fit, the
|
|
146
|
+
* package's sizing from the table's width; `list`: one card per line,
|
|
147
|
+
* divided like rows, for a dense one-line-each layout.
|
|
148
|
+
*/
|
|
149
|
+
arrange?: 'grid' | 'list';
|
|
150
|
+
/**
|
|
151
|
+
* A grid card's width: `sm` covers (about 8.5rem, three across on a
|
|
152
|
+
* phone), `md` (the default) thumbnails (11rem, two on a phone), `lg`
|
|
153
|
+
* big thumbnails (14rem, one on a phone).
|
|
154
|
+
*/
|
|
155
|
+
size?: 'sm' | 'md' | 'lg';
|
|
156
|
+
/** The picture's shape, for the loading skeleton's cards: 16:9 unless set. */
|
|
157
|
+
aspect?: ArtworkAspect;
|
|
110
158
|
};
|
|
111
159
|
export type DataTableWidth = 'full' | 'content';
|
|
112
160
|
export type DataTableTotals = {
|
|
@@ -119,4 +167,4 @@ export type DataTableTotals = {
|
|
|
119
167
|
*/
|
|
120
168
|
cells?: Partial<Record<string, ReactNode>>;
|
|
121
169
|
};
|
|
122
|
-
export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
170
|
+
export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width, groupBy, groupSummary, header: showHeader, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
@@ -14,11 +14,12 @@ import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } fr
|
|
|
14
14
|
import { flexRender } from '@tanstack/react-table';
|
|
15
15
|
import { cn } from 'cn';
|
|
16
16
|
import { getCoreRowModel, useLegacyTable } from '@tanstack/react-table/legacy';
|
|
17
|
-
import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
|
17
|
+
import { ArrowDownIcon, ArrowUpIcon, ArrowUpDownIcon, ChevronLeftIcon, ChevronRightIcon, TableIcon } from 'lucide-react';
|
|
18
18
|
import { ErrorState } from './error-state.js';
|
|
19
19
|
import { Button } from '../ui/button.js';
|
|
20
20
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
|
+
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
22
23
|
import { EditInPlaceContext } from './edit-in-place.js';
|
|
23
24
|
import { EditInPlaceStore } from './edit-in-place-store.js';
|
|
24
25
|
import { LeaveGuard } from './editable-value.js';
|
|
@@ -38,6 +39,41 @@ function leafColumnIds(columns) {
|
|
|
38
39
|
return id === undefined ? [] : [id];
|
|
39
40
|
});
|
|
40
41
|
}
|
|
42
|
+
// A card grid's columns: as many of the size as the table's width holds,
|
|
43
|
+
// and a fixed count on a phone-width table (`@container/data-table-cards`,
|
|
44
|
+
// under 28rem), where minimum widths would leave one lonely column.
|
|
45
|
+
const CARD_GRID_CLASS = {
|
|
46
|
+
sm: 'grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] @max-md/data-table-cards:grid-cols-3',
|
|
47
|
+
md: 'grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] @max-md/data-table-cards:grid-cols-2',
|
|
48
|
+
lg: 'grid-cols-[repeat(auto-fill,minmax(14rem,1fr))] @max-md/data-table-cards:grid-cols-1',
|
|
49
|
+
};
|
|
50
|
+
const CARD_GRID_GAP_CLASS = 'gap-x-4 gap-y-6 @max-md/data-table-cards:gap-x-2.5 @max-md/data-table-cards:gap-y-4';
|
|
51
|
+
// A `list` layout: rows in a bordered box, divided as the table's are.
|
|
52
|
+
const CARD_LIST_CLASS = 'divide-y divide-border overflow-hidden rounded-lg border border-border';
|
|
53
|
+
const SKELETON_ASPECT_CLASS = {
|
|
54
|
+
'2:3': 'aspect-[2/3]',
|
|
55
|
+
'1:1': 'aspect-square',
|
|
56
|
+
'16:9': 'aspect-video',
|
|
57
|
+
};
|
|
58
|
+
function CardsSkeleton({ layout }) {
|
|
59
|
+
if (layout.arrange === 'list') {
|
|
60
|
+
return (_jsx("div", { className: cn(CARD_LIST_CLASS, 'flex flex-col'), "data-state": "loading", children: Array.from({ length: 6 }, (_, index) => (_jsxs("div", { className: "flex items-center gap-3 px-3 py-2", children: [_jsx(Skeleton, { className: "size-6 shrink-0 rounded-full" }), _jsx(Skeleton, { className: "h-4 flex-1" })] }, index))) }));
|
|
61
|
+
}
|
|
62
|
+
return (_jsx("div", { className: "@container/data-table-cards", "data-state": "loading", children: _jsx("div", { className: cn('grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS), children: Array.from({ length: 8 }, (_, index) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: cn('w-full rounded-md', SKELETON_ASPECT_CLASS[layout.aspect ?? '16:9']) }), _jsx(Skeleton, { className: "h-4 w-4/5" }), _jsx(Skeleton, { className: "h-3 w-1/2" })] }, index))) }) }));
|
|
63
|
+
}
|
|
64
|
+
// The layout switch: a toggle group (one tab stop, the arrow keys along
|
|
65
|
+
// it), the pressed one in the muted fill with the foreground's colour and
|
|
66
|
+
// medium weight, the others in muted text, so the difference isn't the
|
|
67
|
+
// fill alone. On a phone only the icons show; the label stays their name.
|
|
68
|
+
const LAYOUT_ITEM_CLASS = 'gap-1.5 text-muted-foreground aria-pressed:bg-muted aria-pressed:font-medium aria-pressed:text-foreground';
|
|
69
|
+
function LayoutSwitch({ options, value, onValueChange, }) {
|
|
70
|
+
return (_jsx(ToggleGroup, { "aria-label": "Layout", variant: "outline", size: "sm", spacing: 0, "data-slot": "data-table-layout-switch", value: [value], onValueChange: (next) => {
|
|
71
|
+
// [] is the pressed one pressed again: it stays on.
|
|
72
|
+
const chosen = next[0];
|
|
73
|
+
if (chosen !== undefined && chosen !== value)
|
|
74
|
+
onValueChange(chosen);
|
|
75
|
+
}, children: options.map(({ id, label, icon: Icon }) => (_jsxs(ToggleGroupItem, { value: id, className: LAYOUT_ITEM_CLASS, title: label, children: [_jsx(Icon, { "aria-hidden": "true", className: "size-4" }), _jsx("span", { className: "max-sm:sr-only", children: label })] }, id))) }));
|
|
76
|
+
}
|
|
41
77
|
function TableSkeleton({ columnCount }) {
|
|
42
78
|
return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
|
|
43
79
|
}
|
|
@@ -172,7 +208,17 @@ function useBottomScrollbar(containerRef, isSuccessView) {
|
|
|
172
208
|
}, [containerRef, isSuccessView, isOverflowing]);
|
|
173
209
|
return { barRef, isOverflowing, scrollWidth };
|
|
174
210
|
}
|
|
175
|
-
export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, totals, }) {
|
|
211
|
+
export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, width = 'full', groupBy, groupSummary, header: showHeader = true, highlightRow, totals, layouts, layout: layoutProp, onLayoutChange, tableLayout, }) {
|
|
212
|
+
// The switch's own state, for a table that doesn't pass `layout`.
|
|
213
|
+
const [ownLayout, setOwnLayout] = useState('table');
|
|
214
|
+
const requestedLayout = layoutProp ?? ownLayout;
|
|
215
|
+
const cardLayout = layouts?.find((candidate) => candidate.id === requestedLayout);
|
|
216
|
+
const currentLayout = cardLayout?.id ?? 'table';
|
|
217
|
+
const setLayout = (next) => {
|
|
218
|
+
if (layoutProp === undefined)
|
|
219
|
+
setOwnLayout(next);
|
|
220
|
+
onLayoutChange?.(next);
|
|
221
|
+
};
|
|
176
222
|
// Editing in place in the rows (cell pattern 18, "In a table's rows"):
|
|
177
223
|
// a cell that renders an <EditableValue> edits where it's shown, one at
|
|
178
224
|
// a time, with the leave-page prompt while one is open, the same rules
|
|
@@ -229,15 +275,18 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
229
275
|
return () => observer.disconnect();
|
|
230
276
|
}, [width, isSuccessView]);
|
|
231
277
|
const containerRef = useRef(null);
|
|
232
|
-
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
|
|
278
|
+
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView && !cardLayout);
|
|
233
279
|
const rows = table.getRowModel().rows;
|
|
234
280
|
const visibleLeafColumns = table.getVisibleLeafColumns();
|
|
235
281
|
const visibleColumnCount = visibleLeafColumns.length;
|
|
236
282
|
// One body row, the same in a plain table and a grouped one.
|
|
237
283
|
const renderRow = (row) => (_jsx(TableRow, { "data-highlighted": highlightRow?.(row.original) || undefined, className: cn(pinFirstColumn && ROW_GROUP_CLASS, highlightRow?.(row.original) && HIGHLIGHTED_ROW_CLASS), children: row.getVisibleCells().map((cell, index) => (_jsx(TableCell, { className: cn(pinFirstColumn && index === 0 && PINNED_COLUMN_CLASS, pinLastColumn && index === row.getVisibleCells().length - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(cell.column.columnDef.meta?.align)), children: pinFirstColumn && index === 0 ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: flexRender(cell.column.columnDef.cell, cell.getContext()) })) : (flexRender(cell.column.columnDef.cell, cell.getContext())) }, cell.id))) }, row.id));
|
|
238
|
-
const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, style: width === 'content' && !isSuccessView && lastWidth !== null
|
|
284
|
+
const content = (_jsxs("div", { ref: root, className: cn('flex flex-col gap-4', width === 'content' && CONTENT_WIDTH_CLASS), "data-width": width, "data-layout": layouts?.length ? currentLayout : undefined, style: width === 'content' && !isSuccessView && lastWidth !== null
|
|
239
285
|
? { '--data-table-last-width': `${lastWidth}px` }
|
|
240
|
-
: undefined, children: [
|
|
286
|
+
: undefined, children: [layouts?.length ? (_jsxs("div", { className: "flex flex-wrap items-end gap-x-4 gap-y-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: toolbar }), _jsx(LayoutSwitch, { options: [
|
|
287
|
+
{ id: 'table', label: tableLayout?.label ?? 'Table', icon: tableLayout?.icon ?? TableIcon },
|
|
288
|
+
...layouts,
|
|
289
|
+
], value: currentLayout, onValueChange: setLayout })] })) : (toolbar), error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (cardLayout ? (_jsx(CardsSkeleton, { layout: cardLayout })) : (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length }))) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [cardLayout ? (_jsx(Cards, { layout: cardLayout, rows: rows, groupBy: groupBy, groupSummary: groupSummary })) : (_jsxs(Table, { "data-state": "success", containerRef: containerRef,
|
|
241
290
|
// With the sticky bar below as the visible scrollbar, hide the
|
|
242
291
|
// container's own. It stays overflow-x-auto and fully
|
|
243
292
|
// scrollable (wheel, trackpad, drag, and the sync effect); it
|
|
@@ -269,6 +318,15 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
269
318
|
}) }, headerGroup.id))) }), groupBy ? (groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs(TableBody, { "data-slot": "table-group", children: [_jsx(TableRow, { "data-slot": "table-group-heading", className: "hover:bg-transparent", children: _jsx(TableHead, { scope: "rowgroup", colSpan: visibleColumnCount, className: GROUP_HEADING_CLASS, children: _jsxs("span", { className: "sticky left-(--table-cell-px) inline-flex items-baseline gap-2", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }) }) }), _jsx("tr", { hidden: true, "aria-hidden": "true", "data-slot": "table-group-stripe-offset" }), run.rows.map(renderRow)] }, `${runIndex}:${run.key}`)))) : (_jsx(TableBody, { children: rows.map(renderRow) })), totals && (_jsx(TableFooter, { className: TOTALS_FOOTER_CLASS, children: _jsx(TableRow, { "data-slot": "table-totals", className: "hover:bg-transparent", children: visibleLeafColumns.map((column, index) => {
|
|
270
319
|
const className = cn(pinFirstColumn && index === 0 && PINNED_TOTALS_LABEL_CLASS, pinLastColumn && index === visibleColumnCount - 1 && PINNED_LAST_COLUMN_CLASS, alignClass(column.columnDef.meta?.align));
|
|
271
320
|
return index === 0 ? (_jsx(TableHead, { scope: "row", className: cn(TOTALS_LABEL_CLASS, className), children: pinFirstColumn ? (_jsx("div", { className: PINNED_FIRST_CONTENT_CLASS, children: totals.label })) : (totals.label) }, column.id)) : (_jsx(TableCell, { className: className, children: totals.cells?.[column.id] }, column.id));
|
|
272
|
-
}) }) }))] }), pinFirstColumn && isOverflowing && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
|
|
321
|
+
}) }) }))] })), pinFirstColumn && isOverflowing && !cardLayout && (_jsx("div", { ref: barRef, "aria-hidden": "true", tabIndex: -1, "data-slot": "bottom-scrollbar", className: "sticky bottom-0 z-20 h-4 overflow-x-auto border-t border-border bg-background", children: _jsx("div", { style: { width: scrollWidth, height: 1 } }) })), _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("p", { className: "type-body text-muted-foreground", children: [total === 0 ? 0 : (page - 1) * pageSize + 1, '–', Math.min(page * pageSize, total), " of ", total] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Previous page", disabled: page <= 1, onClick: () => onPageChange(page - 1), children: _jsx(ChevronLeftIcon, {}) }), _jsxs("span", { className: "type-body text-muted-foreground", children: ["Page ", page, " of ", pageCount] }), _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "Next page", disabled: page >= pageCount, onClick: () => onPageChange(page + 1), children: _jsx(ChevronRightIcon, {}) })] })] })] }))] }));
|
|
273
322
|
return (_jsxs(EditInPlaceContext.Provider, { value: store, children: [editing && _jsx(LeaveGuard, { store: store }), width === 'content' ? _jsx("div", { className: "@container/data-table", children: content }) : content] }));
|
|
274
323
|
}
|
|
324
|
+
// A card layout's success view: the rows as cards in a grid or a list,
|
|
325
|
+
// each run of one group (`groupBy`) under its own heading, as the table's
|
|
326
|
+
// heading rows are, with the same label and summary.
|
|
327
|
+
function Cards({ layout, rows, groupBy, groupSummary, }) {
|
|
328
|
+
const list = (items) => (_jsx("ul", { role: "list", className: cn(layout.arrange === 'list' ? CARD_LIST_CLASS : ['grid', CARD_GRID_CLASS[layout.size ?? 'md'], CARD_GRID_GAP_CLASS]), children: items.map((row) => (_jsx("li", { "data-slot": "data-table-card", className: "min-w-0", children: layout.renderCard(row.original) }, row.id))) }));
|
|
329
|
+
return (_jsx("div", { className: "@container/data-table-cards flex flex-col gap-6", "data-state": "success", "data-slot": "data-table-cards", children: groupBy
|
|
330
|
+
? groupRuns(rows, (row) => groupBy(row.original)).map((run, runIndex) => (_jsxs("section", { "data-slot": "table-group", className: "flex flex-col gap-3", children: [_jsxs("h2", { "data-slot": "table-group-heading", className: "flex items-baseline gap-2 border-b border-border pb-1.5", children: [_jsx("span", { className: "type-label text-foreground", children: run.label }), groupSummary && (_jsx("span", { className: "type-caption text-muted-foreground tabular-nums", children: groupSummary(run.rows.map((row) => row.original)) }))] }), list(run.rows)] }, `${runIndex}:${run.key}`)))
|
|
331
|
+
: list(rows) }));
|
|
332
|
+
}
|
|
@@ -13,7 +13,23 @@ export type SessionViewProps = {
|
|
|
13
13
|
fullscreenControl?: boolean;
|
|
14
14
|
/** What takes focus on open (default: the first focusable control, which is the header's). */
|
|
15
15
|
initialFocus?: DialogPrimitive.Popup.Props['initialFocus'];
|
|
16
|
+
/** Render the view with the dark theme's tokens, whatever the app's theme. */
|
|
17
|
+
dark?: boolean;
|
|
16
18
|
/** On the area below the header, which holds `children`. */
|
|
17
19
|
className?: string;
|
|
18
20
|
};
|
|
19
|
-
export declare function SessionView({ open, onOpenChange, title, children, keepAwake, fullscreenControl, initialFocus, className, }: SessionViewProps): React.JSX.Element;
|
|
21
|
+
export declare function SessionView({ open, onOpenChange, title, children, keepAwake, fullscreenControl, initialFocus, dark, className, }: SessionViewProps): React.JSX.Element;
|
|
22
|
+
export type SessionStageTone = 'neutral' | 'success' | 'info' | 'warning' | 'destructive';
|
|
23
|
+
export type SessionStageProps = React.ComponentProps<'div'> & {
|
|
24
|
+
/** The phase's tone: what the glow and `sessionStageLit` take. */
|
|
25
|
+
tone?: SessionStageTone;
|
|
26
|
+
/** A soft wash of the tone behind the centre of the stage. */
|
|
27
|
+
glow?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export declare function SessionStage({ tone, glow, className, style, ...props }: SessionStageProps): React.JSX.Element;
|
|
30
|
+
/** The big time on a stage: the tone's text shade, with a glow of the tone around the figures. */
|
|
31
|
+
export declare const sessionStageLit = "text-[var(--stage-tone-text)] [text-shadow:0_0_0.3em_color-mix(in_oklab,var(--stage-tone)_55%,transparent)]";
|
|
32
|
+
/** A quiet outline control on a stage: no fill, a faint border, dimmed text that brightens on hover. */
|
|
33
|
+
export declare const sessionStageQuiet = "border-foreground/25 bg-transparent text-muted-foreground shadow-none hover:border-foreground hover:bg-transparent hover:text-foreground dark:border-foreground/25 dark:bg-transparent dark:hover:border-foreground dark:hover:bg-transparent";
|
|
34
|
+
/** The main control on a stage (Start, Done): an outline too, in the foreground colour. */
|
|
35
|
+
export declare const sessionStageMain = "border-foreground bg-transparent text-foreground shadow-none hover:bg-foreground/10 hover:text-foreground dark:border-foreground dark:bg-transparent dark:hover:bg-foreground/10";
|
|
@@ -8,6 +8,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
8
8
|
// Optional: a "Full screen" control that also asks the browser for real
|
|
9
9
|
// fullscreen and quietly isn't there where the browser can't (iOS Safari
|
|
10
10
|
// on a phone), and keepAwake, which holds a screen wake lock while open.
|
|
11
|
+
// `dark` renders the view in the dark theme whatever the app's (the dark
|
|
12
|
+
// stage, #253): the dark tokens are scoped to the view, so the page under
|
|
13
|
+
// it keeps its own. SessionStage, below, is the stage under the header.
|
|
11
14
|
// Recipes for its states in Playwright: docs/foundation/timed-screens.md.
|
|
12
15
|
import * as React from 'react';
|
|
13
16
|
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog';
|
|
@@ -16,10 +19,12 @@ import { cn } from 'cn';
|
|
|
16
19
|
import { Button } from '../ui/button.js';
|
|
17
20
|
import { Dialog, DialogClose, DialogPortal, DialogTitle } from '../ui/dialog.js';
|
|
18
21
|
import { useWakeLock } from '../../hooks/use-wake-lock.js';
|
|
19
|
-
export function SessionView({ open, onOpenChange, title, children, keepAwake = false, fullscreenControl = false, initialFocus, className, }) {
|
|
22
|
+
export function SessionView({ open, onOpenChange, title, children, keepAwake = false, fullscreenControl = false, initialFocus, dark = false, className, }) {
|
|
20
23
|
useWakeLock(open && keepAwake);
|
|
21
24
|
const fullscreen = useDocumentFullscreen(open);
|
|
22
|
-
return (_jsx(Dialog, { open: open, onOpenChange: (next) => onOpenChange(next), children: _jsx(DialogPortal, { children: _jsxs(DialogPrimitive.Popup, { "data-slot": "session-view", initialFocus: initialFocus, className: cn('fixed inset-0 z-50 flex flex-col bg-background text-foreground outline-none',
|
|
25
|
+
return (_jsx(Dialog, { open: open, onOpenChange: (next) => onOpenChange(next), children: _jsx(DialogPortal, { children: _jsxs(DialogPrimitive.Popup, { "data-slot": "session-view", "data-look": dark ? 'dark' : undefined, initialFocus: initialFocus, className: cn('fixed inset-0 z-50 flex flex-col bg-background text-foreground outline-none',
|
|
26
|
+
// theme.css's .dark block remaps the tokens for this subtree only.
|
|
27
|
+
dark && 'dark scheme-dark',
|
|
23
28
|
// The insets are 0 unless the page's viewport meta has viewport-fit=cover.
|
|
24
29
|
'pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]', 'duration-150 data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0'), children: [_jsxs("header", { className: "flex items-center gap-1 px-4 py-2", children: [_jsx(DialogTitle, { className: "min-w-0 flex-1 truncate type-section-title", children: title }), fullscreenControl && fullscreen.supported && (_jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Full screen", "aria-pressed": fullscreen.active, onClick: fullscreen.toggle, children: fullscreen.active ? _jsx(Minimize2Icon, {}) : _jsx(Maximize2Icon, {}) })), _jsx(DialogClose, { render: _jsx(Button, { variant: "ghost", size: "icon", "aria-label": "Close" }), children: _jsx(XIcon, {}) })] }), _jsx("div", { className: cn('flex min-h-0 flex-1 flex-col overflow-auto px-4 pb-4', className), children: children })] }) }) }));
|
|
25
30
|
}
|
|
@@ -66,3 +71,29 @@ function useDocumentFullscreen(open) {
|
|
|
66
71
|
};
|
|
67
72
|
return { supported, active, toggle };
|
|
68
73
|
}
|
|
74
|
+
// Each tone's solid fill (the glow) and its text shade (the lit time).
|
|
75
|
+
// Neutral glows with the foreground, faintly, and lights in it.
|
|
76
|
+
const STAGE_TONES = {
|
|
77
|
+
neutral: { solid: 'var(--foreground)', text: 'var(--foreground)', wash: '8%' },
|
|
78
|
+
success: { solid: 'var(--success)', text: 'var(--success-text)', wash: '24%' },
|
|
79
|
+
info: { solid: 'var(--info)', text: 'var(--info-text)', wash: '24%' },
|
|
80
|
+
warning: { solid: 'var(--warning)', text: 'var(--warning-text)', wash: '26%' },
|
|
81
|
+
destructive: { solid: 'var(--destructive)', text: 'var(--destructive-text)', wash: '24%' },
|
|
82
|
+
};
|
|
83
|
+
// The stage: the area a timed screen draws its run in, filling the view
|
|
84
|
+
// below the header. It sets --stage-tone (the tone's solid) and
|
|
85
|
+
// --stage-tone-text (its text shade) for what's inside, and with `glow` a
|
|
86
|
+
// radial wash of the tone on the page colour, fading out to the edges. Made
|
|
87
|
+
// for a dark SessionView, where the wash reads as light; on a light page it
|
|
88
|
+
// is a faint tint.
|
|
89
|
+
export function SessionStage({ tone = 'neutral', glow = false, className, style, ...props }) {
|
|
90
|
+
const t = STAGE_TONES[tone];
|
|
91
|
+
return (_jsx("div", { "data-slot": "session-stage", "data-tone": tone, "data-glow": glow || undefined, className: cn('relative flex min-h-0 flex-1 flex-col', glow &&
|
|
92
|
+
'bg-[radial-gradient(ellipse_70%_60%_at_50%_52%,color-mix(in_oklab,var(--stage-tone)_var(--stage-wash),var(--background))_0,var(--background)_75%)]', className), style: { '--stage-tone': t.solid, '--stage-tone-text': t.text, '--stage-wash': t.wash, ...style }, ...props }));
|
|
93
|
+
}
|
|
94
|
+
/** The big time on a stage: the tone's text shade, with a glow of the tone around the figures. */
|
|
95
|
+
export const sessionStageLit = 'text-[var(--stage-tone-text)] [text-shadow:0_0_0.3em_color-mix(in_oklab,var(--stage-tone)_55%,transparent)]';
|
|
96
|
+
/** A quiet outline control on a stage: no fill, a faint border, dimmed text that brightens on hover. */
|
|
97
|
+
export const sessionStageQuiet = 'border-foreground/25 bg-transparent text-muted-foreground shadow-none hover:border-foreground hover:bg-transparent hover:text-foreground dark:border-foreground/25 dark:bg-transparent dark:hover:border-foreground dark:hover:bg-transparent';
|
|
98
|
+
/** The main control on a stage (Start, Done): an outline too, in the foreground colour. */
|
|
99
|
+
export const sessionStageMain = 'border-foreground bg-transparent text-foreground shadow-none hover:bg-foreground/10 hover:text-foreground dark:border-foreground dark:bg-transparent dark:hover:bg-foreground/10';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
|
|
2
|
+
export type LayoutStore = {
|
|
3
|
+
get: (key: string) => string | null;
|
|
4
|
+
set: (key: string, value: string) => void;
|
|
5
|
+
subscribe: (key: string, listener: () => void) => () => void;
|
|
6
|
+
};
|
|
7
|
+
export declare function createLayoutStore(getStorage: () => StorageLike | undefined): LayoutStore;
|
|
8
|
+
/** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
|
|
9
|
+
export declare function resolveLayout(layouts: readonly string[], fromUrl: string | null, remembered: string | null, defaultLayout: string): string;
|
|
10
|
+
export type TableLayoutOptions = {
|
|
11
|
+
/** Every layout id the table has, the table's own `'table'` among them. */
|
|
12
|
+
layouts: readonly string[];
|
|
13
|
+
/** The one shown until another is picked; `'table'` when left out. */
|
|
14
|
+
defaultLayout?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Where the pick is remembered on this device, one key per table: name it
|
|
17
|
+
* for the app and the list (`"my-app:comics:layout"`). Without one the
|
|
18
|
+
* pick lives only in the URL.
|
|
19
|
+
*/
|
|
20
|
+
storageKey?: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
|
|
24
|
+
* Setting one writes `?layout=` (replacing the history entry, as a filter
|
|
25
|
+
* does) and remembers it under `storageKey`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useTableLayout({ layouts, defaultLayout, storageKey }: TableLayoutOptions): {
|
|
28
|
+
layout: string;
|
|
29
|
+
setLayout: (next: string) => void;
|
|
30
|
+
};
|
|
31
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
2
|
+
import { useSearchParams } from 'react-router';
|
|
3
|
+
export function createLayoutStore(getStorage) {
|
|
4
|
+
const memory = new Map();
|
|
5
|
+
const listeners = new Map();
|
|
6
|
+
return {
|
|
7
|
+
get(key) {
|
|
8
|
+
const kept = memory.get(key);
|
|
9
|
+
if (kept !== undefined)
|
|
10
|
+
return kept;
|
|
11
|
+
try {
|
|
12
|
+
return getStorage()?.getItem(key) ?? null;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
set(key, value) {
|
|
19
|
+
memory.set(key, value);
|
|
20
|
+
try {
|
|
21
|
+
getStorage()?.setItem(key, value);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
// Storage blocked or full: remembered in memory for this page only.
|
|
25
|
+
}
|
|
26
|
+
for (const listener of listeners.get(key) ?? [])
|
|
27
|
+
listener();
|
|
28
|
+
},
|
|
29
|
+
subscribe(key, listener) {
|
|
30
|
+
let set = listeners.get(key);
|
|
31
|
+
if (!set)
|
|
32
|
+
listeners.set(key, (set = new Set()));
|
|
33
|
+
set.add(listener);
|
|
34
|
+
return () => set.delete(listener);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** The layout to show: the URL's if the table has it, then the remembered one, then the default. */
|
|
39
|
+
export function resolveLayout(layouts, fromUrl, remembered, defaultLayout) {
|
|
40
|
+
if (fromUrl !== null && layouts.includes(fromUrl))
|
|
41
|
+
return fromUrl;
|
|
42
|
+
if (remembered !== null && layouts.includes(remembered))
|
|
43
|
+
return remembered;
|
|
44
|
+
return defaultLayout;
|
|
45
|
+
}
|
|
46
|
+
const browserStore = createLayoutStore(() => (typeof window === 'undefined' ? undefined : window.localStorage));
|
|
47
|
+
/**
|
|
48
|
+
* `{ layout, setLayout }` for DataTable's `layout` and `onLayoutChange`.
|
|
49
|
+
* Setting one writes `?layout=` (replacing the history entry, as a filter
|
|
50
|
+
* does) and remembers it under `storageKey`.
|
|
51
|
+
*/
|
|
52
|
+
export function useTableLayout({ layouts, defaultLayout = 'table', storageKey }) {
|
|
53
|
+
const [params, setParams] = useSearchParams();
|
|
54
|
+
const subscribe = useCallback((listener) => (storageKey === undefined ? () => { } : browserStore.subscribe(storageKey, listener)), [storageKey]);
|
|
55
|
+
const remembered = useSyncExternalStore(subscribe, () => (storageKey === undefined ? null : browserStore.get(storageKey)), () => null);
|
|
56
|
+
const layout = resolveLayout(layouts, params.get('layout'), remembered, defaultLayout);
|
|
57
|
+
const setLayout = useCallback((next) => {
|
|
58
|
+
if (storageKey !== undefined)
|
|
59
|
+
browserStore.set(storageKey, next);
|
|
60
|
+
setParams((previous) => {
|
|
61
|
+
const updated = new URLSearchParams(previous);
|
|
62
|
+
updated.set('layout', next);
|
|
63
|
+
return updated;
|
|
64
|
+
}, { replace: true });
|
|
65
|
+
}, [setParams, storageKey]);
|
|
66
|
+
return { layout, setLayout };
|
|
67
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export { AppShell, type AppShellProps, type AppShellVersion, type BottomBarItem, type NavEntry, type NavItem, type NavSection, } from './components/app/app-shell.js';
|
|
2
2
|
export type { IconComponent } from './components/app/icon-component.js';
|
|
3
3
|
export { Board, type BoardColumn, type BoardMoveTarget, type BoardProps } from './components/app/board.js';
|
|
4
|
+
export { Artwork, ArtworkTag, ArtworkToggle, type ArtworkAspect, type ArtworkProps, type ArtworkToggleProps, } from './components/app/artwork.js';
|
|
4
5
|
export { CountLink, type CountLinkProps } from './components/app/count-link.js';
|
|
5
|
-
export { DataTable, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
6
|
+
export { DataTable, type DataTableCardLayout, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
|
|
6
7
|
export { type RowGroup } from './components/app/table-groups.js';
|
|
7
8
|
export { byDay, type ByDayOptions } from './lib/group-by-day.js';
|
|
8
9
|
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
@@ -25,7 +26,7 @@ export { RichTextEditor, type RichTextEditorProps } from './components/app/rich-
|
|
|
25
26
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
26
27
|
export { PillFilter, type PillFilterOption, type PillFilterProps } from './components/app/pill-filter.js';
|
|
27
28
|
export { SecondaryFilters, type ActiveFilter, type SecondaryFiltersProps } from './components/app/secondary-filters.js';
|
|
28
|
-
export { SessionView, type SessionViewProps } from './components/app/session-view.js';
|
|
29
|
+
export { SessionStage, SessionView, sessionStageLit, sessionStageMain, sessionStageQuiet, type SessionStageProps, type SessionStageTone, type SessionViewProps, } from './components/app/session-view.js';
|
|
29
30
|
export { AllowReminders, type AllowRemindersProps } from './components/app/allow-reminders.js';
|
|
30
31
|
export { StageCircle, type StageCircleProps, type StageCircleStage, type StageCircleTone, } from './components/app/stage-circle.js';
|
|
31
32
|
export { Notice, type NoticeProps, type NoticeTone } from './components/app/notice.js';
|
|
@@ -51,6 +52,7 @@ export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
|
51
52
|
export type { ErrorReportingOptions } from './error-reporting/reporter.js';
|
|
52
53
|
export { createQueryClient } from './api/query-client.js';
|
|
53
54
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
55
|
+
export { useTableLayout, type TableLayoutOptions } from './hooks/use-table-layout.js';
|
|
54
56
|
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
55
57
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
56
58
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Composites
|
|
6
6
|
export { AppShell, } from './components/app/app-shell.js';
|
|
7
7
|
export { Board } from './components/app/board.js';
|
|
8
|
+
export { Artwork, ArtworkTag, ArtworkToggle, } from './components/app/artwork.js';
|
|
8
9
|
export { CountLink } from './components/app/count-link.js';
|
|
9
10
|
export { DataTable, } from './components/app/data-table.js';
|
|
10
11
|
export {} from './components/app/table-groups.js';
|
|
@@ -28,7 +29,7 @@ export { RichTextEditor } from './components/app/rich-text-editor.js';
|
|
|
28
29
|
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
29
30
|
export { PillFilter } from './components/app/pill-filter.js';
|
|
30
31
|
export { SecondaryFilters } from './components/app/secondary-filters.js';
|
|
31
|
-
export { SessionView } from './components/app/session-view.js';
|
|
32
|
+
export { SessionStage, SessionView, sessionStageLit, sessionStageMain, sessionStageQuiet, } from './components/app/session-view.js';
|
|
32
33
|
export { AllowReminders } from './components/app/allow-reminders.js';
|
|
33
34
|
export { StageCircle, } from './components/app/stage-circle.js';
|
|
34
35
|
export { Notice } from './components/app/notice.js';
|
|
@@ -54,6 +55,7 @@ export { AuthProvider, useAuth } from './auth/use-auth.js';
|
|
|
54
55
|
export { createQueryClient } from './api/query-client.js';
|
|
55
56
|
// Hooks
|
|
56
57
|
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
58
|
+
export { useTableLayout } from './hooks/use-table-layout.js';
|
|
57
59
|
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
58
60
|
export { useDocumentTitle } from './hooks/use-document-title.js';
|
|
59
61
|
export { useRecordUpdate, } from './hooks/use-record-update.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.40.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|