@tristan2828/ui-foundation 4.35.0 → 4.37.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–463
16
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 465–523
17
+ - [Notices](#notices) · lines 525–553
18
+ - [A value over time](#a-value-over-time) · lines 555–588
19
+ - [A list as a board](#a-list-as-a-board) · lines 590–641
19
20
 
20
21
  <!-- /index -->
21
22
 
@@ -423,7 +424,10 @@ const { data, isLoading, error, refetch } = useReadyTasksQuery() // one page of
423
424
  - **Three to six rows.** A widget is a sample, not the list: ask the API
424
425
  for that many (the query's page size) and put the rest behind the
425
426
  header `link` ("All", "All doing"), which goes to the full list
426
- filtered the same way. Set `skeletonRows` to the number it usually holds, so the card
427
+ filtered the same way. A widget about a service outside the app links
428
+ its header there instead: `link={{ href: 'https://…', label: 'Open
429
+ <service>' }}` opens it in a new tab, with the external-link mark and
430
+ "(opens in a new tab)" for a screen reader. Set `skeletonRows` to the number it usually holds, so the card
427
431
  keeps its height when the data arrives.
428
432
  - **Each widget owns its four states.** Loading is a skeleton of rows,
429
433
  empty is one quiet line the app writes ("Nothing waiting", not "No
@@ -582,3 +586,56 @@ or `'last'`) with its value.
582
586
  `formatX` the page uses for those values elsewhere.
583
587
  - One series and a few dozen points. Several series, zoom or tooltips
584
588
  are a request for the foundation, not a hand-drawn chart in the app.
589
+
590
+ ## A list as a board
591
+
592
+ `Board` (4.36) shows a list as a column per value of one choice field (a
593
+ status, a stage), a card per record, and moves a record by moving its
594
+ card. It is a second view of a list, on its own page beside the
595
+ `DataTable`, never a replacement: filters, sorting and saved views stay
596
+ the table's.
597
+
598
+ ```tsx
599
+ <Board
600
+ aria-label="Projects by stage"
601
+ columns={STAGES.map((stage) => ({ value: stage, name: STAGE_NAMES[stage], label: <StageLabel value={stage} />, count: totals[stage] }))}
602
+ items={projects}
603
+ getId={(project) => project.id}
604
+ getColumn={(project) => project.stage}
605
+ getName={(project) => project.name}
606
+ renderCard={(project) => <ProjectCard project={project} />}
607
+ onMove={(project, stage) => saves.get(project.id)?.({ stage })}
608
+ moveTo={[...STAGE_TARGETS, { value: 'archived', label: 'Archived' }]}
609
+ isLoading={isLoading}
610
+ error={error}
611
+ onRetry={refetch}
612
+ emptyTitle="No projects yet"
613
+ />
614
+ ```
615
+
616
+ - **A card moves two ways:** dragged to another column, and its Move
617
+ button, a menu of `moveTo` (the columns by default) for the keyboard
618
+ and a phone, where there is no drag. A value with no column (Archived,
619
+ Dropped) is only in the menu, and takes the card off the board. After
620
+ a move from the menu the focus is back on the card's Move button in
621
+ its new column (on the board, if the card left it), and a status line
622
+ tells a screen reader "Moved <name> to <column>".
623
+ - **A column has a name in words** for the menu and that line: its
624
+ `label` when that is a string, else give `name` (a label with a glyph
625
+ in it is not plain text). The columns are headings, h2 by default;
626
+ `headingLevel` puts them under a section's heading.
627
+ - **Saving is the app's**, through `useRecordUpdate`, optimistic: cards
628
+ are placed by `getColumn` on the record, so a move shows at once and a
629
+ refusal puts the card back with the toast. Without `onMove` the board
630
+ is read-only, for a computed field.
631
+ - **A column is its own query** when a list can outgrow one page: give
632
+ it `count` (the total) and a `footer` linking the table filtered to it
633
+ when it holds more than it shows.
634
+ - **The card is short:** the title (a link to the record, cell pattern
635
+ 16) and three or four values as glyphs, the same cells the table uses.
636
+ An empty value is left out, not shown as a dash.
637
+ - It owns its states: skeleton columns, `ErrorState`, and an empty
638
+ board; an empty column is a muted line, not an empty state. The
639
+ columns share the width, at least 16rem each, and scroll sideways with
640
+ snap points; a phone shows one column at a time, with the edge of the
641
+ next one showing.
@@ -68,6 +68,18 @@ Scope it to the signed-in user, as Widgets are.
68
68
  sends a reminder when today is one of its days in its time zone and the
69
69
  time has come, once a day at most. More than 15 minutes late (the
70
70
  backend was down) and it's skipped for the day rather than sent late.
71
+ - **Reminders in a table of the app's own** (another schema, another
72
+ name, as an app with a schema per module keeps them): define its table
73
+ model with the same columns (`id`, `user_id`, `title`, `body`, `url`,
74
+ `time_of_day`, `days`, `time_zone`, `enabled`, `last_sent_on`) and hand
75
+ it to the job, `reminders.register(model=MyReminder)` in place of
76
+ `reminders.register()`. Left out, the job reads the package's
77
+ `reminders` table. Neither table is on `SQLModel.metadata` until the
78
+ app uses it: `push_subscriptions` arrives with `foundation_backend.push`
79
+ (and `foundation_backend.reminders`, which sends through it), the
80
+ package's `reminders` only with its `Reminder` model (imported, or the
81
+ job run without a model). So an app without push, or with its own
82
+ table, gets neither from `create_all` or autogenerate.
71
83
  - **Something else to send?** Call `send_push` from the app's own code or
72
84
  job: a message when a long task finishes, a daily summary. The `url` is
73
85
  always the app's own path.
@@ -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
+ }
@@ -14,14 +14,12 @@ export interface WidgetCardProps {
14
14
  headingLevel?: 2 | 3 | 4 | 5 | 6;
15
15
  /**
16
16
  * A link on the right of the header to everything the widget samples:
17
- * `{ to: '/tasks?status=doing', label: 'All doing' }`. Its accessible
18
- * name is `label`, described by the title, so several "All" links on one
19
- * page stay distinct.
17
+ * `{ to: '/tasks?status=doing', label: 'All doing' }`, or an outside
18
+ * address, opened in a new tab: `{ href: 'https://example.com', label:
19
+ * 'Open Example' }`. Its accessible name is `label`, described by the
20
+ * title, so several "All" links on one page stay distinct.
20
21
  */
21
- link?: {
22
- to: To;
23
- label: string;
24
- };
22
+ link?: WidgetCardLink;
25
23
  isLoading: boolean;
26
24
  error?: AppError | null;
27
25
  onRetry?: () => void;
@@ -44,6 +42,15 @@ export interface WidgetCardProps {
44
42
  children?: ReactNode;
45
43
  className?: string;
46
44
  }
45
+ export type WidgetCardLink = {
46
+ to: To;
47
+ label: string;
48
+ href?: never;
49
+ } | {
50
+ href: string;
51
+ label: string;
52
+ to?: never;
53
+ };
47
54
  export declare function WidgetCard({ title, icon: Icon, headingLevel, link, isLoading, error, onRetry, isEmpty, emptyMessage, skeletonRows, collapsible, defaultCollapsed, storageKey, children, className, }: WidgetCardProps): import("react").JSX.Element;
48
55
  /** The rows of a WidgetCard: a list with a divider between rows. */
49
56
  export declare function WidgetList({ children, className }: {
@@ -24,6 +24,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
24
24
  // where that's blocked). The body is hidden, not unmounted, so whatever
25
25
  // the app keeps in it stays put.
26
26
  //
27
+ // The header's link is an app route (`to`) or, for a widget about a
28
+ // service outside the app, its address (`href`, #248): that one opens in a
29
+ // new tab, with the outside-link mark and a screen reader's "(opens in a
30
+ // new tab)", as WidgetListItem's `href` does.
31
+ //
27
32
  // WidgetList and WidgetListItem are the common body: rows with dividers,
28
33
  // each a primary link (or plain text) truncated to one line, an optional
29
34
  // muted note ("2 h ago") and an optional trailing element (a badge, a
@@ -52,7 +57,7 @@ export function WidgetCard({ title, icon: Icon, headingLevel = 2, link, isLoadin
52
57
  const state = error ? 'error' : isLoading ? 'loading' : isEmpty ? 'empty' : 'success';
53
58
  const [folded, setFolded] = useRememberedFlag(collapsible && storageKey !== undefined ? `${storageKey}:collapsed` : undefined, defaultCollapsed);
54
59
  const collapsed = collapsible && folded;
55
- return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label })), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
60
+ return (_jsx("section", { "aria-labelledby": headingId, "data-slot": "widget-card", "data-state": state, "data-collapsed": collapsed || undefined, className: cn('min-w-0 self-start', className), children: _jsxs(Card, { className: "density-compact:[--card-spacing:--spacing(3)]", children: [_jsxs(CardHeader, { className: "flex items-center justify-between gap-3", children: [_jsxs(Heading, { id: headingId, className: "flex min-w-0 items-center gap-2 type-section-title text-foreground", children: [Icon && _jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }), _jsx("span", { className: "truncate", children: title })] }), (link || collapsible) && (_jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [link?.href !== undefined ? (_jsxs("a", { href: link.href, target: "_blank", rel: "noreferrer", "aria-describedby": headingId, className: cn(HEADER_LINK_CLASS, 'inline-flex items-center gap-1'), children: [link.label, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "size-3 shrink-0 text-muted-foreground" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] })) : (link && (_jsx(Link, { to: link.to, "aria-describedby": headingId, className: HEADER_LINK_CLASS, children: link.label }))), collapsible && (_jsx(Button, { variant: "ghost", size: "icon-sm", "aria-labelledby": headingId, "aria-expanded": !collapsed, "aria-controls": contentId, onClick: () => setFolded(!collapsed),
56
61
  // The button's own open look (a filled background) would
57
62
  // read as pressed: the chevron says it.
58
63
  className: "-my-1 -mr-1.5 aria-expanded:not-hover:bg-transparent", children: _jsx(ChevronDownIcon, { "aria-hidden": "true", className: cn('transition-transform', collapsed && '-rotate-90') }) }))] }))] }), _jsx(CardContent, { id: contentId, hidden: collapsed, "aria-busy": state === 'loading' || undefined, children: error ? (_jsx(ErrorState, { error: error, onRetry: onRetry, size: "compact" })) : state === 'loading' ? (_jsx(WidgetListSkeleton, { rows: skeletonRows })) : state === 'empty' ? (_jsx("p", { "data-state": "empty", className: "py-2 type-body text-muted-foreground", children: emptyMessage })) : (children) })] }) }));
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';
@@ -39,7 +40,7 @@ export { DEFAULT_IMAGE_TYPES, type ImageUpload, type ImageUploadResult } from '.
39
40
  export { formatPlainDate, type PlainDate } from './lib/plain-date.js';
40
41
  export { ChoiceCell, LinkCell, NameCell, type ChoiceCellProps, type LinkCellProps, type NameCellProps, } from './components/app/row-edit-cells.js';
41
42
  export { ModuleCard, ModuleStrip, type CategorySlot, type ModuleCardLink, type ModuleCardPlace, type ModuleCardProps, type ModuleStripProps, } from './components/app/module-card.js';
42
- export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
43
+ export { WidgetCard, WidgetList, WidgetListItem, type WidgetCardLink, type WidgetCardProps, type WidgetListItemProps, } from './components/app/widget-card.js';
43
44
  export { ThemeProvider } from './components/theme-provider.js';
44
45
  export { LoginRoute, type LoginRouteProps } from './routes/login.js';
45
46
  export { RegisterRoute } from './routes/register.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.35.0",
3
+ "version": "4.37.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": {