@gemboss/ui 0.12.0 → 0.13.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/CHANGELOG.md +33 -0
- package/README.md +74 -0
- package/catalog/index.html +2138 -10
- package/gemboss-ui.css +19 -3
- package/package.json +2 -1
- package/src/collections.css +350 -0
- package/src/collections.tsx +414 -0
- package/src/index-table.tsx +216 -0
- package/src/index.ts +11 -0
- package/src/kit-plain.css +18 -2
- package/src/kit-plain.js +136 -1
|
@@ -0,0 +1,414 @@
|
|
|
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
|
+
|
|
20
|
+
const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
|
|
21
|
+
|
|
22
|
+
export type SortDirection = "ascending" | "descending";
|
|
23
|
+
export type CollectionSort = { key: string; direction: SortDirection };
|
|
24
|
+
|
|
25
|
+
/** A column of a DataTable or an IndexTable. Every field is plain data, so a server component can build it. */
|
|
26
|
+
export type CollectionColumn = {
|
|
27
|
+
key: string;
|
|
28
|
+
header: React.ReactNode;
|
|
29
|
+
/** Right-aligned tabular figures, header included (`is-num`). */
|
|
30
|
+
numeric?: boolean;
|
|
31
|
+
/** Where clicking the header goes to sort by this column (the order lives in the URL). */
|
|
32
|
+
sortHref?: string;
|
|
33
|
+
/** Sortable through `onSort` (a client parent) instead of a link. */
|
|
34
|
+
sortable?: boolean;
|
|
35
|
+
/** IndexTable below 768: "hide" leaves the cell out of the folded row; default shows it on the second line. */
|
|
36
|
+
condensed?: "sub" | "hide";
|
|
37
|
+
/** IndexTable below 768: the words in front of the value on the folded line ("Score"). */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** Cap the cell at this width with an ellipsis (the whole text belongs in the cell's `title`). */
|
|
40
|
+
truncate?: number | string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type CollectionRow = { id: string; cells: Record<string, React.ReactNode> };
|
|
44
|
+
|
|
45
|
+
/** `aria-sort` for a column header: only on a sortable column; "none" when it is not the current sort. */
|
|
46
|
+
export function ariaSort(col: CollectionColumn, sort?: CollectionSort | null): React.AriaAttributes["aria-sort"] {
|
|
47
|
+
if (!col.sortHref && !col.sortable) return undefined;
|
|
48
|
+
return sort && sort.key === col.key ? sort.direction : "none";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The header cell's contents: the label, as a link (`sortHref`) or a button (`onSort`) when the column sorts. */
|
|
52
|
+
export function SortHeader({ col, sort, onSort }: { col: CollectionColumn; sort?: CollectionSort | null; onSort?: (key: string, direction: SortDirection) => void }) {
|
|
53
|
+
const next: SortDirection = sort && sort.key === col.key && sort.direction === "ascending" ? "descending" : "ascending";
|
|
54
|
+
if (col.sortHref) return <a className="ui-table__sort" href={col.sortHref}>{col.header}</a>;
|
|
55
|
+
if (col.sortable && onSort) return <button type="button" className="ui-table__sort" onClick={() => onSort(col.key, next)}>{col.header}</button>;
|
|
56
|
+
return <>{col.header}</>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const truncStyle = (t: CollectionColumn["truncate"]): React.CSSProperties | undefined =>
|
|
60
|
+
t == null ? undefined : ({ ["--ui-trunc" as string]: typeof t === "number" ? `${t}px` : t } as React.CSSProperties);
|
|
61
|
+
|
|
62
|
+
export function Truncate({ children, title, width }: { children: React.ReactNode; title?: string; width?: number | string }) {
|
|
63
|
+
return <span className="ui-trunc" title={title} style={truncStyle(width)}>{children}</span>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── 1 · DataTable ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
export type DataTableProps = {
|
|
69
|
+
columns: CollectionColumn[];
|
|
70
|
+
rows: CollectionRow[];
|
|
71
|
+
/** Names the table for a screen reader (a visually hidden caption). */
|
|
72
|
+
label: string;
|
|
73
|
+
sort?: CollectionSort | null;
|
|
74
|
+
onSort?: (key: string, direction: SortDirection) => void;
|
|
75
|
+
/** The first column stays put while the table scrolls sideways (what a data table does below 768). */
|
|
76
|
+
stickyFirstColumn?: boolean;
|
|
77
|
+
/** Rows 32 instead of 36: only for more than 20 rows. */
|
|
78
|
+
density?: "default" | "compact";
|
|
79
|
+
/** The first cell of every row is its header (`th scope="row"`). Default true: a data table's first column names the row. */
|
|
80
|
+
rowHeaders?: boolean;
|
|
81
|
+
totals?: Record<string, React.ReactNode>;
|
|
82
|
+
footer?: React.ReactNode;
|
|
83
|
+
/** loading → the skeleton in this table's shape; busy → the rows stay, dimmed; empty / no-match / error → `stateSlot`. */
|
|
84
|
+
state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
|
|
85
|
+
/** What to show for empty / no-match / error: a `CollectionState`. The header stays above it. */
|
|
86
|
+
stateSlot?: React.ReactNode;
|
|
87
|
+
className?: string;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
|
|
91
|
+
if (state === "loading") return <CollectionSkeleton pattern="table" columns={columns} label={label} />;
|
|
92
|
+
const showRows = state === "ready" || state === "busy";
|
|
93
|
+
return (
|
|
94
|
+
<div className={cx("ui-table-wrap", className)} aria-busy={state === "busy" ? true : undefined}>
|
|
95
|
+
<table className={cx("ui-table", stickyFirstColumn && "ui-table--sticky-first", density === "compact" && "ui-table--compact")}>
|
|
96
|
+
<caption className="ui-sr-only">{label}</caption>
|
|
97
|
+
<thead>
|
|
98
|
+
<tr>{columns.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} /></th>)}</tr>
|
|
99
|
+
</thead>
|
|
100
|
+
{showRows ? (
|
|
101
|
+
<tbody>
|
|
102
|
+
{rows.map((r) => (
|
|
103
|
+
<tr key={r.id}>
|
|
104
|
+
{columns.map((c, i) => {
|
|
105
|
+
const body = c.truncate != null ? <span className="ui-trunc" style={truncStyle(c.truncate)}>{r.cells[c.key]}</span> : r.cells[c.key];
|
|
106
|
+
return i === 0 && rowHeaders
|
|
107
|
+
? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"}>{body}</th>
|
|
108
|
+
: <td key={c.key} className={c.numeric ? "is-num" : undefined}>{body}</td>;
|
|
109
|
+
})}
|
|
110
|
+
</tr>
|
|
111
|
+
))}
|
|
112
|
+
</tbody>
|
|
113
|
+
) : null}
|
|
114
|
+
{totals && showRows ? (
|
|
115
|
+
<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>
|
|
116
|
+
) : null}
|
|
117
|
+
</table>
|
|
118
|
+
{!showRows ? stateSlot : null}
|
|
119
|
+
{footer && showRows ? <div className="ui-table__footer">{footer}</div> : null}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── 3 · ResourceList / ResourceItem ────────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export type ResourceItemProps = {
|
|
127
|
+
title: React.ReactNode;
|
|
128
|
+
/** The detail page. The WHOLE row then opens it; the title stays a real link. */
|
|
129
|
+
href?: string;
|
|
130
|
+
/** The link element: a Next `Link`, a router link. Default `<a>`. */
|
|
131
|
+
linkAs?: React.ElementType;
|
|
132
|
+
/** The full title, when it may be cut by the ellipsis. */
|
|
133
|
+
titleText?: string;
|
|
134
|
+
media?: React.ReactNode;
|
|
135
|
+
subtitle?: React.ReactNode;
|
|
136
|
+
meta?: React.ReactNode;
|
|
137
|
+
/** At most three are shown; the rest become "+N". */
|
|
138
|
+
badges?: React.ReactNode[];
|
|
139
|
+
aside?: React.ReactNode;
|
|
140
|
+
/** One button and/or a ⋯ `Menu`. Never more than one visible button. */
|
|
141
|
+
actions?: React.ReactNode;
|
|
142
|
+
selected?: boolean;
|
|
143
|
+
id?: string;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id }: ResourceItemProps) {
|
|
147
|
+
const shown = (badges || []).slice(0, 3), more = (badges || []).length - shown.length;
|
|
148
|
+
return (
|
|
149
|
+
<li className={cx("ui-list__item", selected && "is-selected")} id={id} aria-selected={selected ? true : undefined}>
|
|
150
|
+
{media ? <div className="ui-list__media">{media}</div> : null}
|
|
151
|
+
<div className="ui-list__body">
|
|
152
|
+
{href ? <Link className="ui-list__title" href={href} title={titleText}>{title}</Link> : <span className="ui-list__title" title={titleText}>{title}</span>}
|
|
153
|
+
{subtitle ? <p className="ui-list__sub">{subtitle}</p> : null}
|
|
154
|
+
{meta ? <p className="ui-list__meta">{meta}</p> : null}
|
|
155
|
+
{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}
|
|
156
|
+
</div>
|
|
157
|
+
{aside != null ? <div className="ui-list__aside">{aside}</div> : null}
|
|
158
|
+
{actions ? <div className="ui-list__actions">{actions}</div> : null}
|
|
159
|
+
</li>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export type ResourceListProps = {
|
|
164
|
+
label: string;
|
|
165
|
+
children?: React.ReactNode;
|
|
166
|
+
/** Inside a card that is already the frame: no border, no shadow. */
|
|
167
|
+
flush?: boolean;
|
|
168
|
+
state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
|
|
169
|
+
stateSlot?: React.ReactNode;
|
|
170
|
+
/** How many placeholder rows while loading (the number expected, at most 10). */
|
|
171
|
+
loadingRows?: number;
|
|
172
|
+
/** The media shape of the loading rows. */
|
|
173
|
+
loadingMedia?: "avatar" | "thumbnail" | "none";
|
|
174
|
+
className?: string;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export function ResourceList({ label, children, flush, state = "ready", stateSlot, loadingRows = 5, loadingMedia = "thumbnail", className }: ResourceListProps) {
|
|
178
|
+
if (state === "loading") return <CollectionSkeleton pattern="list" rows={loadingRows} media={loadingMedia} label={label} flush={flush} />;
|
|
179
|
+
if (state === "empty" || state === "no-match" || state === "error") {
|
|
180
|
+
return <div className={cx("ui-list", flush && "ui-list--flush", className)} role="region" aria-label={label}>{stateSlot}</div>;
|
|
181
|
+
}
|
|
182
|
+
return <ul className={cx("ui-list", flush && "ui-list--flush", className)} aria-label={label} aria-busy={state === "busy" ? true : undefined}>{children}</ul>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 4 · CompactList ────────────────────────────────────────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
export type CompactItem = {
|
|
188
|
+
id: string;
|
|
189
|
+
label: React.ReactNode;
|
|
190
|
+
href?: string;
|
|
191
|
+
onClick?: () => void;
|
|
192
|
+
icon?: React.ReactNode;
|
|
193
|
+
meta?: React.ReactNode;
|
|
194
|
+
current?: boolean;
|
|
195
|
+
/** activity: the line under the label, and the dot's tone. */
|
|
196
|
+
detail?: React.ReactNode;
|
|
197
|
+
time?: React.ReactNode;
|
|
198
|
+
dateTime?: string;
|
|
199
|
+
tone?: "success" | "warning" | "critical" | "info";
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export function CompactList({ items, label, variant, linkAs: Link = "a", flush, className }: { items: CompactItem[]; label: string; variant?: "activity"; linkAs?: React.ElementType; flush?: boolean; className?: string }) {
|
|
203
|
+
const activity = variant === "activity";
|
|
204
|
+
return (
|
|
205
|
+
<ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", flush && "ui-list--flush", className)} aria-label={label}>
|
|
206
|
+
{items.map((it) => activity ? (
|
|
207
|
+
<li key={it.id} className="ui-list__item">
|
|
208
|
+
<span className={cx("ui-list__dot", it.tone && `ui-list__dot--${it.tone}`)} aria-hidden="true" />
|
|
209
|
+
<div>
|
|
210
|
+
{it.href ? <Link className="ui-list__label" href={it.href}>{it.label}</Link> : <span className="ui-list__label">{it.label}</span>}
|
|
211
|
+
{it.detail ? <span className="ui-list__detail">{it.detail}</span> : null}
|
|
212
|
+
</div>
|
|
213
|
+
{it.time ? <time className="ui-list__time" dateTime={it.dateTime}>{it.time}</time> : <span />}
|
|
214
|
+
</li>
|
|
215
|
+
) : (
|
|
216
|
+
<li key={it.id} className="ui-list__item">
|
|
217
|
+
{React.createElement(it.href ? Link : it.onClick ? "button" : "div", {
|
|
218
|
+
className: cx("ui-list__row", it.current && "is-selected"),
|
|
219
|
+
...(it.href ? { href: it.href, "aria-current": it.current ? "page" : undefined } : it.onClick ? { type: "button", onClick: it.onClick } : {}),
|
|
220
|
+
}, <>
|
|
221
|
+
{it.icon ? <span className="ui-list__icon" aria-hidden="true">{it.icon}</span> : null}
|
|
222
|
+
<span className="ui-list__label">{it.label}</span>
|
|
223
|
+
{it.meta != null ? <span className="ui-list__meta">{it.meta}</span> : null}
|
|
224
|
+
</>)}
|
|
225
|
+
</li>
|
|
226
|
+
))}
|
|
227
|
+
</ul>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── 5 · CardGrid / MediaCard ───────────────────────────────────────────────────────────────────────────────────────
|
|
232
|
+
|
|
233
|
+
export type CardGridMin = 200 | 240 | 320;
|
|
234
|
+
|
|
235
|
+
export function CardGrid({ min = 240, label, children, className }: { min?: CardGridMin; label?: string; children?: React.ReactNode; className?: string }) {
|
|
236
|
+
return <div className={cx("ui-cardgrid", className)} data-min={min === 240 ? undefined : min} role={label ? "list" : undefined} aria-label={label}>{children}</div>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export type MediaCardProps = {
|
|
240
|
+
title: React.ReactNode;
|
|
241
|
+
href?: string;
|
|
242
|
+
linkAs?: React.ElementType;
|
|
243
|
+
/** The picture (an `<img alt="">`): the title names it. null on a board card without one. */
|
|
244
|
+
media?: React.ReactNode;
|
|
245
|
+
meta?: React.ReactNode;
|
|
246
|
+
badges?: React.ReactNode[];
|
|
247
|
+
/** The ⋯ menu, in the picture's corner. */
|
|
248
|
+
menu?: React.ReactNode;
|
|
249
|
+
foot?: React.ReactNode;
|
|
250
|
+
/** 16/10 by default; 4/3 for a theme or a page. */
|
|
251
|
+
ratio?: "16/10" | "4/3";
|
|
252
|
+
/** Inside CardGrid with a `label`, each card is a list item. */
|
|
253
|
+
listItem?: boolean;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges, menu, foot, ratio = "16/10", listItem }: MediaCardProps) {
|
|
257
|
+
return (
|
|
258
|
+
<article className="ui-card ui-card--media" data-ratio={ratio === "4/3" ? "4/3" : undefined} role={listItem ? "listitem" : undefined}>
|
|
259
|
+
{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>}
|
|
260
|
+
{menu ? <div className="ui-card__menu">{menu}</div> : null}
|
|
261
|
+
<div className="ui-card__body">
|
|
262
|
+
{href ? <Link className="ui-card__link" href={href}>{title}</Link> : <span className="ui-card__link">{title}</span>}
|
|
263
|
+
{meta ? <span className="ui-card__meta">{meta}</span> : null}
|
|
264
|
+
{badges && badges.length ? <div className="ui-list__badges">{badges.slice(0, 3).map((b, i) => <React.Fragment key={i}>{b}</React.Fragment>)}</div> : null}
|
|
265
|
+
</div>
|
|
266
|
+
{foot ? <div className="ui-card__foot">{foot}</div> : null}
|
|
267
|
+
</article>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ── 6 · Board ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
|
|
273
|
+
export type BoardColumn = {
|
|
274
|
+
id: string;
|
|
275
|
+
title: React.ReactNode;
|
|
276
|
+
tone?: "success" | "warning" | "critical" | "info";
|
|
277
|
+
/** The cards, already rendered (MediaCard, or a `ui-card ui-card--media` without a picture). */
|
|
278
|
+
cards: React.ReactNode[];
|
|
279
|
+
/** The total when the column holds more than it was given (a server that sent the first page). */
|
|
280
|
+
count?: number;
|
|
281
|
+
empty?: React.ReactNode;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** 12 cards a column, then "Show 24 more" (nested `<details>`, no script): the GemFactory rule, now everyone's. */
|
|
285
|
+
export function Board({ columns, label, pageSize = 12, step = 24, moreLabel = (n) => `Show ${n} more`, emptyLabel = "Nothing here", className }:
|
|
286
|
+
{ columns: BoardColumn[]; label: string; pageSize?: number; step?: number; moreLabel?: (n: number) => React.ReactNode; emptyLabel?: React.ReactNode; className?: string }) {
|
|
287
|
+
const rest = (cards: React.ReactNode[], from: number): React.ReactNode => {
|
|
288
|
+
if (from >= cards.length) return null;
|
|
289
|
+
const n = Math.min(step, cards.length - from);
|
|
290
|
+
return (
|
|
291
|
+
<details className="ui-board__more">
|
|
292
|
+
<summary className="ui-btn ui-btn--sm">{moreLabel(n)}</summary>
|
|
293
|
+
<ol className="ui-board__list">{cards.slice(from, from + n).map((c, i) => <li key={from + i}>{c}</li>)}</ol>
|
|
294
|
+
{rest(cards, from + n)}
|
|
295
|
+
</details>
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
return (
|
|
299
|
+
<div className={cx("ui-board", className)} role="region" aria-label={label}>
|
|
300
|
+
{columns.map((col) => (
|
|
301
|
+
<section key={col.id} className="ui-board__col" aria-labelledby={`ui-board-${col.id}`}>
|
|
302
|
+
<header className="ui-board__head">
|
|
303
|
+
<span className={cx("ui-board__dot", col.tone && `ui-board__dot--${col.tone}`)} aria-hidden="true" />
|
|
304
|
+
<span className="ui-board__name" id={`ui-board-${col.id}`}>{col.title}</span>
|
|
305
|
+
<span className="ui-board__count">{col.count ?? col.cards.length}</span>
|
|
306
|
+
</header>
|
|
307
|
+
{col.cards.length ? (
|
|
308
|
+
<>
|
|
309
|
+
<ol className="ui-board__list">{col.cards.slice(0, pageSize).map((c, i) => <li key={i}>{c}</li>)}</ol>
|
|
310
|
+
{rest(col.cards, pageSize)}
|
|
311
|
+
</>
|
|
312
|
+
) : <p className="ui-board__empty">{col.empty ?? emptyLabel}</p>}
|
|
313
|
+
</section>
|
|
314
|
+
))}
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── States ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
export type CollectionStateProps = {
|
|
322
|
+
kind: "empty" | "no-match" | "error";
|
|
323
|
+
title: React.ReactNode;
|
|
324
|
+
body?: React.ReactNode;
|
|
325
|
+
/** empty: the ONE thing to do next (create, connect, run). no-match: "Clear filters". error: "Try again". */
|
|
326
|
+
action?: React.ReactNode;
|
|
327
|
+
/** error with data still on screen: when that data is from ("as of 14:05"). */
|
|
328
|
+
staleNote?: React.ReactNode;
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
/** Empty (nothing yet), no match (filters found nothing: the header and the filters stay), error (never an empty list). */
|
|
332
|
+
export function CollectionState({ kind, title, body, action, staleNote }: CollectionStateProps) {
|
|
333
|
+
if (kind === "error") {
|
|
334
|
+
return (
|
|
335
|
+
<div className="ui-notice ui-notice--danger ui-coll-error" role="alert">
|
|
336
|
+
<div><b>{title}</b>{body ? <> {body}</> : null}{staleNote ? <span className="ui-muted"> {staleNote}</span> : null}</div>
|
|
337
|
+
{action}
|
|
338
|
+
</div>
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
return (
|
|
342
|
+
<div className={cx("ui-empty", kind === "no-match" && "ui-empty--no-match")}>
|
|
343
|
+
<p className="ui-empty__title">{title}</p>
|
|
344
|
+
{body ? <p className="ui-empty__body">{body}</p> : null}
|
|
345
|
+
{action}
|
|
346
|
+
</div>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export type SkeletonPattern = "table" | "index" | "list" | "compact" | "grid" | "board";
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* A placeholder in the shape of the pattern: a table keeps its real header and gets 8px bars in the cells, a list a
|
|
354
|
+
* media box and two bars, a grid a picture and two bars. `rows` = the number of rows expected (at most 10). The bars'
|
|
355
|
+
* sheen stops under prefers-reduced-motion (ui.css). The whole block is one status for a screen reader.
|
|
356
|
+
*/
|
|
357
|
+
export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "thumbnail", label = "Loading", flush, loadingText = "Loading…", check, actions }:
|
|
358
|
+
{ pattern: SkeletonPattern; rows?: number; columns?: CollectionColumn[]; media?: "avatar" | "thumbnail" | "none"; label?: string; flush?: boolean; loadingText?: string;
|
|
359
|
+
/** index: the checkbox and actions columns the real table will have, so no column moves when rows arrive. */ check?: boolean; actions?: boolean }) {
|
|
360
|
+
const n = Math.max(1, Math.min(10, rows));
|
|
361
|
+
const line = (w?: string) => <span className="ui-skeleton__line" style={w ? { width: w } : undefined} />;
|
|
362
|
+
const status = <span className="ui-sr-only" role="status">{loadingText}</span>;
|
|
363
|
+
if (pattern === "table" || pattern === "index") {
|
|
364
|
+
const cols = columns.length ? columns : [{ key: "a", header: "" }, { key: "b", header: "" }, { key: "c", header: "" }];
|
|
365
|
+
return (
|
|
366
|
+
<div className={cx("ui-table-wrap ui-skeleton--table", pattern === "index" && "ui-index")} aria-busy="true">
|
|
367
|
+
{status}
|
|
368
|
+
<table className={cx("ui-table", pattern === "index" && "ui-table--index")} aria-hidden="true">
|
|
369
|
+
<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>
|
|
370
|
+
<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>
|
|
371
|
+
</table>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
if (pattern === "list" || pattern === "compact") {
|
|
376
|
+
return (
|
|
377
|
+
<div aria-busy="true">
|
|
378
|
+
{status}
|
|
379
|
+
<ul className={cx("ui-list", pattern === "list" ? "ui-skeleton--list" : "ui-list--compact ui-skeleton--compact", flush && "ui-list--flush")} aria-hidden="true">
|
|
380
|
+
{Array.from({ length: n }, (_, i) => pattern === "list" ? (
|
|
381
|
+
<li key={i} className="ui-list__item">
|
|
382
|
+
{media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
|
|
383
|
+
<span className="ui-list__body">{line()}{line()}</span>
|
|
384
|
+
</li>
|
|
385
|
+
) : (
|
|
386
|
+
<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>
|
|
387
|
+
))}
|
|
388
|
+
</ul>
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
if (pattern === "grid") {
|
|
393
|
+
return (
|
|
394
|
+
<div aria-busy="true">
|
|
395
|
+
{status}
|
|
396
|
+
<div className="ui-cardgrid ui-skeleton--grid" aria-hidden="true">
|
|
397
|
+
{Array.from({ length: n }, (_, i) => (
|
|
398
|
+
<div key={i} className="ui-card ui-card--media"><span className="ui-card__media" /><span className="ui-card__body">{line()}{line()}</span></div>
|
|
399
|
+
))}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
return (
|
|
405
|
+
<div aria-busy="true">
|
|
406
|
+
{status}
|
|
407
|
+
<div className="ui-board ui-skeleton--board" aria-hidden="true">
|
|
408
|
+
{Array.from({ length: Math.min(n, 6) }, (_, i) => (
|
|
409
|
+
<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>
|
|
410
|
+
))}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* IndexTable (0.13.0): a list of entities people open, compare and act on in bulk. Polaris IndexTable's shape on the
|
|
4
|
+
* classes of `@gemboss/ui/collections.css` (README "Collections").
|
|
5
|
+
*
|
|
6
|
+
* - The WHOLE row opens the item (Chris 05/10/2026): a click anywhere that is not a control follows the row's title link
|
|
7
|
+
* (`a.ui-table__title`), which stays a real link, so Cmd-click, middle-click and a screen reader all work. A click on
|
|
8
|
+
* a checkbox, a button, a menu, a select or another link does what that control does and nothing else; so does a
|
|
9
|
+
* click that ends a text selection. A row with two or more in-place editing controls should not be linked (`href`
|
|
10
|
+
* off; link only its title by putting the link in `title` yourself).
|
|
11
|
+
* - Selection only with bulk actions: a checkbox column with nothing to do with the selection is inert (Artifacts and
|
|
12
|
+
* GemCRM shipped exactly that). The bulk bar takes the header's place, carries its own select-all box (the folded list
|
|
13
|
+
* has no header), and Escape clears the selection.
|
|
14
|
+
* - Below 768 of ITS container the table folds into a list (collections.css): the title line, then the other cells on
|
|
15
|
+
* one caption line (`label` prefixes a value; `condensed: "hide"` leaves a cell out), checkbox and actions kept.
|
|
16
|
+
*
|
|
17
|
+
* Every prop that describes the table is plain data (rows of rendered cells, `sortHref` strings), so a server
|
|
18
|
+
* component can render it; the callbacks (`onSort`, `onSelectionChange`, bulk `onAction`) need a client parent.
|
|
19
|
+
* Its own module, "use client": nothing the admin imports pulls it in (first-load budget).
|
|
20
|
+
*/
|
|
21
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
22
|
+
import { ariaSort, SortHeader, CollectionSkeleton, type CollectionColumn, type CollectionSort, type SortDirection } from "./collections";
|
|
23
|
+
|
|
24
|
+
const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
|
|
25
|
+
|
|
26
|
+
export type IndexTableRow = {
|
|
27
|
+
id: string;
|
|
28
|
+
/** The row's main text. With `href` the table makes it the row's link; without, pass your own `<a className="ui-table__title">` or leave the row unlinked. */
|
|
29
|
+
title: React.ReactNode;
|
|
30
|
+
/** The whole title for a screen tip when the ellipsis cuts it. */
|
|
31
|
+
titleText?: string;
|
|
32
|
+
href?: string;
|
|
33
|
+
/** "_blank" for a row that opens somewhere else (the row follows the same link). */
|
|
34
|
+
target?: string;
|
|
35
|
+
/** One line under the title (`ui-sub`). */
|
|
36
|
+
sub?: React.ReactNode;
|
|
37
|
+
/** Avatar 24/32 or a `ui-thumb` (40), left of the title. One size for the whole table. */
|
|
38
|
+
media?: React.ReactNode;
|
|
39
|
+
/** The other columns, by column key. */
|
|
40
|
+
cells: Record<string, React.ReactNode>;
|
|
41
|
+
/** One button and/or a ⋯ menu, at the end of the row. Never more than one visible button. */
|
|
42
|
+
actions?: React.ReactNode;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BulkAction = { content: React.ReactNode; onAction: (ids: string[]) => void; tone?: "critical" };
|
|
46
|
+
|
|
47
|
+
export type IndexTableProps = {
|
|
48
|
+
/** The first column is the primary one (title, sub, media); the rest map to `row.cells[key]`. */
|
|
49
|
+
columns: CollectionColumn[];
|
|
50
|
+
rows: IndexTableRow[];
|
|
51
|
+
/** Names the table for a screen reader. */
|
|
52
|
+
label: string;
|
|
53
|
+
sort?: CollectionSort | null;
|
|
54
|
+
onSort?: (key: string, direction: SortDirection) => void;
|
|
55
|
+
/** Turns the checkbox column on. Only together with `bulkActions`. */
|
|
56
|
+
bulkActions?: BulkAction[];
|
|
57
|
+
/** Controlled selection; leave both out and the table keeps it. */
|
|
58
|
+
selected?: string[];
|
|
59
|
+
onSelectionChange?: (ids: string[]) => void;
|
|
60
|
+
/** "12 selected": `(n) => …`. */
|
|
61
|
+
selectedLabel?: (n: number) => React.ReactNode;
|
|
62
|
+
selectAllLabel?: string;
|
|
63
|
+
/** The link element for titles (a Next `Link` from a client parent). Default `<a>`. */
|
|
64
|
+
linkAs?: React.ElementType;
|
|
65
|
+
/** Below 768 the header is gone: these chips sort instead. Default: one per sortable column. */
|
|
66
|
+
sortLabel?: React.ReactNode;
|
|
67
|
+
state?: "ready" | "loading" | "busy" | "empty" | "no-match" | "error";
|
|
68
|
+
/** For empty / no-match / error: a `CollectionState`. The header stays above it. */
|
|
69
|
+
stateSlot?: React.ReactNode;
|
|
70
|
+
loadingRows?: number;
|
|
71
|
+
/** Whether the loading skeleton has an actions column (default: any row has actions). */
|
|
72
|
+
actionsColumn?: boolean;
|
|
73
|
+
className?: string;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Anything in a row that is a control of its own: a click on it is not a click on the row. */
|
|
77
|
+
export const ROW_CONTROLS = 'a[href], button, input, select, textarea, label, summary, details, dialog, [role="dialog"], [role="menu"], [role="button"], [role="menuitem"], [role="checkbox"], [contenteditable="true"], [contenteditable=""], [data-row-ignore], .ui-table__check';
|
|
78
|
+
/** Escape belongs to these first (a ⋯ menu, a dialog in the row): it closes them, not the selection. */
|
|
79
|
+
export const ESCAPE_OWNERS = 'details[open], dialog, [role="dialog"], [role="menu"]';
|
|
80
|
+
|
|
81
|
+
/** The click on a row, minus controls and text selection; follows the row's title link. Shared with kit-plain.js in spirit (same rules). */
|
|
82
|
+
export function followRow(e: React.MouseEvent<HTMLElement>): void {
|
|
83
|
+
const row = e.currentTarget;
|
|
84
|
+
const t = e.target as Element;
|
|
85
|
+
if (e.defaultPrevented || e.button > 1) return;
|
|
86
|
+
// A portal (a popover rendered elsewhere) still bubbles React events through the row: not a click on the row.
|
|
87
|
+
if (!(t instanceof Node) || !row.contains(t)) return;
|
|
88
|
+
const hit = t.closest ? t.closest(ROW_CONTROLS) : null;
|
|
89
|
+
if (hit && row.contains(hit)) return;
|
|
90
|
+
const sel = typeof window !== "undefined" ? window.getSelection() : null;
|
|
91
|
+
if (sel && !sel.isCollapsed && sel.anchorNode && row.contains(sel.anchorNode)) return;
|
|
92
|
+
const a = row.querySelector<HTMLAnchorElement>("a.ui-table__title[href]");
|
|
93
|
+
if (!a) return;
|
|
94
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { window.open(a.href, "_blank", "noopener"); return; }
|
|
95
|
+
a.click();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function IndexTable({ columns, rows, label, sort, onSort, bulkActions, selected: selectedProp, onSelectionChange, selectedLabel = (n) => `${n} selected`,
|
|
99
|
+
selectAllLabel = "Select all", linkAs: Link = "a", sortLabel = "Sort", state = "ready", stateSlot, loadingRows = 5, actionsColumn, className }: IndexTableProps) {
|
|
100
|
+
const selectable = !!(bulkActions && bulkActions.length);
|
|
101
|
+
const [own, setOwn] = useState<string[]>([]);
|
|
102
|
+
const selected = selectedProp ?? own;
|
|
103
|
+
const setSelected = useCallback((ids: string[]) => { if (!selectedProp) setOwn(ids); onSelectionChange?.(ids); }, [selectedProp, onSelectionChange]);
|
|
104
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
105
|
+
const anchorRef = useRef<string | null>(null); // Shift+click range anchor: an id, so a re-sort cannot move it
|
|
106
|
+
const ids = rows.map((r) => r.id);
|
|
107
|
+
const idSet = new Set(ids), selSet = new Set(selected);
|
|
108
|
+
const n = selected.filter((id) => idSet.has(id)).length;
|
|
109
|
+
const hasActions = rows.some((r) => r.actions);
|
|
110
|
+
const all = n > 0 && n === ids.length, some = n > 0 && !all;
|
|
111
|
+
const headBox = useRef<HTMLInputElement>(null), barBox = useRef<HTMLInputElement>(null);
|
|
112
|
+
useEffect(() => { for (const b of [headBox.current, barBox.current]) if (b) b.indeterminate = some; }, [some]);
|
|
113
|
+
// Escape clears the selection while focus is in the table (a menu open inside it closes first: it stops the key).
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const root = rootRef.current;
|
|
116
|
+
if (!root || !selectable) return;
|
|
117
|
+
const onKey = (e: KeyboardEvent) => {
|
|
118
|
+
if (e.key !== "Escape" || e.defaultPrevented || n === 0) return;
|
|
119
|
+
// A ⋯ menu or a dialog open in the table takes this Escape (it closes), not the selection.
|
|
120
|
+
if ((e.target as Element).closest?.(ESCAPE_OWNERS) || root.querySelector(ESCAPE_OWNERS)) return;
|
|
121
|
+
setSelected([]);
|
|
122
|
+
};
|
|
123
|
+
root.addEventListener("keydown", onKey);
|
|
124
|
+
return () => root.removeEventListener("keydown", onKey);
|
|
125
|
+
}, [n, selectable, setSelected]);
|
|
126
|
+
|
|
127
|
+
if (state === "loading") return <CollectionSkeleton pattern="index" columns={columns} rows={loadingRows} label={label} check={selectable} actions={actionsColumn ?? hasActions} />;
|
|
128
|
+
const showRows = state === "ready" || state === "busy";
|
|
129
|
+
const [primary, ...rest] = columns;
|
|
130
|
+
const toggleAll = (on: boolean) => { anchorRef.current = null; setSelected(on ? ids : []); };
|
|
131
|
+
const toggle = (i: number, on: boolean, shift: boolean) => {
|
|
132
|
+
const id = ids[i];
|
|
133
|
+
const next = new Set(selected);
|
|
134
|
+
const from = anchorRef.current ? ids.indexOf(anchorRef.current) : -1; // -1 when the anchor left the rows (filter, page)
|
|
135
|
+
if (shift && from > -1) {
|
|
136
|
+
for (let k = Math.min(from, i); k <= Math.max(from, i); k++) on ? next.add(ids[k]) : next.delete(ids[k]);
|
|
137
|
+
} else if (on) next.add(id); else next.delete(id);
|
|
138
|
+
anchorRef.current = id;
|
|
139
|
+
setSelected(ids.filter((x) => next.has(x)));
|
|
140
|
+
};
|
|
141
|
+
const sortable = columns.filter((c) => c.sortHref || (c.sortable && onSort));
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<div className={cx("ui-index", className)} ref={rootRef} aria-busy={state === "busy" ? true : undefined}>
|
|
145
|
+
{sortable.length && showRows ? (
|
|
146
|
+
<div className="ui-index__sortbar" role="group" aria-label={typeof sortLabel === "string" ? sortLabel : "Sort"}>
|
|
147
|
+
<span aria-hidden="true">{sortLabel}</span>
|
|
148
|
+
{sortable.map((c) => {
|
|
149
|
+
const on = sort?.key === c.key;
|
|
150
|
+
const arrow = on ? (sort!.direction === "ascending" ? " ↑" : " ↓") : "";
|
|
151
|
+
return c.sortHref
|
|
152
|
+
? <a key={c.key} className="ui-chip" href={c.sortHref} aria-current={on ? "true" : undefined}>{c.header}{arrow}</a>
|
|
153
|
+
: <button key={c.key} type="button" className="ui-chip" aria-pressed={on} onClick={() => onSort!(c.key, on && sort!.direction === "ascending" ? "descending" : "ascending")}>{c.header}{arrow}</button>;
|
|
154
|
+
})}
|
|
155
|
+
</div>
|
|
156
|
+
) : null}
|
|
157
|
+
{selectable ? (
|
|
158
|
+
<div className="ui-bulkbar" hidden={n === 0}>
|
|
159
|
+
<label className="ui-table__checkbox"><input ref={barBox} type="checkbox" checked={all} aria-label={selectAllLabel} onChange={(e) => toggleAll(e.target.checked)} /></label>
|
|
160
|
+
<span className="ui-bulkbar__count" aria-live="polite">{selectedLabel(n)}</span>
|
|
161
|
+
<span className="ui-bulkbar__actions">
|
|
162
|
+
{bulkActions!.map((b, i) => (
|
|
163
|
+
<button key={i} type="button" className={cx("ui-btn ui-btn--sm", b.tone === "critical" && "ui-btn--critical")} onClick={() => b.onAction(ids.filter((x) => selSet.has(x)))}>{b.content}</button>
|
|
164
|
+
))}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
<div className="ui-table-wrap">
|
|
169
|
+
<table className="ui-table ui-table--index">
|
|
170
|
+
<caption className="ui-sr-only">{label}</caption>
|
|
171
|
+
<thead>
|
|
172
|
+
<tr>
|
|
173
|
+
{selectable ? <th className="ui-table__check" scope="col"><label className="ui-table__checkbox"><input ref={headBox} type="checkbox" checked={all} aria-label={selectAllLabel} onChange={(e) => toggleAll(e.target.checked)} disabled={!showRows || !rows.length} /></label></th> : null}
|
|
174
|
+
<th className="ui-table__primary" scope="col" aria-sort={ariaSort(primary, sort)}><SortHeader col={primary} sort={sort} onSort={onSort} /></th>
|
|
175
|
+
{rest.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} /></th>)}
|
|
176
|
+
{hasActions ? <th className="ui-table__actions" scope="col"><span className="ui-sr-only">Actions</span></th> : null}
|
|
177
|
+
</tr>
|
|
178
|
+
</thead>
|
|
179
|
+
{showRows ? (
|
|
180
|
+
<tbody>
|
|
181
|
+
{rows.map((r, i) => {
|
|
182
|
+
const isSel = selSet.has(r.id);
|
|
183
|
+
const title = r.href
|
|
184
|
+
? <Link className="ui-table__title" href={r.href} target={r.target} rel={r.target === "_blank" ? "noopener" : undefined} title={r.titleText}>{r.title}</Link>
|
|
185
|
+
: r.title;
|
|
186
|
+
return (
|
|
187
|
+
<tr key={r.id} className={cx(r.href && "is-linked", isSel && "is-selected")} aria-selected={selectable ? isSel : undefined} onClick={r.href ? followRow : undefined} onAuxClick={r.href ? followRow : undefined}>
|
|
188
|
+
{selectable ? (
|
|
189
|
+
<td className="ui-table__check" onClick={(e) => { if (e.target === e.currentTarget) (e.currentTarget.querySelector("input") as HTMLInputElement | null)?.click(); }}>
|
|
190
|
+
<label className="ui-table__checkbox">
|
|
191
|
+
<input type="checkbox" checked={isSel} aria-label={`Select ${r.titleText ?? (typeof r.title === "string" ? r.title : r.id)}`}
|
|
192
|
+
onChange={(e) => toggle(i, e.target.checked, (e.nativeEvent as MouseEvent).shiftKey)} />
|
|
193
|
+
</label>
|
|
194
|
+
</td>
|
|
195
|
+
) : null}
|
|
196
|
+
<td className="ui-table__primary" style={primary.truncate != null ? ({ ["--ui-trunc" as string]: typeof primary.truncate === "number" ? `${primary.truncate}px` : primary.truncate } as React.CSSProperties) : undefined}>
|
|
197
|
+
{r.media ? <div className="ui-table__primary-inner">{r.media}<div className="ui-table__primary-text">{title}{r.sub ? <span className="ui-sub">{r.sub}</span> : null}</div></div>
|
|
198
|
+
: <>{title}{r.sub ? <span className="ui-sub">{r.sub}</span> : null}</>}
|
|
199
|
+
</td>
|
|
200
|
+
{rest.map((c) => (
|
|
201
|
+
<td key={c.key} className={c.numeric ? "is-num" : undefined} data-label={c.label} data-condensed={c.condensed === "hide" ? "hide" : undefined}>
|
|
202
|
+
{c.truncate != null ? <span className="ui-trunc" style={{ ["--ui-trunc" as string]: typeof c.truncate === "number" ? `${c.truncate}px` : c.truncate } as React.CSSProperties}>{r.cells[c.key]}</span> : r.cells[c.key]}
|
|
203
|
+
</td>
|
|
204
|
+
))}
|
|
205
|
+
{hasActions ? <td className="ui-table__actions">{r.actions ? <span className="ui-row-actions">{r.actions}</span> : null}</td> : null}
|
|
206
|
+
</tr>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
209
|
+
</tbody>
|
|
210
|
+
) : null}
|
|
211
|
+
</table>
|
|
212
|
+
{!showRows ? stateSlot : null}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,17 @@ export { PageContainer, pageWidthClass } from "./page";
|
|
|
7
7
|
export type { PageWidth } from "./page";
|
|
8
8
|
// The loading boundary every route group under @gemboss/ui/next needs (0.10.0). Plain module, like ./page.
|
|
9
9
|
export { RouteCover, RouteHolding } from "./route-holding";
|
|
10
|
+
// Collections (0.13.0): tables, lists, grids, boards. Their own modules, like ./page: nothing the admin imports pulls them in
|
|
11
|
+
// (it draws tables with Polaris). ./collections is plain (a server component renders it); ./index-table is "use client".
|
|
12
|
+
export {
|
|
13
|
+
Board, CardGrid, CollectionSkeleton, CollectionState, CompactList, DataTable, MediaCard, ResourceItem, ResourceList, SortHeader, Truncate, ariaSort,
|
|
14
|
+
} from "./collections";
|
|
15
|
+
export type {
|
|
16
|
+
BoardColumn, CardGridMin, CollectionColumn, CollectionRow, CollectionSort, CollectionStateProps, CompactItem, DataTableProps, MediaCardProps,
|
|
17
|
+
ResourceItemProps, ResourceListProps, SkeletonPattern, SortDirection,
|
|
18
|
+
} from "./collections";
|
|
19
|
+
export { IndexTable, followRow, ROW_CONTROLS } from "./index-table";
|
|
20
|
+
export type { BulkAction, IndexTableProps, IndexTableRow } from "./index-table";
|
|
10
21
|
export { GemMark } from "./GemMark";
|
|
11
22
|
export { AuthError, AuthField, AuthFoot, AuthForm, AuthHelp, AuthLayout, PasswordField, AuthSubmit } from "./auth";
|
|
12
23
|
export { BrandLockup } from "./brand";
|