@gemboss/ui 0.12.0 → 0.21.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.
@@ -0,0 +1,543 @@
1
+ /**
2
+ * Collections (0.13.0): five of the six patterns Chris settled on 05/10/2026, as thin components over the classes of
3
+ * `@gemboss/ui/collections.css` (load it beside gemboss-ui.css). The sixth, `IndexTable`, keeps selection state and is
4
+ * in ./index-table (a client module). README "Collections" says which pattern to use when.
5
+ *
6
+ * DataTable compare values across items, column by column (reports, logs). Polaris DataTable.
7
+ * ResourceList read item by item: small media, a title, one or two lines, badges, one action. Polaris ResourceList.
8
+ * CompactList one line per item (a side rail, a popup, "needs attention"); `variant="activity"` is the timeline.
9
+ * CardGrid the picture is the content; auto-fill at 200 / 240 / 320, 16 apart. With MediaCard.
10
+ * Board items moving through fixed states; 12 cards a column, then "Show 24 more".
11
+ * CollectionState / CollectionSkeleton empty, no match, error, loading (in the shape of the pattern).
12
+ *
13
+ * A plain module: no hooks, no "use client", so a server component renders every one of these with any props (render
14
+ * functions included), and nothing the admin imports pulls it into its first load (the admin draws tables with
15
+ * Polaris; same reasoning as ./page). The markup is the same as the plain-HTML contract in the catalog, so a surface
16
+ * without React writes these classes by hand and gets the same thing.
17
+ */
18
+ import React from "react";
19
+ import { attachBoardMove } from "./board-move";
20
+
21
+ const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
22
+
23
+ export type SortDirection = "ascending" | "descending";
24
+ export type CollectionSort = { key: string; direction: SortDirection };
25
+
26
+ /** A column of a DataTable or an IndexTable. Every field is plain data, so a server component can build it. */
27
+ export type CollectionColumn = {
28
+ key: string;
29
+ header: React.ReactNode;
30
+ /** Right-aligned tabular figures, header included (`is-num`). */
31
+ numeric?: boolean;
32
+ /** Where clicking the header goes to sort by this column (the order lives in the URL). */
33
+ sortHref?: string;
34
+ /** Sortable through `onSort` (a client parent) instead of a link. */
35
+ sortable?: boolean;
36
+ /** IndexTable below 768: "hide" leaves the cell out of the folded row; default shows it on the second line. */
37
+ condensed?: "sub" | "hide";
38
+ /** IndexTable below 768: the words in front of the value on the folded line ("Score"). */
39
+ label?: string;
40
+ /** Cap the cell at this width with an ellipsis (the whole text belongs in the cell's `title`). */
41
+ truncate?: number | string;
42
+ /** DataTable (0.16.0): the column is never narrower than this, for cells that wrap (a title + a sub line, a note), so
43
+ * a narrow frame scrolls instead of squeezing them to a word a line. Polaris `firstColumnMinWidth`, for any column.
44
+ * A row header's column has a floor of its own (9.0625rem, Polaris's first column) that this replaces: `0` removes
45
+ * it for a row header that is always short (a date, an id). */
46
+ minWidth?: number | string;
47
+ };
48
+
49
+ export type CollectionRow = { id: string; cells: Record<string, React.ReactNode> };
50
+
51
+ /** `aria-sort` for a column header: only on a sortable column; "none" when it is not the current sort. */
52
+ export function ariaSort(col: CollectionColumn, sort?: CollectionSort | null): React.AriaAttributes["aria-sort"] {
53
+ if (!col.sortHref && !col.sortable) return undefined;
54
+ return sort && sort.key === col.key ? sort.direction : "none";
55
+ }
56
+
57
+ /** Extra props for every sort link (0.16.0), passed to `linkAs`: `{ scroll: false }` for a Next `Link` keeps the page
58
+ * where it is, since a sort is the same page in another order. Plain data, so a server component can pass it. */
59
+ export type SortLinkProps = Record<string, unknown>;
60
+
61
+ /** The header cell's contents: the label, as a link (`sortHref`) or a button (`onSort`) when the column sorts. The link
62
+ * is `linkAs` (0.16.0; default `<a>`), the same element as the rows' titles: a router link sorts without reloading the page. */
63
+ export function SortHeader({ col, sort, onSort, linkAs: Link = "a", linkProps }: { col: CollectionColumn; sort?: CollectionSort | null; onSort?: (key: string, direction: SortDirection) => void;
64
+ linkAs?: React.ElementType; linkProps?: SortLinkProps }) {
65
+ const next: SortDirection = sort && sort.key === col.key && sort.direction === "ascending" ? "descending" : "ascending";
66
+ if (col.sortHref) return <Link {...linkProps} className="ui-table__sort" href={col.sortHref}>{col.header}</Link>;
67
+ if (col.sortable && onSort) return <button type="button" className="ui-table__sort" onClick={() => onSort(col.key, next)}>{col.header}</button>;
68
+ return <>{col.header}</>;
69
+ }
70
+
71
+ const truncStyle = (t: CollectionColumn["truncate"]): React.CSSProperties | undefined =>
72
+ t == null ? undefined : ({ ["--ui-trunc" as string]: typeof t === "number" ? `${t}px` : t } as React.CSSProperties);
73
+ const minStyle = (w: CollectionColumn["minWidth"]): React.CSSProperties | undefined => (w == null ? undefined : { minWidth: w });
74
+
75
+ export function Truncate({ children, title, width }: { children: React.ReactNode; title?: string; width?: number | string }) {
76
+ return <span className="ui-trunc" title={title} style={truncStyle(width)}>{children}</span>;
77
+ }
78
+
79
+ // ── 1 · DataTable ──────────────────────────────────────────────────────────────────────────────────────────────────
80
+
81
+ export type DataTableProps = {
82
+ columns: CollectionColumn[];
83
+ rows: CollectionRow[];
84
+ /** Names the table for a screen reader (a visually hidden caption). */
85
+ label: string;
86
+ sort?: CollectionSort | null;
87
+ onSort?: (key: string, direction: SortDirection) => void;
88
+ /** The link element for the sort links (`sortHref`): a Next `Link` from a client module, so a sort does not reload the
89
+ * page (0.16.0). Default `<a>`. */
90
+ linkAs?: React.ElementType;
91
+ /** Extra props for those links, e.g. `{ scroll: false }` for a Next `Link` (0.16.0). */
92
+ sortLinkProps?: SortLinkProps;
93
+ /** The first column stays put while the table scrolls sideways (what a data table does below 768). */
94
+ stickyFirstColumn?: boolean;
95
+ /** Rows 32 instead of 36: only for more than 20 rows. */
96
+ density?: "default" | "compact";
97
+ /** The first cell of every row is its header (`th scope="row"`). Default true: a data table's first column names the row. */
98
+ rowHeaders?: boolean;
99
+ totals?: Record<string, React.ReactNode>;
100
+ footer?: React.ReactNode;
101
+ /** loading → the skeleton in this table's shape; busy → the rows stay, dimmed; empty / no-match / error → `stateSlot`. */
102
+ state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
103
+ /** What to show for empty / no-match / error: a `CollectionState`. The header stays above it. */
104
+ stateSlot?: React.ReactNode;
105
+ className?: string;
106
+ };
107
+
108
+ export function DataTable({ columns, rows, label, sort, onSort, linkAs, sortLinkProps, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
109
+ if (state === "loading") return <CollectionSkeleton pattern="table" columns={columns} label={label} />;
110
+ const showRows = state === "ready" || state === "busy";
111
+ return (
112
+ <div className={cx("ui-table-wrap", className)} aria-busy={state === "busy" ? true : undefined}>
113
+ <table className={cx("ui-table", stickyFirstColumn && "ui-table--sticky-first", density === "compact" && "ui-table--compact")}>
114
+ <caption className="ui-sr-only">{label}</caption>
115
+ <thead>
116
+ <tr>{columns.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} style={minStyle(c.minWidth)} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} linkAs={linkAs} linkProps={sortLinkProps} /></th>)}</tr>
117
+ </thead>
118
+ {showRows ? (
119
+ <tbody>
120
+ {rows.map((r) => (
121
+ <tr key={r.id}>
122
+ {columns.map((c, i) => {
123
+ const body = c.truncate != null ? <span className="ui-trunc" style={truncStyle(c.truncate)}>{r.cells[c.key]}</span> : r.cells[c.key];
124
+ return i === 0 && rowHeaders
125
+ ? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"} style={minStyle(c.minWidth)}>{body}</th>
126
+ : <td key={c.key} className={c.numeric ? "is-num" : undefined}>{body}</td>;
127
+ })}
128
+ </tr>
129
+ ))}
130
+ </tbody>
131
+ ) : null}
132
+ {totals && showRows ? (
133
+ <tfoot className="ui-table__totals"><tr>{columns.map((c) => <td key={c.key} className={c.numeric ? "is-num" : undefined}>{totals[c.key]}</td>)}</tr></tfoot>
134
+ ) : null}
135
+ </table>
136
+ {!showRows ? stateSlot : null}
137
+ {footer && showRows ? <div className="ui-table__footer">{footer}</div> : null}
138
+ </div>
139
+ );
140
+ }
141
+
142
+ // ── 3 · ResourceList / ResourceItem ────────────────────────────────────────────────────────────────────────────────
143
+
144
+ export type ResourceItemProps = {
145
+ title: React.ReactNode;
146
+ /** The detail page. The WHOLE row then opens it; the title stays a real link. */
147
+ href?: string;
148
+ /** The link element: a Next `Link`, a router link. Default `<a>`. */
149
+ linkAs?: React.ElementType;
150
+ /** The full title, when it may be cut by the ellipsis. */
151
+ titleText?: string;
152
+ media?: React.ReactNode;
153
+ subtitle?: React.ReactNode;
154
+ meta?: React.ReactNode;
155
+ /** At most three are shown; the rest become "+N". */
156
+ badges?: React.ReactNode[];
157
+ aside?: React.ReactNode;
158
+ /** One button and/or a ⋯ `Menu`. Never more than one visible button. */
159
+ actions?: React.ReactNode;
160
+ selected?: boolean;
161
+ id?: string;
162
+ /** The selection checkbox, first in the media slot (as Polaris ResourceItem's). `SelectableResourceList` fills it. */
163
+ check?: React.ReactNode;
164
+ };
165
+
166
+ export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id, check }: ResourceItemProps) {
167
+ const shown = (badges || []).slice(0, 3), more = (badges || []).length - shown.length;
168
+ return (
169
+ // Not aria-selected: a listitem does not take it (jsx-a11y role-supports-aria-props). With a checkbox the box says
170
+ // it; without one, `selected` marks the item open beside the list (master-detail): aria-current.
171
+ <li className={cx("ui-list__item", selected && "is-selected")} id={id} aria-current={selected && !check ? "true" : undefined}>
172
+ {media || check ? <div className="ui-list__media">{check}{media}</div> : null}
173
+ <div className="ui-list__body">
174
+ {href ? <Link className="ui-list__title" href={href} title={titleText}>{title}</Link> : <span className="ui-list__title" title={titleText}>{title}</span>}
175
+ {subtitle ? <p className="ui-list__sub">{subtitle}</p> : null}
176
+ {meta ? <p className="ui-list__meta">{meta}</p> : null}
177
+ {shown.length ? <div className="ui-list__badges">{shown.map((b, i) => <React.Fragment key={i}>{b}</React.Fragment>)}{more > 0 ? <span className="ui-badge">+{more}</span> : null}</div> : null}
178
+ </div>
179
+ {aside != null ? <div className="ui-list__aside">{aside}</div> : null}
180
+ {actions ? <div className="ui-list__actions">{actions}</div> : null}
181
+ </li>
182
+ );
183
+ }
184
+
185
+ export type ResourceListProps = {
186
+ label: string;
187
+ children?: React.ReactNode;
188
+ /** Inside a card that is already the frame: no border, no shadow. */
189
+ flush?: boolean;
190
+ state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
191
+ stateSlot?: React.ReactNode;
192
+ /** How many placeholder rows while loading (the number expected, at most 10). */
193
+ loadingRows?: number;
194
+ /** The media shape of the loading rows. */
195
+ loadingMedia?: "avatar" | "thumbnail" | "none";
196
+ className?: string;
197
+ };
198
+
199
+ export function ResourceList({ label, children, flush, state = "ready", stateSlot, loadingRows = 5, loadingMedia = "thumbnail", className }: ResourceListProps) {
200
+ if (state === "loading") return <CollectionSkeleton pattern="list" rows={loadingRows} media={loadingMedia} label={label} flush={flush} />;
201
+ if (state === "empty" || state === "no-match" || state === "error") {
202
+ return <div className={cx("ui-list", flush && "ui-list--flush", className)} role="region" aria-label={label}>{stateSlot}</div>;
203
+ }
204
+ return <ul className={cx("ui-list", flush && "ui-list--flush", className)} aria-label={label} aria-busy={state === "busy" ? true : undefined}>{children}</ul>;
205
+ }
206
+
207
+ // ── 4 · CompactList ────────────────────────────────────────────────────────────────────────────────────────────────
208
+
209
+ export type CompactItem = {
210
+ id: string;
211
+ label: React.ReactNode;
212
+ href?: string;
213
+ onClick?: () => void;
214
+ icon?: React.ReactNode;
215
+ meta?: React.ReactNode;
216
+ current?: boolean;
217
+ /** The full label as text, for `title` when the label can be cut (not under `wrap`, where it is all on screen). */
218
+ labelText?: string;
219
+ /** activity: the line under the label, and the dot's tone. */
220
+ detail?: React.ReactNode;
221
+ /** activity (0.15.0): the event's own text under the label and the detail (a summary, a note, a post): body size,
222
+ * wraps, never cut (`.ui-list__content`). Paragraphs as `<p>`. Not drawn by the one-line variant. */
223
+ body?: React.ReactNode;
224
+ time?: React.ReactNode;
225
+ dateTime?: string;
226
+ tone?: "success" | "warning" | "critical" | "info";
227
+ };
228
+
229
+ export type CompactListProps = {
230
+ items: CompactItem[];
231
+ label: string;
232
+ variant?: "activity";
233
+ linkAs?: React.ElementType;
234
+ flush?: boolean;
235
+ /** 0.15.0: the label (and an activity's detail) wrap instead of ending in "…" (`ui-list--wrap`). For a list whose
236
+ * label carries data a finger must be able to read (a company, a colleague, who wrote it): `title` is mouse-only. */
237
+ wrap?: boolean;
238
+ className?: string;
239
+ };
240
+
241
+ export function CompactList({ items, label, variant, linkAs: Link = "a", flush, wrap, className }: CompactListProps) {
242
+ const activity = variant === "activity";
243
+ return (
244
+ <ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", wrap && "ui-list--wrap", flush && "ui-list--flush", className)} aria-label={label}>
245
+ {items.map((it) => activity ? (
246
+ <li key={it.id} className="ui-list__item">
247
+ <span className={cx("ui-list__dot", it.tone && `ui-list__dot--${it.tone}`)} aria-hidden="true" />
248
+ <div>
249
+ {it.href ? <Link className="ui-list__label" href={it.href} title={wrap ? undefined : it.labelText}>{it.label}</Link> : <span className="ui-list__label" title={wrap ? undefined : it.labelText}>{it.label}</span>}
250
+ {it.detail ? <span className="ui-list__detail">{it.detail}</span> : null}
251
+ {it.body ? <div className="ui-list__content">{it.body}</div> : null}
252
+ </div>
253
+ {it.time ? <time className="ui-list__time" dateTime={it.dateTime}>{it.time}</time> : <span />}
254
+ </li>
255
+ ) : (
256
+ <li key={it.id} className="ui-list__item">
257
+ {React.createElement(it.href ? Link : it.onClick ? "button" : "div", {
258
+ className: cx("ui-list__row", it.current && "is-selected"),
259
+ ...(it.href ? { href: it.href, "aria-current": it.current ? "page" : undefined } : it.onClick ? { type: "button", onClick: it.onClick } : {}),
260
+ }, <>
261
+ {it.icon ? <span className="ui-list__icon" aria-hidden="true">{it.icon}</span> : null}
262
+ <span className="ui-list__label" title={wrap ? undefined : it.labelText}>{it.label}</span>
263
+ {it.meta != null ? <span className="ui-list__meta">{it.meta}</span> : null}
264
+ </>)}
265
+ </li>
266
+ ))}
267
+ </ul>
268
+ );
269
+ }
270
+
271
+ // ── 5 · CardGrid / MediaCard ───────────────────────────────────────────────────────────────────────────────────────
272
+
273
+ export type CardGridMin = 200 | 240 | 320;
274
+
275
+ export function CardGrid({ min = 240, label, children, className }: { min?: CardGridMin; label?: string; children?: React.ReactNode; className?: string }) {
276
+ return <div className={cx("ui-cardgrid", className)} data-min={min === 240 ? undefined : min} role={label ? "list" : undefined} aria-label={label}>{children}</div>;
277
+ }
278
+
279
+ export type MediaCardProps = {
280
+ title: React.ReactNode;
281
+ href?: string;
282
+ linkAs?: React.ElementType;
283
+ /** The picture (an `<img alt="">`): the title names it. null on a board card without one. */
284
+ media?: React.ReactNode;
285
+ meta?: React.ReactNode;
286
+ badges?: React.ReactNode[];
287
+ /** The ⋯ menu, in the picture's corner. */
288
+ menu?: React.ReactNode;
289
+ foot?: React.ReactNode;
290
+ /** 16/10 by default; 4/3 for a theme or a page; 1/1 (0.18.0) for a square picture: a moodboard, a product shot, a
291
+ * social post. These three only (no free ratio: a grid of mixed shapes loses its rows); any other value draws 16/10. */
292
+ ratio?: "16/10" | "4/3" | "1/1";
293
+ /** Inside CardGrid with a `label`, each card is a list item. */
294
+ listItem?: boolean;
295
+ };
296
+
297
+ export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges, menu, foot, ratio = "16/10", listItem }: MediaCardProps) {
298
+ return (
299
+ <article className="ui-card ui-card--media" data-ratio={ratio === "4/3" || ratio === "1/1" ? ratio : undefined} role={listItem ? "listitem" : undefined}>
300
+ {media == null ? null : href ? <Link className="ui-card__media" href={href} tabIndex={-1} aria-hidden="true">{media}</Link> : <div className="ui-card__media">{media}</div>}
301
+ {menu ? <div className="ui-card__menu">{menu}</div> : null}
302
+ <div className="ui-card__body">
303
+ {href ? <Link className="ui-card__link" href={href}>{title}</Link> : <span className="ui-card__link">{title}</span>}
304
+ {meta ? <span className="ui-card__meta">{meta}</span> : null}
305
+ {badges && badges.length ? <div className="ui-list__badges">{badges.slice(0, 3).map((b, i) => <React.Fragment key={i}>{b}</React.Fragment>)}</div> : null}
306
+ </div>
307
+ {foot ? <div className="ui-card__foot">{foot}</div> : null}
308
+ </article>
309
+ );
310
+ }
311
+
312
+ // ── 6 · Board ──────────────────────────────────────────────────────────────────────────────────────────────────────
313
+
314
+ /** A card with its id (0.18.0): what a move reports and what server paging de-duplicates by. `name` is what the live
315
+ * region says ("Moved <name> to Review"); default the card's title text. */
316
+ export type BoardCard = { id: string; card: React.ReactNode; name?: string };
317
+
318
+ export type BoardColumn = {
319
+ id: string;
320
+ title: React.ReactNode;
321
+ tone?: "success" | "warning" | "critical" | "info";
322
+ /** The cards, already rendered (MediaCard, or a `ui-card ui-card--media` without a picture). */
323
+ cards?: React.ReactNode[];
324
+ /** 0.18.0: the cards with their ids, instead of `cards`. Needed for `onMove` and for server paging. */
325
+ items?: BoardCard[];
326
+ /** The total when the column holds more than it was given (a server that sent the first page). */
327
+ count?: number;
328
+ empty?: React.ReactNode;
329
+ /** 0.18.0, server paging: the server has more cards for this column than it sent. The column then shows every card
330
+ * it was given (the server already cut it: `boardShown(page)` cards, 12 then 24 more a page) and ends in ONE
331
+ * "Show N more" that asks the server, instead of the nested `<details>` that need every card in the HTML. Default:
332
+ * true when `moreHref` is set; `false` on the last page. */
333
+ hasMore?: boolean;
334
+ /** The URL of this page with this column showing one more page (`?col=ideas&page=2`): the position lives in the URL
335
+ * like `ui-pager`'s, so it works with no script, Back and a sent link keep it. kit-plain.js fetches it and appends the
336
+ * new cards in place (`data-gb-more`); in React it is `linkAs` (a Next `Link` re-renders the server page). */
337
+ moreHref?: string;
338
+ /** A request for the next page is under way (`onLoadMore`): the button becomes a spinner + "Loading…". */
339
+ loading?: boolean;
340
+ };
341
+
342
+ /** Cards a server-paged column shows at `page` (1-based): 12, then 24 more each page (Chris 05/10/2026). */
343
+ export function boardShown(page: number, pageSize = 12, step = 24): number {
344
+ // A page read from a query string that is missing or not a number (NaN) is the first page, not an empty column.
345
+ const p = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
346
+ return pageSize + (p - 1) * step;
347
+ }
348
+
349
+ export type BoardProps = {
350
+ columns: BoardColumn[];
351
+ label: string;
352
+ /** The board's DOM id, and the prefix of the ids inside (column headings, the move help). Set it when a page has two
353
+ * boards: kit-plain.js reads a fetched page's column from the board with the same id. */
354
+ id?: string;
355
+ pageSize?: number;
356
+ step?: number;
357
+ moreLabel?: (n: number) => React.ReactNode;
358
+ emptyLabel?: React.ReactNode;
359
+ /** 0.18.0: server paging from a client parent: "Show N more" calls this with the column's id (with `moreHref` too,
360
+ * the link keeps its href for a new tab). The parent appends the cards and sets `loading` meanwhile. */
361
+ onLoadMore?: (columnId: string) => void;
362
+ /** The element for the "Show more" links (`moreHref`): a Next `Link` keeps the page in the app. Default `<a>`. */
363
+ linkAs?: React.ElementType;
364
+ /** Extra props for those links, e.g. `{ scroll: false }` for a Next `Link` (the page stays where it was). */
365
+ moreLinkProps?: Record<string, unknown>;
366
+ /** The page is plain HTML driven by kit-plain.js (React only renders it, nothing hydrates: GemFactory): the "Show more"
367
+ * links get `data-gb-more`, so kit-plain.js fetches the next page and appends it in place. Never on a board React
368
+ * owns: kit-plain.js would put foreign nodes in React's lists. */
369
+ fetchMore?: boolean;
370
+ /** 0.18.0: cards move between columns, by dragging or with the keyboard (Space, arrows, Space; board-move.ts). Called
371
+ * once per drop with the card's id, the target column's id and its index there (0 = top, counted without the card).
372
+ * Only cards given as `items` move. Without it the board is read-only, as before. */
373
+ onMove?: (cardId: string, toColumn: string, toIndex: number) => void;
374
+ className?: string;
375
+ };
376
+
377
+ /** The words a screen reader hears on a movable card (aria-describedby), and the plain-HTML contract's `<p hidden>`. */
378
+ export const BOARD_MOVE_HELP = "Press Space to pick the card up, arrow keys to move it, Space to drop it, Escape to cancel.";
379
+
380
+ /** 12 cards a column, then "Show 24 more" (nested `<details>`, no script): the GemFactory rule, now everyone's. With
381
+ * `hasMore` (0.18.0) the server pages instead; with `onMove` cards move between columns. */
382
+ export function Board({ columns, label, id, pageSize = 12, step = 24, moreLabel = (n) => `Show ${n} more`, emptyLabel = "Nothing here",
383
+ onLoadMore, linkAs: Link = "a", moreLinkProps, fetchMore, onMove, className }: BoardProps) {
384
+ const movable = !!onMove;
385
+ const prefix = id ?? "ui-board";
386
+ const help = `${prefix}-move-help`;
387
+ // A callback ref, not an effect: this module stays plain (a server component renders the board; refs do not run
388
+ // there). attachBoardMove wires the element once and reads the latest onMove at drop time.
389
+ const ref = movable ? (el: HTMLDivElement | null) => { if (el) attachBoardMove(el, (cardId, to, index) => onMove!(cardId, to, index)); } : undefined;
390
+ const entries = (col: BoardColumn): BoardCard[] => col.items ?? (col.cards || []).map((card, i) => ({ id: "", card, name: String(i) }));
391
+ const li = (c: BoardCard, key: React.Key) => c.id
392
+ ? <li key={c.id} data-id={c.id} data-name={c.name} {...(movable ? { tabIndex: 0, draggable: true, "aria-describedby": help } : {})}>{c.card}</li>
393
+ : <li key={key}>{c.card}</li>;
394
+ const rest = (cards: BoardCard[], from: number): React.ReactNode => {
395
+ if (from >= cards.length) return null;
396
+ const n = Math.min(step, cards.length - from);
397
+ return (
398
+ <details className="ui-board__more">
399
+ <summary className="ui-btn ui-btn--sm">{moreLabel(n)}</summary>
400
+ <ol className="ui-board__list">{cards.slice(from, from + n).map((c, i) => li(c, from + i))}</ol>
401
+ {rest(cards, from + n)}
402
+ </details>
403
+ );
404
+ };
405
+ const next = (col: BoardColumn, shown: number): React.ReactNode => {
406
+ if (!(col.hasMore ?? col.moreHref != null)) return null;
407
+ const n = col.count != null ? Math.min(step, Math.max(col.count - shown, 0)) : step;
408
+ if (col.loading) return <div className="ui-more ui-board__next" aria-busy="true"><span className="ui-spinner" aria-hidden="true" /><span role="status">Loading…</span></div>;
409
+ const words = n > 0 ? moreLabel(n) : "Show more";
410
+ const ask = onLoadMore ? (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); onLoadMore(col.id); } : undefined;
411
+ return (
412
+ <div className="ui-more ui-board__next">
413
+ {col.moreHref
414
+ ? <Link {...moreLinkProps} className="ui-btn ui-btn--sm" href={col.moreHref} data-gb-more={fetchMore ? "" : undefined} onClick={ask}>{words}</Link>
415
+ : <button type="button" className="ui-btn ui-btn--sm" onClick={ask} disabled={!onLoadMore}>{words}</button>}
416
+ </div>
417
+ );
418
+ };
419
+ return (
420
+ <div className={cx("ui-board", className)} id={id} role="region" aria-label={label} ref={ref} data-gb-move={movable ? "" : undefined}>
421
+ {movable ? <p id={help} hidden>{BOARD_MOVE_HELP}</p> : null}
422
+ {movable ? <div className="ui-sr-only" aria-live="assertive" data-gb-live="" /> : null}
423
+ {columns.map((col) => {
424
+ const cards = entries(col);
425
+ const server = col.hasMore != null || col.moreHref != null;
426
+ return (
427
+ <section key={col.id} className="ui-board__col" data-col={col.id} aria-labelledby={`${prefix}-${col.id}`}>
428
+ <header className="ui-board__head">
429
+ <span className={cx("ui-board__dot", col.tone && `ui-board__dot--${col.tone}`)} aria-hidden="true" />
430
+ <span className="ui-board__name" id={`${prefix}-${col.id}`}>{col.title}</span>
431
+ <span className="ui-board__count">{col.count ?? cards.length}</span>
432
+ </header>
433
+ {cards.length ? (
434
+ server
435
+ ? <ol className="ui-board__list">{cards.map((c, i) => li(c, i))}</ol>
436
+ : <><ol className="ui-board__list">{cards.slice(0, pageSize).map((c, i) => li(c, i))}</ol>{rest(cards, pageSize)}</>
437
+ ) : <p className="ui-board__empty">{col.empty ?? emptyLabel}</p>}
438
+ {server ? next(col, cards.length) : null}
439
+ </section>
440
+ );
441
+ })}
442
+ </div>
443
+ );
444
+ }
445
+
446
+ // ── States ─────────────────────────────────────────────────────────────────────────────────────────────────────────
447
+
448
+ export type CollectionStateProps = {
449
+ kind: "empty" | "no-match" | "error";
450
+ title: React.ReactNode;
451
+ body?: React.ReactNode;
452
+ /** empty: the ONE thing to do next (create, connect, run). no-match: "Clear filters". error: "Try again". */
453
+ action?: React.ReactNode;
454
+ /** error with data still on screen: when that data is from ("as of 14:05"). */
455
+ staleNote?: React.ReactNode;
456
+ };
457
+
458
+ /** Empty (nothing yet), no match (filters found nothing: the header and the filters stay), error (never an empty list). */
459
+ export function CollectionState({ kind, title, body, action, staleNote }: CollectionStateProps) {
460
+ if (kind === "error") {
461
+ return (
462
+ <div className="ui-notice ui-notice--danger ui-coll-error" role="alert">
463
+ <div><b>{title}</b>{body ? <> {body}</> : null}{staleNote ? <span className="ui-muted"> {staleNote}</span> : null}</div>
464
+ {action}
465
+ </div>
466
+ );
467
+ }
468
+ return (
469
+ <div className={cx("ui-empty", kind === "no-match" && "ui-empty--no-match")}>
470
+ <p className="ui-empty__title">{title}</p>
471
+ {body ? <p className="ui-empty__body">{body}</p> : null}
472
+ {action}
473
+ </div>
474
+ );
475
+ }
476
+
477
+ export type SkeletonPattern = "table" | "index" | "list" | "compact" | "grid" | "board";
478
+
479
+ /**
480
+ * A placeholder in the shape of the pattern: a table keeps its real header and gets 8px bars in the cells, a list a
481
+ * media box and two bars, a grid a picture and two bars. `rows` = the number of rows expected (at most 10). The bars'
482
+ * sheen stops under prefers-reduced-motion (ui.css). The whole block is one status for a screen reader.
483
+ */
484
+ export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "thumbnail", label = "Loading", flush, loadingText = "Loading…", check, actions }:
485
+ { pattern: SkeletonPattern; rows?: number; columns?: CollectionColumn[]; media?: "avatar" | "thumbnail" | "none"; label?: string; flush?: boolean; loadingText?: string;
486
+ /** index: the checkbox and actions columns the real table will have; list (0.16.0): each row's box before its
487
+ * media. So nothing moves when rows arrive. */ check?: boolean; actions?: boolean }) {
488
+ const n = Math.max(1, Math.min(10, rows));
489
+ const line = (w?: string) => <span className="ui-skeleton__line" style={w ? { width: w } : undefined} />;
490
+ const status = <span className="ui-sr-only" role="status">{loadingText}</span>;
491
+ if (pattern === "table" || pattern === "index") {
492
+ const cols = columns.length ? columns : [{ key: "a", header: "" }, { key: "b", header: "" }, { key: "c", header: "" }];
493
+ return (
494
+ <div className={cx("ui-table-wrap ui-skeleton--table", pattern === "index" && "ui-index")} aria-busy="true">
495
+ {status}
496
+ <table className={cx("ui-table", pattern === "index" && "ui-table--index")} aria-hidden="true">
497
+ <thead><tr>{check ? <th className="ui-table__check"><span className="ui-table__checkbox" /></th> : null}{cols.map((c) => <th key={c.key} className={c.numeric ? "is-num" : undefined}>{c.header}</th>)}{actions ? <th className="ui-table__actions" /> : null}</tr></thead>
498
+ <tbody>{Array.from({ length: n }, (_, i) => <tr key={i}>{check ? <td className="ui-table__check"><span className="ui-table__checkbox" /></td> : null}{cols.map((c) => <td key={c.key}>{line()}</td>)}{actions ? <td className="ui-table__actions">{line("40px")}</td> : null}</tr>)}</tbody>
499
+ </table>
500
+ </div>
501
+ );
502
+ }
503
+ if (pattern === "list" || pattern === "compact") {
504
+ return (
505
+ <div aria-busy="true">
506
+ {status}
507
+ <ul className={cx("ui-list", pattern === "list" ? "ui-skeleton--list" : "ui-list--compact ui-skeleton--compact", flush && "ui-list--flush")} aria-hidden="true">
508
+ {Array.from({ length: n }, (_, i) => pattern === "list" ? (
509
+ <li key={i} className="ui-list__item">
510
+ {check ? <span className="ui-list__media"><span className="ui-list__check" />{media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}</span>
511
+ : media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
512
+ <span className="ui-list__body">{line()}{line()}</span>
513
+ </li>
514
+ ) : (
515
+ <li key={i} className="ui-list__item"><span className="ui-list__row"><span className="ui-skeleton__block" style={{ width: 16, height: 16 }} />{line()}</span></li>
516
+ ))}
517
+ </ul>
518
+ </div>
519
+ );
520
+ }
521
+ if (pattern === "grid") {
522
+ return (
523
+ <div aria-busy="true">
524
+ {status}
525
+ <div className="ui-cardgrid ui-skeleton--grid" aria-hidden="true">
526
+ {Array.from({ length: n }, (_, i) => (
527
+ <div key={i} className="ui-card ui-card--media"><span className="ui-card__media" /><span className="ui-card__body">{line()}{line()}</span></div>
528
+ ))}
529
+ </div>
530
+ </div>
531
+ );
532
+ }
533
+ return (
534
+ <div aria-busy="true">
535
+ {status}
536
+ <div className="ui-board ui-skeleton--board" aria-hidden="true">
537
+ {Array.from({ length: Math.min(n, 6) }, (_, i) => (
538
+ <div key={i} className="ui-board__col"><span className="ui-board__head">{line("40%")}</span><ol className="ui-board__list"><li /><li /><li /></ol></div>
539
+ ))}
540
+ </div>
541
+ </div>
542
+ );
543
+ }