@tristan2828/ui-foundation 4.34.0 → 4.36.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.
@@ -3,19 +3,20 @@
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 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
6
+ - [Semantic tones](#semantic-tones) · lines 29–57
7
+ - [The three badge styles](#the-three-badge-styles) · lines 59–76
8
+ - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 78–107
9
+ - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 109–122
10
+ - [Logos and brand marks](#logos-and-brand-marks) · lines 124–168
11
+ - [Categorical colour](#categorical-colour) · lines 170–240
12
+ - [Typography](#typography) · lines 242–324
13
+ - [Written text (notes)](#written-text-notes) · lines 273–324
14
+ - [Density](#density) · lines 326–383
15
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 385–460
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 462–520
17
+ - [Notices](#notices) · lines 522–550
18
+ - [A value over time](#a-value-over-time) · lines 552–585
19
+ - [A list as a board](#a-list-as-a-board) · lines 587–638
19
20
 
20
21
  <!-- /index -->
21
22
 
@@ -582,3 +583,56 @@ or `'last'`) with its value.
582
583
  `formatX` the page uses for those values elsewhere.
583
584
  - One series and a few dozen points. Several series, zoom or tooltips
584
585
  are a request for the foundation, not a hand-drawn chart in the app.
586
+
587
+ ## A list as a board
588
+
589
+ `Board` (4.36) shows a list as a column per value of one choice field (a
590
+ status, a stage), a card per record, and moves a record by moving its
591
+ card. It is a second view of a list, on its own page beside the
592
+ `DataTable`, never a replacement: filters, sorting and saved views stay
593
+ the table's.
594
+
595
+ ```tsx
596
+ <Board
597
+ aria-label="Projects by stage"
598
+ columns={STAGES.map((stage) => ({ value: stage, name: STAGE_NAMES[stage], label: <StageLabel value={stage} />, count: totals[stage] }))}
599
+ items={projects}
600
+ getId={(project) => project.id}
601
+ getColumn={(project) => project.stage}
602
+ getName={(project) => project.name}
603
+ renderCard={(project) => <ProjectCard project={project} />}
604
+ onMove={(project, stage) => saves.get(project.id)?.({ stage })}
605
+ moveTo={[...STAGE_TARGETS, { value: 'archived', label: 'Archived' }]}
606
+ isLoading={isLoading}
607
+ error={error}
608
+ onRetry={refetch}
609
+ emptyTitle="No projects yet"
610
+ />
611
+ ```
612
+
613
+ - **A card moves two ways:** dragged to another column, and its Move
614
+ button, a menu of `moveTo` (the columns by default) for the keyboard
615
+ and a phone, where there is no drag. A value with no column (Archived,
616
+ Dropped) is only in the menu, and takes the card off the board. After
617
+ a move from the menu the focus is back on the card's Move button in
618
+ its new column (on the board, if the card left it), and a status line
619
+ tells a screen reader "Moved <name> to <column>".
620
+ - **A column has a name in words** for the menu and that line: its
621
+ `label` when that is a string, else give `name` (a label with a glyph
622
+ in it is not plain text). The columns are headings, h2 by default;
623
+ `headingLevel` puts them under a section's heading.
624
+ - **Saving is the app's**, through `useRecordUpdate`, optimistic: cards
625
+ are placed by `getColumn` on the record, so a move shows at once and a
626
+ refusal puts the card back with the toast. Without `onMove` the board
627
+ is read-only, for a computed field.
628
+ - **A column is its own query** when a list can outgrow one page: give
629
+ it `count` (the total) and a `footer` linking the table filtered to it
630
+ when it holds more than it shows.
631
+ - **The card is short:** the title (a link to the record, cell pattern
632
+ 16) and three or four values as glyphs, the same cells the table uses.
633
+ An empty value is left out, not shown as a dash.
634
+ - It owns its states: skeleton columns, `ErrorState`, and an empty
635
+ board; an empty column is a muted line, not an empty state. The
636
+ columns share the width, at least 16rem each, and scroll sideways with
637
+ snap points; a phone shows one column at a time, with the edge of the
638
+ next one showing.
@@ -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
- | `app/push.py` | the reference backend | `send_push(session, user_id, title, body, url)`: encrypted (RFC 8291), signed (VAPID), to every device the user allowed |
17
- | `app/reminders.py`, `reminders` | the reference backend | A reminder row (when, which days, the text, the path), and the `send-reminders` job that sends each one when it's due |
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
- `backend/scripts/send_test_push.py <email>`.
85
+ `python -m foundation_backend.scripts.send_test_push <email>` (from `backend/`).
@@ -0,0 +1,50 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { AppError } from '../../api/contracts.js';
3
+ export type BoardColumn<V extends string> = {
4
+ /** The field's value this column holds. */
5
+ value: V;
6
+ /** The column's heading: a word, or a glyph and a word. */
7
+ label: ReactNode;
8
+ /**
9
+ * The column's name as plain text: its entry in the default Move menu and
10
+ * in what a screen reader hears after a move. Omitted: `label` when it is
11
+ * a string, else `value`.
12
+ */
13
+ name?: string;
14
+ /** The column's total, beside its heading. Omitted: the cards on the board are counted. */
15
+ count?: number;
16
+ /** The line an empty column shows. Default "Nothing here". */
17
+ empty?: ReactNode;
18
+ /** Under the cards: "Showing 100 of 240", a link to the whole list. */
19
+ footer?: ReactNode;
20
+ };
21
+ /** A value a card can be moved to from its Move menu. */
22
+ export type BoardMoveTarget<V extends string> = {
23
+ value: V;
24
+ label: string;
25
+ };
26
+ export type BoardProps<T, V extends string> = {
27
+ /** Names the board as a whole ("Tasks by status"). */
28
+ 'aria-label': string;
29
+ columns: readonly BoardColumn<V>[];
30
+ items: readonly T[];
31
+ getId: (item: T) => string | number;
32
+ /** The record's value of the field: which column its card is in. */
33
+ getColumn: (item: T) => V;
34
+ /** What the Move button calls the record: "Move <name>". */
35
+ getName: (item: T) => string;
36
+ renderCard: (item: T) => ReactNode;
37
+ /** Saves the record's new value. Omitted: a read-only board. */
38
+ onMove?: (item: T, value: V) => void;
39
+ /** The Move menu's choices. Default: the columns, by their `name`. */
40
+ moveTo?: readonly BoardMoveTarget<V>[];
41
+ /** The columns' heading level: 2 under the page's h1 (the default), deeper under a section. */
42
+ headingLevel?: 2 | 3 | 4 | 5 | 6;
43
+ isLoading?: boolean;
44
+ error?: AppError | null;
45
+ onRetry?: () => void;
46
+ emptyTitle?: ReactNode;
47
+ emptyDescription?: ReactNode;
48
+ emptyAction?: ReactNode;
49
+ };
50
+ export declare function Board<T, V extends string>({ 'aria-label': ariaLabel, columns, items, getId, getColumn, getName, renderCard, onMove, moveTo, headingLevel, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, }: BoardProps<T, V>): import("react").JSX.Element;
@@ -0,0 +1,131 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A board: a column per value of one choice field (a status, a stage), a
3
+ // card per record, and a record moved by moving its card. A second way to
4
+ // look at a list, beside its DataTable, never instead of it: filters, sort
5
+ // and saved views stay the table's.
6
+ //
7
+ // - Columns: `columns`, in order, each a `value` with its `label` (a stage
8
+ // circle and a word, say), an optional `count` (the column's total,
9
+ // which may be more than the cards loaded), an `empty` line, and a
10
+ // `footer` ("Showing 100 of 240", a link to the filtered list). A
11
+ // column whose `label` isn't plain text gives its `name` for the Move
12
+ // menu and what a screen reader hears after a move.
13
+ // - Cards: every record in `items` whose `getColumn` is one of the
14
+ // columns' values, in `items`' order; a record whose value has no column
15
+ // isn't shown. `renderCard` is the card's content: the title (a link to
16
+ // the record's page) and a few values.
17
+ // - Moving: with `onMove`, a card drags to another column (a pointer) and
18
+ // has a Move button (a menu of `moveTo`, by default the columns: the
19
+ // keyboard and a phone, where there is no drag). `moveTo` may add values
20
+ // with no column, such as Dropped, which take the card off the board.
21
+ // After a move from the menu the focus goes back to the card's Move
22
+ // button in its new column (to the board, if the card left it), and a
23
+ // status line says where the card went.
24
+ // Without `onMove` the board is read-only: a computed field.
25
+ // `onMove(item, value)` is the save; the app makes it optimistic
26
+ // (useRecordUpdate), so placing cards by `getColumn` on the record shows
27
+ // a move at once, and a refusal puts the card back.
28
+ // - States: `isLoading` (skeleton columns), `error` (ErrorState with
29
+ // `onRetry`), and an empty board (`emptyTitle`, `emptyDescription`,
30
+ // `emptyAction`), each for the board as a whole; an empty column is not
31
+ // an empty state, only a muted line.
32
+ //
33
+ // Columns share the width, at least 16rem each, and scroll sideways with
34
+ // snap points when they don't fit. A phone shows one column at a time,
35
+ // with the edge of the next one showing.
36
+ import { useEffect, useId, useRef, useState } from 'react';
37
+ import { ArrowRightLeftIcon } from 'lucide-react';
38
+ import { cn } from 'cn';
39
+ import { ErrorState } from './error-state.js';
40
+ import { Button } from '../ui/button.js';
41
+ import { Card } from '../ui/card.js';
42
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '../ui/dropdown-menu.js';
43
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
44
+ import { Skeleton } from '../ui/skeleton.js';
45
+ // The drag's data type: a card of this board, and nothing a drop from
46
+ // elsewhere (a file, a link) carries.
47
+ const DRAG_TYPE = 'application/x-ui-foundation-board-card';
48
+ // At least 16rem, sharing the width; on a phone the board's width less 2rem,
49
+ // so the next column's edge shows there is more to scroll to.
50
+ const COLUMN_WIDTH = 'min-w-[calc(100%-2rem)] flex-1 basis-0 sm:min-w-64';
51
+ const columnName = (column) => column.name ?? (typeof column.label === 'string' ? column.label : column.value);
52
+ export function Board({ 'aria-label': ariaLabel, columns, items, getId, getColumn, getName, renderCard, onMove, moveTo, headingLevel = 2, isLoading = false, error = null, onRetry, emptyTitle = 'Nothing here yet', emptyDescription, emptyAction, }) {
53
+ const boardRef = useRef(null);
54
+ const [announcement, setAnnouncement] = useState('');
55
+ // A move from the Move menu, until its card is in its new column or off
56
+ // the board. The card is remounted there, so the Move button that had
57
+ // the focus is gone: its new one takes the focus, or the board does.
58
+ const moved = useRef(null);
59
+ useEffect(() => {
60
+ const pending = moved.current;
61
+ if (pending === null)
62
+ return;
63
+ const item = items.find((candidate) => String(getId(candidate)) === pending.id);
64
+ if (item !== undefined && getColumn(item) !== pending.value)
65
+ return;
66
+ moved.current = null;
67
+ const board = boardRef.current;
68
+ // Only when the focus was lost with the old button, never taken from
69
+ // wherever the user has gone since.
70
+ if (board === null || (document.activeElement !== null && document.activeElement !== document.body))
71
+ return;
72
+ const button = Array.from(board.querySelectorAll('[data-board-move]')).find((candidate) => candidate.dataset.boardMove === pending.id);
73
+ (button ?? board).focus();
74
+ }, [items, getId, getColumn]);
75
+ if (error) {
76
+ return (_jsx("div", { "data-slot": "board", "data-state": "error", children: _jsx(ErrorState, { error: error, onRetry: onRetry }) }));
77
+ }
78
+ if (isLoading) {
79
+ return (_jsx("div", { "data-slot": "board", "data-state": "loading", role: "group", "aria-busy": "true", "aria-label": ariaLabel, className: "flex gap-3 overflow-x-auto pb-2", children: columns.map((column) => (_jsxs("div", { className: cn('flex flex-col gap-2 rounded-lg bg-muted p-2', COLUMN_WIDTH), children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsx(Skeleton, { className: "h-20 w-full" }), _jsx(Skeleton, { className: "h-20 w-full" })] }, column.value))) }));
80
+ }
81
+ const shown = items.filter((item) => columns.some((column) => column.value === getColumn(item)));
82
+ if (shown.length === 0) {
83
+ return (_jsx("div", { "data-slot": "board", "data-state": "empty", children: _jsxs(Empty, { children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction && _jsx(EmptyContent, { children: emptyAction })] }) }));
84
+ }
85
+ const targets = moveTo ?? columns.map((column) => ({ value: column.value, label: columnName(column) }));
86
+ const move = (id, value, from) => {
87
+ const item = items.find((candidate) => String(getId(candidate)) === id);
88
+ if (onMove === undefined || item === undefined || getColumn(item) === value)
89
+ return;
90
+ const column = columns.find((candidate) => candidate.value === value);
91
+ const where = targets.find((target) => target.value === value)?.label ?? (column ? columnName(column) : value);
92
+ setAnnouncement(`Moved ${getName(item)} to ${where}`);
93
+ if (from === 'menu')
94
+ moved.current = { id, value };
95
+ onMove(item, value);
96
+ };
97
+ return (_jsxs("div", { ref: boardRef, "data-slot": "board", "data-state": "success", role: "group", "aria-label": ariaLabel, tabIndex: -1, className: "flex snap-x snap-mandatory gap-3 overflow-x-auto pb-2 outline-none", children: [columns.map((column) => {
98
+ const cards = shown.filter((item) => getColumn(item) === column.value);
99
+ return (_jsxs(BoardColumnView, { column: column, count: column.count ?? cards.length, headingLevel: headingLevel, onDrop: onMove ? (id) => move(id, column.value, 'drag') : undefined, children: [cards.map((item) => (_jsx(Card, { size: "sm", "data-slot": "board-card", draggable: onMove ? true : undefined, onDragStart: onMove
100
+ ? (event) => {
101
+ event.dataTransfer.setData(DRAG_TYPE, String(getId(item)));
102
+ event.dataTransfer.effectAllowed = 'move';
103
+ }
104
+ : undefined, className: cn('gap-2 px-3 py-3', onMove && 'cursor-grab active:cursor-grabbing'), children: _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: renderCard(item) }), onMove && (_jsx(MoveMenu, { id: String(getId(item)), name: getName(item), value: getColumn(item), targets: targets, onMove: (value) => move(String(getId(item)), value, 'menu') }))] }) }, getId(item)))), cards.length === 0 && (_jsx("p", { className: "px-1 py-6 text-center type-caption text-muted-foreground", children: column.empty ?? 'Nothing here' })), column.footer && _jsx("div", { className: "px-1 type-caption text-muted-foreground", children: column.footer })] }, column.value));
105
+ }), _jsx("span", { role: "status", className: "sr-only", children: announcement })] }));
106
+ }
107
+ function BoardColumnView({ column, count, headingLevel, onDrop, children, }) {
108
+ const [over, setOver] = useState(false);
109
+ const headingId = useId();
110
+ const Heading = `h${headingLevel}`;
111
+ const accepts = (event) => onDrop !== undefined && event.dataTransfer.types.includes(DRAG_TYPE);
112
+ return (_jsxs("section", { "aria-labelledby": headingId, "data-slot": "board-column", "data-over": over || undefined, className: cn('flex snap-start flex-col gap-2 rounded-lg bg-muted p-2 outline-2 outline-offset-2 outline-transparent data-over:outline-ring', COLUMN_WIDTH), onDragOver: (event) => {
113
+ if (!accepts(event))
114
+ return;
115
+ event.preventDefault();
116
+ event.dataTransfer.dropEffect = 'move';
117
+ setOver(true);
118
+ }, onDragLeave: (event) => {
119
+ if (!event.currentTarget.contains(event.relatedTarget))
120
+ setOver(false);
121
+ }, onDrop: (event) => {
122
+ setOver(false);
123
+ if (!accepts(event))
124
+ return;
125
+ event.preventDefault();
126
+ onDrop?.(event.dataTransfer.getData(DRAG_TYPE));
127
+ }, children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 px-1 pt-0.5 type-label", children: [column.label, _jsx("span", { className: "font-normal tabular-nums text-muted-foreground", children: count })] }), children] }));
128
+ }
129
+ function MoveMenu({ id, name, value, targets, onMove, }) {
130
+ return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: _jsx(Button, { variant: "ghost", size: "icon-xs", "aria-label": `Move ${name}`, title: "Move to\u2026", "data-board-move": id }), children: _jsx(ArrowRightLeftIcon, { "aria-hidden": "true" }) }), _jsx(DropdownMenuContent, { align: "end", className: "w-44", children: _jsxs(DropdownMenuGroup, { children: [_jsx(DropdownMenuLabel, { children: "Move to" }), _jsx(DropdownMenuRadioGroup, { value: value, onValueChange: (next) => onMove(next), children: targets.map((target) => (_jsx(DropdownMenuRadioItem, { value: target.value, closeOnClick: true, children: target.label }, target.value))) })] }) })] }));
131
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
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
+ export { Board, type BoardColumn, type BoardMoveTarget, type BoardProps } from './components/app/board.js';
3
4
  export { CountLink, type CountLinkProps } from './components/app/count-link.js';
4
5
  export { DataTable, type DataTableProps, type DataTableTotals, type DataTableWidth, type SortingState, } from './components/app/data-table.js';
5
6
  export { type RowGroup } from './components/app/table-groups.js';
package/dist/index.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // points (`/gateway`, `/mocks`, `/testing`) so each stays behind its boundary.
5
5
  // Composites
6
6
  export { AppShell, } from './components/app/app-shell.js';
7
+ export { Board } from './components/app/board.js';
7
8
  export { CountLink } from './components/app/count-link.js';
8
9
  export { DataTable, } from './components/app/data-table.js';
9
10
  export {} from './components/app/table-groups.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.34.0",
3
+ "version": "4.36.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": {