@gemboss/ui 0.13.0 → 0.25.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 +512 -1
- package/README.md +426 -14
- package/catalog/editor.html +1027 -0
- package/catalog/index.html +1215 -90
- package/gemboss-ui.css +3 -4698
- package/package.json +24 -5
- package/parts/components.css +5 -0
- package/parts/core.css +5 -0
- package/parts/shell.css +5 -0
- package/src/app-shell.css +1 -256
- package/src/assistant/engine.tsx +607 -0
- package/src/assistant/host.tsx +1 -0
- package/src/assistant/index.ts +1 -0
- package/src/assistant-engine.css +1 -0
- package/src/assistant.css +1 -558
- package/src/auth.css +1 -606
- package/src/auth.tsx +1 -0
- package/src/board-move.ts +194 -0
- package/src/chat-composer.css +1 -110
- package/src/chat.css +1 -432
- package/src/collections.css +1 -350
- package/src/collections.tsx +170 -40
- package/src/dialog.tsx +3 -135
- package/src/editor/controls.tsx +207 -0
- package/src/editor/css-url.ts +11 -0
- package/src/editor/frame.tsx +243 -0
- package/src/editor/index.ts +52 -0
- package/src/editor/layers.tsx +136 -0
- package/src/editor/panel.tsx +189 -0
- package/src/editor/panes.tsx +45 -0
- package/src/editor/sortable.tsx +79 -0
- package/src/editor/swatch.ts +45 -0
- package/src/editor/tree.tsx +61 -0
- package/src/editor-layers-plain.js +1 -0
- package/src/editor-plain.js +1 -0
- package/src/editor.css +1 -1099
- package/src/gemboss-field.css +1 -112
- package/src/gemboss-tokens.css +1 -164
- package/src/icons.tsx +2 -0
- package/src/index-table.tsx +20 -56
- package/src/index.ts +16 -6
- package/src/kit-plain.css +1 -499
- package/src/kit-plain.js +1 -575
- package/src/launcher.tsx +1 -0
- package/src/menu-layer.ts +94 -0
- package/src/menu.tsx +28 -2
- package/src/portal.css +1 -139
- package/src/portal.tsx +1 -0
- package/src/reactions-data.ts +59 -0
- package/src/reactions.css +1 -0
- package/src/reactions.tsx +140 -0
- package/src/resource-list-select.tsx +73 -0
- package/src/search-dialog.tsx +146 -0
- package/src/segmented-click.ts +20 -0
- package/src/segmented.tsx +98 -0
- package/src/selection.tsx +79 -0
- package/src/shell.css +1 -1079
- package/src/theme-dark.css +1 -66
- package/src/theme-script.ts +10 -0
- package/src/theme.tsx +5 -5
- package/src/ui.css +1 -610
package/src/collections.tsx
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
* Polaris; same reasoning as ./page). The markup is the same as the plain-HTML contract in the catalog, so a surface
|
|
16
16
|
* without React writes these classes by hand and gets the same thing.
|
|
17
17
|
*/
|
|
18
|
+
import "./collections.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
18
19
|
import React from "react";
|
|
20
|
+
import { attachBoardMove } from "./board-move";
|
|
19
21
|
|
|
20
22
|
const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
|
|
21
23
|
|
|
@@ -38,6 +40,11 @@ export type CollectionColumn = {
|
|
|
38
40
|
label?: string;
|
|
39
41
|
/** Cap the cell at this width with an ellipsis (the whole text belongs in the cell's `title`). */
|
|
40
42
|
truncate?: number | string;
|
|
43
|
+
/** DataTable (0.16.0): the column is never narrower than this, for cells that wrap (a title + a sub line, a note), so
|
|
44
|
+
* a narrow frame scrolls instead of squeezing them to a word a line. Polaris `firstColumnMinWidth`, for any column.
|
|
45
|
+
* A row header's column has a floor of its own (9.0625rem, Polaris's first column) that this replaces: `0` removes
|
|
46
|
+
* it for a row header that is always short (a date, an id). */
|
|
47
|
+
minWidth?: number | string;
|
|
41
48
|
};
|
|
42
49
|
|
|
43
50
|
export type CollectionRow = { id: string; cells: Record<string, React.ReactNode> };
|
|
@@ -48,16 +55,23 @@ export function ariaSort(col: CollectionColumn, sort?: CollectionSort | null): R
|
|
|
48
55
|
return sort && sort.key === col.key ? sort.direction : "none";
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
58
|
+
/** Extra props for every sort link (0.16.0), passed to `linkAs`: `{ scroll: false }` for a Next `Link` keeps the page
|
|
59
|
+
* where it is, since a sort is the same page in another order. Plain data, so a server component can pass it. */
|
|
60
|
+
export type SortLinkProps = Record<string, unknown>;
|
|
61
|
+
|
|
62
|
+
/** The header cell's contents: the label, as a link (`sortHref`) or a button (`onSort`) when the column sorts. The link
|
|
63
|
+
* is `linkAs` (0.16.0; default `<a>`), the same element as the rows' titles: a router link sorts without reloading the page. */
|
|
64
|
+
export function SortHeader({ col, sort, onSort, linkAs: Link = "a", linkProps }: { col: CollectionColumn; sort?: CollectionSort | null; onSort?: (key: string, direction: SortDirection) => void;
|
|
65
|
+
linkAs?: React.ElementType; linkProps?: SortLinkProps }) {
|
|
53
66
|
const next: SortDirection = sort && sort.key === col.key && sort.direction === "ascending" ? "descending" : "ascending";
|
|
54
|
-
if (col.sortHref) return <
|
|
67
|
+
if (col.sortHref) return <Link {...linkProps} className="ui-table__sort" href={col.sortHref}>{col.header}</Link>;
|
|
55
68
|
if (col.sortable && onSort) return <button type="button" className="ui-table__sort" onClick={() => onSort(col.key, next)}>{col.header}</button>;
|
|
56
69
|
return <>{col.header}</>;
|
|
57
70
|
}
|
|
58
71
|
|
|
59
72
|
const truncStyle = (t: CollectionColumn["truncate"]): React.CSSProperties | undefined =>
|
|
60
73
|
t == null ? undefined : ({ ["--ui-trunc" as string]: typeof t === "number" ? `${t}px` : t } as React.CSSProperties);
|
|
74
|
+
const minStyle = (w: CollectionColumn["minWidth"]): React.CSSProperties | undefined => (w == null ? undefined : { minWidth: w });
|
|
61
75
|
|
|
62
76
|
export function Truncate({ children, title, width }: { children: React.ReactNode; title?: string; width?: number | string }) {
|
|
63
77
|
return <span className="ui-trunc" title={title} style={truncStyle(width)}>{children}</span>;
|
|
@@ -72,6 +86,11 @@ export type DataTableProps = {
|
|
|
72
86
|
label: string;
|
|
73
87
|
sort?: CollectionSort | null;
|
|
74
88
|
onSort?: (key: string, direction: SortDirection) => void;
|
|
89
|
+
/** The link element for the sort links (`sortHref`): a Next `Link` from a client module, so a sort does not reload the
|
|
90
|
+
* page (0.16.0). Default `<a>`. */
|
|
91
|
+
linkAs?: React.ElementType;
|
|
92
|
+
/** Extra props for those links, e.g. `{ scroll: false }` for a Next `Link` (0.16.0). */
|
|
93
|
+
sortLinkProps?: SortLinkProps;
|
|
75
94
|
/** The first column stays put while the table scrolls sideways (what a data table does below 768). */
|
|
76
95
|
stickyFirstColumn?: boolean;
|
|
77
96
|
/** Rows 32 instead of 36: only for more than 20 rows. */
|
|
@@ -87,7 +106,7 @@ export type DataTableProps = {
|
|
|
87
106
|
className?: string;
|
|
88
107
|
};
|
|
89
108
|
|
|
90
|
-
export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
|
|
109
|
+
export function DataTable({ columns, rows, label, sort, onSort, linkAs, sortLinkProps, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
|
|
91
110
|
if (state === "loading") return <CollectionSkeleton pattern="table" columns={columns} label={label} />;
|
|
92
111
|
const showRows = state === "ready" || state === "busy";
|
|
93
112
|
return (
|
|
@@ -95,7 +114,7 @@ export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColum
|
|
|
95
114
|
<table className={cx("ui-table", stickyFirstColumn && "ui-table--sticky-first", density === "compact" && "ui-table--compact")}>
|
|
96
115
|
<caption className="ui-sr-only">{label}</caption>
|
|
97
116
|
<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>
|
|
117
|
+
<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>
|
|
99
118
|
</thead>
|
|
100
119
|
{showRows ? (
|
|
101
120
|
<tbody>
|
|
@@ -104,7 +123,7 @@ export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColum
|
|
|
104
123
|
{columns.map((c, i) => {
|
|
105
124
|
const body = c.truncate != null ? <span className="ui-trunc" style={truncStyle(c.truncate)}>{r.cells[c.key]}</span> : r.cells[c.key];
|
|
106
125
|
return i === 0 && rowHeaders
|
|
107
|
-
? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"}>{body}</th>
|
|
126
|
+
? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"} style={minStyle(c.minWidth)}>{body}</th>
|
|
108
127
|
: <td key={c.key} className={c.numeric ? "is-num" : undefined}>{body}</td>;
|
|
109
128
|
})}
|
|
110
129
|
</tr>
|
|
@@ -141,13 +160,17 @@ export type ResourceItemProps = {
|
|
|
141
160
|
actions?: React.ReactNode;
|
|
142
161
|
selected?: boolean;
|
|
143
162
|
id?: string;
|
|
163
|
+
/** The selection checkbox, first in the media slot (as Polaris ResourceItem's). `SelectableResourceList` fills it. */
|
|
164
|
+
check?: React.ReactNode;
|
|
144
165
|
};
|
|
145
166
|
|
|
146
|
-
export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id }: ResourceItemProps) {
|
|
167
|
+
export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id, check }: ResourceItemProps) {
|
|
147
168
|
const shown = (badges || []).slice(0, 3), more = (badges || []).length - shown.length;
|
|
148
169
|
return (
|
|
149
|
-
|
|
150
|
-
|
|
170
|
+
// Not aria-selected: a listitem does not take it (jsx-a11y role-supports-aria-props). With a checkbox the box says
|
|
171
|
+
// it; without one, `selected` marks the item open beside the list (master-detail): aria-current.
|
|
172
|
+
<li className={cx("ui-list__item", selected && "is-selected")} id={id} aria-current={selected && !check ? "true" : undefined}>
|
|
173
|
+
{media || check ? <div className="ui-list__media">{check}{media}</div> : null}
|
|
151
174
|
<div className="ui-list__body">
|
|
152
175
|
{href ? <Link className="ui-list__title" href={href} title={titleText}>{title}</Link> : <span className="ui-list__title" title={titleText}>{title}</span>}
|
|
153
176
|
{subtitle ? <p className="ui-list__sub">{subtitle}</p> : null}
|
|
@@ -192,23 +215,41 @@ export type CompactItem = {
|
|
|
192
215
|
icon?: React.ReactNode;
|
|
193
216
|
meta?: React.ReactNode;
|
|
194
217
|
current?: boolean;
|
|
218
|
+
/** The full label as text, for `title` when the label can be cut (not under `wrap`, where it is all on screen). */
|
|
219
|
+
labelText?: string;
|
|
195
220
|
/** activity: the line under the label, and the dot's tone. */
|
|
196
221
|
detail?: React.ReactNode;
|
|
222
|
+
/** activity (0.15.0): the event's own text under the label and the detail (a summary, a note, a post): body size,
|
|
223
|
+
* wraps, never cut (`.ui-list__content`). Paragraphs as `<p>`. Not drawn by the one-line variant. */
|
|
224
|
+
body?: React.ReactNode;
|
|
197
225
|
time?: React.ReactNode;
|
|
198
226
|
dateTime?: string;
|
|
199
227
|
tone?: "success" | "warning" | "critical" | "info";
|
|
200
228
|
};
|
|
201
229
|
|
|
202
|
-
export
|
|
230
|
+
export type CompactListProps = {
|
|
231
|
+
items: CompactItem[];
|
|
232
|
+
label: string;
|
|
233
|
+
variant?: "activity";
|
|
234
|
+
linkAs?: React.ElementType;
|
|
235
|
+
flush?: boolean;
|
|
236
|
+
/** 0.15.0: the label (and an activity's detail) wrap instead of ending in "…" (`ui-list--wrap`). For a list whose
|
|
237
|
+
* label carries data a finger must be able to read (a company, a colleague, who wrote it): `title` is mouse-only. */
|
|
238
|
+
wrap?: boolean;
|
|
239
|
+
className?: string;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
export function CompactList({ items, label, variant, linkAs: Link = "a", flush, wrap, className }: CompactListProps) {
|
|
203
243
|
const activity = variant === "activity";
|
|
204
244
|
return (
|
|
205
|
-
<ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", flush && "ui-list--flush", className)} aria-label={label}>
|
|
245
|
+
<ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", wrap && "ui-list--wrap", flush && "ui-list--flush", className)} aria-label={label}>
|
|
206
246
|
{items.map((it) => activity ? (
|
|
207
247
|
<li key={it.id} className="ui-list__item">
|
|
208
248
|
<span className={cx("ui-list__dot", it.tone && `ui-list__dot--${it.tone}`)} aria-hidden="true" />
|
|
209
249
|
<div>
|
|
210
|
-
{it.href ? <Link className="ui-list__label" href={it.href}>{it.label}</Link> : <span className="ui-list__label">{it.label}</span>}
|
|
250
|
+
{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>}
|
|
211
251
|
{it.detail ? <span className="ui-list__detail">{it.detail}</span> : null}
|
|
252
|
+
{it.body ? <div className="ui-list__content">{it.body}</div> : null}
|
|
212
253
|
</div>
|
|
213
254
|
{it.time ? <time className="ui-list__time" dateTime={it.dateTime}>{it.time}</time> : <span />}
|
|
214
255
|
</li>
|
|
@@ -219,7 +260,7 @@ export function CompactList({ items, label, variant, linkAs: Link = "a", flush,
|
|
|
219
260
|
...(it.href ? { href: it.href, "aria-current": it.current ? "page" : undefined } : it.onClick ? { type: "button", onClick: it.onClick } : {}),
|
|
220
261
|
}, <>
|
|
221
262
|
{it.icon ? <span className="ui-list__icon" aria-hidden="true">{it.icon}</span> : null}
|
|
222
|
-
<span className="ui-list__label">{it.label}</span>
|
|
263
|
+
<span className="ui-list__label" title={wrap ? undefined : it.labelText}>{it.label}</span>
|
|
223
264
|
{it.meta != null ? <span className="ui-list__meta">{it.meta}</span> : null}
|
|
224
265
|
</>)}
|
|
225
266
|
</li>
|
|
@@ -247,15 +288,16 @@ export type MediaCardProps = {
|
|
|
247
288
|
/** The ⋯ menu, in the picture's corner. */
|
|
248
289
|
menu?: React.ReactNode;
|
|
249
290
|
foot?: React.ReactNode;
|
|
250
|
-
/** 16/10 by default; 4/3 for a theme or a page.
|
|
251
|
-
ratio
|
|
291
|
+
/** 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
|
|
292
|
+
* social post. These three only (no free ratio: a grid of mixed shapes loses its rows); any other value draws 16/10. */
|
|
293
|
+
ratio?: "16/10" | "4/3" | "1/1";
|
|
252
294
|
/** Inside CardGrid with a `label`, each card is a list item. */
|
|
253
295
|
listItem?: boolean;
|
|
254
296
|
};
|
|
255
297
|
|
|
256
298
|
export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges, menu, foot, ratio = "16/10", listItem }: MediaCardProps) {
|
|
257
299
|
return (
|
|
258
|
-
<article className="ui-card ui-card--media" data-ratio={ratio === "4/3"
|
|
300
|
+
<article className="ui-card ui-card--media" data-ratio={ratio === "4/3" || ratio === "1/1" ? ratio : undefined} role={listItem ? "listitem" : undefined}>
|
|
259
301
|
{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
302
|
{menu ? <div className="ui-card__menu">{menu}</div> : null}
|
|
261
303
|
<div className="ui-card__body">
|
|
@@ -270,48 +312,134 @@ export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges
|
|
|
270
312
|
|
|
271
313
|
// ── 6 · Board ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
272
314
|
|
|
315
|
+
/** A card with its id (0.18.0): what a move reports and what server paging de-duplicates by. `name` is what the live
|
|
316
|
+
* region says ("Moved <name> to Review"); default the card's title text. */
|
|
317
|
+
export type BoardCard = { id: string; card: React.ReactNode; name?: string };
|
|
318
|
+
|
|
273
319
|
export type BoardColumn = {
|
|
274
320
|
id: string;
|
|
275
321
|
title: React.ReactNode;
|
|
276
322
|
tone?: "success" | "warning" | "critical" | "info";
|
|
277
323
|
/** The cards, already rendered (MediaCard, or a `ui-card ui-card--media` without a picture). */
|
|
278
|
-
cards
|
|
324
|
+
cards?: React.ReactNode[];
|
|
325
|
+
/** 0.18.0: the cards with their ids, instead of `cards`. Needed for `onMove` and for server paging. */
|
|
326
|
+
items?: BoardCard[];
|
|
279
327
|
/** The total when the column holds more than it was given (a server that sent the first page). */
|
|
280
328
|
count?: number;
|
|
281
329
|
empty?: React.ReactNode;
|
|
330
|
+
/** 0.18.0, server paging: the server has more cards for this column than it sent. The column then shows every card
|
|
331
|
+
* it was given (the server already cut it: `boardShown(page)` cards, 12 then 24 more a page) and ends in ONE
|
|
332
|
+
* "Show N more" that asks the server, instead of the nested `<details>` that need every card in the HTML. Default:
|
|
333
|
+
* true when `moreHref` is set; `false` on the last page. */
|
|
334
|
+
hasMore?: boolean;
|
|
335
|
+
/** The URL of this page with this column showing one more page (`?col=ideas&page=2`): the position lives in the URL
|
|
336
|
+
* 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
|
|
337
|
+
* new cards in place (`data-gb-more`); in React it is `linkAs` (a Next `Link` re-renders the server page). */
|
|
338
|
+
moreHref?: string;
|
|
339
|
+
/** A request for the next page is under way (`onLoadMore`): the button becomes a spinner + "Loading…". */
|
|
340
|
+
loading?: boolean;
|
|
282
341
|
};
|
|
283
342
|
|
|
284
|
-
/**
|
|
285
|
-
export function
|
|
286
|
-
|
|
287
|
-
const
|
|
343
|
+
/** Cards a server-paged column shows at `page` (1-based): 12, then 24 more each page (Chris 05/10/2026). */
|
|
344
|
+
export function boardShown(page: number, pageSize = 12, step = 24): number {
|
|
345
|
+
// A page read from a query string that is missing or not a number (NaN) is the first page, not an empty column.
|
|
346
|
+
const p = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
|
|
347
|
+
return pageSize + (p - 1) * step;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export type BoardProps = {
|
|
351
|
+
columns: BoardColumn[];
|
|
352
|
+
label: string;
|
|
353
|
+
/** The board's DOM id, and the prefix of the ids inside (column headings, the move help). Set it when a page has two
|
|
354
|
+
* boards: kit-plain.js reads a fetched page's column from the board with the same id. */
|
|
355
|
+
id?: string;
|
|
356
|
+
pageSize?: number;
|
|
357
|
+
step?: number;
|
|
358
|
+
moreLabel?: (n: number) => React.ReactNode;
|
|
359
|
+
emptyLabel?: React.ReactNode;
|
|
360
|
+
/** 0.18.0: server paging from a client parent: "Show N more" calls this with the column's id (with `moreHref` too,
|
|
361
|
+
* the link keeps its href for a new tab). The parent appends the cards and sets `loading` meanwhile. */
|
|
362
|
+
onLoadMore?: (columnId: string) => void;
|
|
363
|
+
/** The element for the "Show more" links (`moreHref`): a Next `Link` keeps the page in the app. Default `<a>`. */
|
|
364
|
+
linkAs?: React.ElementType;
|
|
365
|
+
/** Extra props for those links, e.g. `{ scroll: false }` for a Next `Link` (the page stays where it was). */
|
|
366
|
+
moreLinkProps?: Record<string, unknown>;
|
|
367
|
+
/** The page is plain HTML driven by kit-plain.js (React only renders it, nothing hydrates: GemFactory): the "Show more"
|
|
368
|
+
* links get `data-gb-more`, so kit-plain.js fetches the next page and appends it in place. Never on a board React
|
|
369
|
+
* owns: kit-plain.js would put foreign nodes in React's lists. */
|
|
370
|
+
fetchMore?: boolean;
|
|
371
|
+
/** 0.18.0: cards move between columns, by dragging or with the keyboard (Space, arrows, Space; board-move.ts). Called
|
|
372
|
+
* once per drop with the card's id, the target column's id and its index there (0 = top, counted without the card).
|
|
373
|
+
* Only cards given as `items` move. Without it the board is read-only, as before. */
|
|
374
|
+
onMove?: (cardId: string, toColumn: string, toIndex: number) => void;
|
|
375
|
+
className?: string;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/** The words a screen reader hears on a movable card (aria-describedby), and the plain-HTML contract's `<p hidden>`. */
|
|
379
|
+
export const BOARD_MOVE_HELP = "Press Space to pick the card up, arrow keys to move it, Space to drop it, Escape to cancel.";
|
|
380
|
+
|
|
381
|
+
/** 12 cards a column, then "Show 24 more" (nested `<details>`, no script): the GemFactory rule, now everyone's. With
|
|
382
|
+
* `hasMore` (0.18.0) the server pages instead; with `onMove` cards move between columns. */
|
|
383
|
+
export function Board({ columns, label, id, pageSize = 12, step = 24, moreLabel = (n) => `Show ${n} more`, emptyLabel = "Nothing here",
|
|
384
|
+
onLoadMore, linkAs: Link = "a", moreLinkProps, fetchMore, onMove, className }: BoardProps) {
|
|
385
|
+
const movable = !!onMove;
|
|
386
|
+
const prefix = id ?? "ui-board";
|
|
387
|
+
const help = `${prefix}-move-help`;
|
|
388
|
+
// A callback ref, not an effect: this module stays plain (a server component renders the board; refs do not run
|
|
389
|
+
// there). attachBoardMove wires the element once and reads the latest onMove at drop time.
|
|
390
|
+
const ref = movable ? (el: HTMLDivElement | null) => { if (el) attachBoardMove(el, (cardId, to, index) => onMove!(cardId, to, index)); } : undefined;
|
|
391
|
+
const entries = (col: BoardColumn): BoardCard[] => col.items ?? (col.cards || []).map((card, i) => ({ id: "", card, name: String(i) }));
|
|
392
|
+
const li = (c: BoardCard, key: React.Key) => c.id
|
|
393
|
+
? <li key={c.id} data-id={c.id} data-name={c.name} {...(movable ? { tabIndex: 0, draggable: true, "aria-describedby": help } : {})}>{c.card}</li>
|
|
394
|
+
: <li key={key}>{c.card}</li>;
|
|
395
|
+
const rest = (cards: BoardCard[], from: number): React.ReactNode => {
|
|
288
396
|
if (from >= cards.length) return null;
|
|
289
397
|
const n = Math.min(step, cards.length - from);
|
|
290
398
|
return (
|
|
291
399
|
<details className="ui-board__more">
|
|
292
400
|
<summary className="ui-btn ui-btn--sm">{moreLabel(n)}</summary>
|
|
293
|
-
<ol className="ui-board__list">{cards.slice(from, from + n).map((c, i) =>
|
|
401
|
+
<ol className="ui-board__list">{cards.slice(from, from + n).map((c, i) => li(c, from + i))}</ol>
|
|
294
402
|
{rest(cards, from + n)}
|
|
295
403
|
</details>
|
|
296
404
|
);
|
|
297
405
|
};
|
|
406
|
+
const next = (col: BoardColumn, shown: number): React.ReactNode => {
|
|
407
|
+
if (!(col.hasMore ?? col.moreHref != null)) return null;
|
|
408
|
+
const n = col.count != null ? Math.min(step, Math.max(col.count - shown, 0)) : step;
|
|
409
|
+
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>;
|
|
410
|
+
const words = n > 0 ? moreLabel(n) : "Show more";
|
|
411
|
+
const ask = onLoadMore ? (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); onLoadMore(col.id); } : undefined;
|
|
412
|
+
return (
|
|
413
|
+
<div className="ui-more ui-board__next">
|
|
414
|
+
{col.moreHref
|
|
415
|
+
? <Link {...moreLinkProps} className="ui-btn ui-btn--sm" href={col.moreHref} data-gb-more={fetchMore ? "" : undefined} onClick={ask}>{words}</Link>
|
|
416
|
+
: <button type="button" className="ui-btn ui-btn--sm" onClick={ask} disabled={!onLoadMore}>{words}</button>}
|
|
417
|
+
</div>
|
|
418
|
+
);
|
|
419
|
+
};
|
|
298
420
|
return (
|
|
299
|
-
<div className={cx("ui-board", className)} role="region" aria-label={label}>
|
|
300
|
-
{
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<
|
|
310
|
-
{
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
421
|
+
<div className={cx("ui-board", className)} id={id} role="region" aria-label={label} ref={ref} data-gb-move={movable ? "" : undefined}>
|
|
422
|
+
{movable ? <p id={help} hidden>{BOARD_MOVE_HELP}</p> : null}
|
|
423
|
+
{movable ? <div className="ui-sr-only" aria-live="assertive" data-gb-live="" /> : null}
|
|
424
|
+
{columns.map((col) => {
|
|
425
|
+
const cards = entries(col);
|
|
426
|
+
const server = col.hasMore != null || col.moreHref != null;
|
|
427
|
+
return (
|
|
428
|
+
<section key={col.id} className="ui-board__col" data-col={col.id} aria-labelledby={`${prefix}-${col.id}`}>
|
|
429
|
+
<header className="ui-board__head">
|
|
430
|
+
<span className={cx("ui-board__dot", col.tone && `ui-board__dot--${col.tone}`)} aria-hidden="true" />
|
|
431
|
+
<span className="ui-board__name" id={`${prefix}-${col.id}`}>{col.title}</span>
|
|
432
|
+
<span className="ui-board__count">{col.count ?? cards.length}</span>
|
|
433
|
+
</header>
|
|
434
|
+
{cards.length ? (
|
|
435
|
+
server
|
|
436
|
+
? <ol className="ui-board__list">{cards.map((c, i) => li(c, i))}</ol>
|
|
437
|
+
: <><ol className="ui-board__list">{cards.slice(0, pageSize).map((c, i) => li(c, i))}</ol>{rest(cards, pageSize)}</>
|
|
438
|
+
) : <p className="ui-board__empty">{col.empty ?? emptyLabel}</p>}
|
|
439
|
+
{server ? next(col, cards.length) : null}
|
|
440
|
+
</section>
|
|
441
|
+
);
|
|
442
|
+
})}
|
|
315
443
|
</div>
|
|
316
444
|
);
|
|
317
445
|
}
|
|
@@ -356,7 +484,8 @@ export type SkeletonPattern = "table" | "index" | "list" | "compact" | "grid" |
|
|
|
356
484
|
*/
|
|
357
485
|
export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "thumbnail", label = "Loading", flush, loadingText = "Loading…", check, actions }:
|
|
358
486
|
{ 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
|
|
487
|
+
/** index: the checkbox and actions columns the real table will have; list (0.16.0): each row's box before its
|
|
488
|
+
* media. So nothing moves when rows arrive. */ check?: boolean; actions?: boolean }) {
|
|
360
489
|
const n = Math.max(1, Math.min(10, rows));
|
|
361
490
|
const line = (w?: string) => <span className="ui-skeleton__line" style={w ? { width: w } : undefined} />;
|
|
362
491
|
const status = <span className="ui-sr-only" role="status">{loadingText}</span>;
|
|
@@ -379,7 +508,8 @@ export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "t
|
|
|
379
508
|
<ul className={cx("ui-list", pattern === "list" ? "ui-skeleton--list" : "ui-list--compact ui-skeleton--compact", flush && "ui-list--flush")} aria-hidden="true">
|
|
380
509
|
{Array.from({ length: n }, (_, i) => pattern === "list" ? (
|
|
381
510
|
<li key={i} className="ui-list__item">
|
|
382
|
-
{media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
|
|
511
|
+
{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>
|
|
512
|
+
: media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
|
|
383
513
|
<span className="ui-list__body">{line()}{line()}</span>
|
|
384
514
|
</li>
|
|
385
515
|
) : (
|
package/src/dialog.tsx
CHANGED
|
@@ -9,12 +9,10 @@
|
|
|
9
9
|
* sign-in and "Create a new post" open like this (Featurebase does both as popups: Chris 04/10/2026,
|
|
10
10
|
* "signup login là bật ra popup đó nhanh và tiện hơn").
|
|
11
11
|
*
|
|
12
|
-
* `SearchDialog
|
|
13
|
-
*
|
|
14
|
-
* (debounced, the latest answer wins); ↑ ↓ move, Enter opens, Esc closes. Results are links, so a
|
|
15
|
-
* page without JavaScript loses only the popup, never the search itself (`fallbackHref`).
|
|
12
|
+
* `SearchDialog` lives in search-dialog.tsx since 0.23.0: it brings portal.css (its own look), and a
|
|
13
|
+
* surface that only opens a Dialog should not download it.
|
|
16
14
|
*/
|
|
17
|
-
import React, {
|
|
15
|
+
import React, { useEffect, useId, useRef } from "react";
|
|
18
16
|
import { Icon } from "./icons";
|
|
19
17
|
|
|
20
18
|
const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
|
|
@@ -63,133 +61,3 @@ export function Dialog({ open, onClose, title, children, footer, wide = false, c
|
|
|
63
61
|
</dialog>
|
|
64
62
|
);
|
|
65
63
|
}
|
|
66
|
-
|
|
67
|
-
export type SearchItem = { href: string; title: string; meta?: React.ReactNode };
|
|
68
|
-
export type SearchSection = { title: string; items: SearchItem[] };
|
|
69
|
-
|
|
70
|
-
// One constant, not a default `= []`: a fresh array each render, read by an effect, re-ran it forever
|
|
71
|
-
// ("Maximum update depth exceeded", review of 0.7.0 in Chromium).
|
|
72
|
-
const NO_SECTIONS: SearchSection[] = [];
|
|
73
|
-
|
|
74
|
-
export function SearchDialog({ search, initial = NO_SECTIONS, placeholder = "Search…", label = "Search", fallbackHref, shortcut = true, renderItem }: {
|
|
75
|
-
/** Results for a query. Called at most every 150 ms; an answer to an older query is dropped. */
|
|
76
|
-
search: (q: string) => Promise<SearchSection[]>;
|
|
77
|
-
/** What the popup shows before anything is typed (Featurebase: "Trending requests", "Latest updates"). */
|
|
78
|
-
initial?: SearchSection[];
|
|
79
|
-
placeholder?: string;
|
|
80
|
-
label?: string;
|
|
81
|
-
/** Where the topbar button goes without JavaScript (a search page), used as given. */
|
|
82
|
-
fallbackHref?: string;
|
|
83
|
-
/** ⌘K / Ctrl+K and "/" open it. */
|
|
84
|
-
shortcut?: boolean;
|
|
85
|
-
renderItem?: (item: SearchItem) => React.ReactNode;
|
|
86
|
-
}) {
|
|
87
|
-
const [open, setOpen] = useState(false);
|
|
88
|
-
const [q, setQ] = useState("");
|
|
89
|
-
const [sections, setSections] = useState<SearchSection[]>(initial);
|
|
90
|
-
const [active, setActive] = useState(0);
|
|
91
|
-
const [busy, setBusy] = useState(false);
|
|
92
|
-
const ref = useRef<HTMLDialogElement>(null);
|
|
93
|
-
const input = useRef<HTMLInputElement>(null);
|
|
94
|
-
const seq = useRef(0);
|
|
95
|
-
const listId = useId();
|
|
96
|
-
const flat = sections.flatMap((s) => s.items);
|
|
97
|
-
// The latest `search` and `initial`, read when needed: a consumer's inline arrow or array (a new one
|
|
98
|
-
// each render) must not restart the debounce or the effect.
|
|
99
|
-
const searchRef = useRef(search);
|
|
100
|
-
const initialRef = useRef(initial);
|
|
101
|
-
searchRef.current = search;
|
|
102
|
-
initialRef.current = initial;
|
|
103
|
-
|
|
104
|
-
const show = useCallback(() => {
|
|
105
|
-
const d = ref.current;
|
|
106
|
-
if (d && !d.open) { d.showModal(); setOpen(true); requestAnimationFrame(() => input.current?.focus()); }
|
|
107
|
-
}, []);
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
if (!shortcut) return undefined;
|
|
110
|
-
const onKey = (e: KeyboardEvent) => {
|
|
111
|
-
const t = e.target as HTMLElement | null;
|
|
112
|
-
const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
113
|
-
// A handler that already took the key (the admin's own ⌘K palette) keeps it.
|
|
114
|
-
if (e.defaultPrevented) return;
|
|
115
|
-
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) { e.preventDefault(); show(); }
|
|
116
|
-
};
|
|
117
|
-
document.addEventListener("keydown", onKey);
|
|
118
|
-
return () => document.removeEventListener("keydown", onKey);
|
|
119
|
-
}, [shortcut, show]);
|
|
120
|
-
useEffect(() => {
|
|
121
|
-
if (!open) return undefined;
|
|
122
|
-
const query = q.trim();
|
|
123
|
-
// Every change takes a new number: an answer still on its way for an older query is dropped,
|
|
124
|
-
// including when the box was just cleared (it used to land under an empty box).
|
|
125
|
-
const mine = ++seq.current;
|
|
126
|
-
if (!query) { setSections(initialRef.current); setActive(0); setBusy(false); return undefined; }
|
|
127
|
-
setBusy(true);
|
|
128
|
-
const t = setTimeout(async () => {
|
|
129
|
-
try {
|
|
130
|
-
const res = await searchRef.current(query);
|
|
131
|
-
if (mine === seq.current) { setSections(res); setActive(0); }
|
|
132
|
-
} catch {
|
|
133
|
-
if (mine === seq.current) setSections(NO_SECTIONS);
|
|
134
|
-
} finally {
|
|
135
|
-
if (mine === seq.current) setBusy(false);
|
|
136
|
-
}
|
|
137
|
-
}, 150);
|
|
138
|
-
return () => { clearTimeout(t); if (mine === seq.current) setBusy(false); };
|
|
139
|
-
}, [q, open]);
|
|
140
|
-
// Before anything is typed, show the latest suggestions the page passed.
|
|
141
|
-
useEffect(() => { if (open && !q.trim()) setSections(initial); }, [initial, open, q]);
|
|
142
|
-
// The highlighted result stays in view as the arrows move it.
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
if (!open) return;
|
|
145
|
-
document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" });
|
|
146
|
-
}, [active, open, listId]);
|
|
147
|
-
|
|
148
|
-
const go = (item: SearchItem | undefined) => { if (item) window.location.assign(item.href); };
|
|
149
|
-
let n = -1;
|
|
150
|
-
return (
|
|
151
|
-
<>
|
|
152
|
-
<a className="gb-search-trigger" href={fallbackHref ?? "#"} aria-label={label} aria-haspopup="dialog"
|
|
153
|
-
onClick={(e) => { e.preventDefault(); show(); }}>
|
|
154
|
-
<Icon name="search" size={18} />
|
|
155
|
-
</a>
|
|
156
|
-
<dialog ref={ref} className="ui-modal gb-search" aria-label={label}
|
|
157
|
-
onClose={() => { setOpen(false); setQ(""); }}
|
|
158
|
-
onClick={(e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }}>
|
|
159
|
-
<div className="gb-search-box">
|
|
160
|
-
<Icon name="search" size={18} className="gb-search-icon" />
|
|
161
|
-
<input ref={input} className="gb-search-input" type="search" placeholder={placeholder} aria-label={label}
|
|
162
|
-
value={q} onChange={(e) => setQ(e.target.value)} role="combobox" aria-expanded="true" aria-controls={listId}
|
|
163
|
-
aria-activedescendant={flat[active] ? `${listId}-${active}` : undefined} autoComplete="off"
|
|
164
|
-
onKeyDown={(e) => {
|
|
165
|
-
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, flat.length - 1)); }
|
|
166
|
-
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
|
167
|
-
// Enter that confirms an IME composition (Vietnamese, Chinese) is not "open".
|
|
168
|
-
else if (e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); go(flat[active]); }
|
|
169
|
-
}} />
|
|
170
|
-
</div>
|
|
171
|
-
<p className="gb-search-empty" aria-live="polite">
|
|
172
|
-
{sections.every((s) => !s.items.length) && q.trim() ? (busy ? "Searching…" : "Nothing matches.") : ""}
|
|
173
|
-
</p>
|
|
174
|
-
<div className="gb-search-results" id={listId} role="listbox" aria-busy={busy}>
|
|
175
|
-
{sections.filter((s) => s.items.length).map((s) => (
|
|
176
|
-
<div key={s.title} className="gb-search-section" role="group" aria-label={s.title}>
|
|
177
|
-
<div className="gb-search-section-title">{s.title}</div>
|
|
178
|
-
{s.items.map((it) => {
|
|
179
|
-
n += 1;
|
|
180
|
-
const i = n;
|
|
181
|
-
return (
|
|
182
|
-
<a key={`${s.title}-${it.href}`} id={`${listId}-${i}`} href={it.href} role="option" aria-selected={i === active}
|
|
183
|
-
className={cx("gb-search-item", i === active && "is-active")} onMouseMove={() => setActive(i)}>
|
|
184
|
-
{renderItem ? renderItem(it) : (<><span className="gb-search-item-title">{it.title}</span>{it.meta ? <span className="gb-search-item-meta">{it.meta}</span> : null}</>)}
|
|
185
|
-
</a>
|
|
186
|
-
);
|
|
187
|
-
})}
|
|
188
|
-
</div>
|
|
189
|
-
))}
|
|
190
|
-
</div>
|
|
191
|
-
<div className="gb-search-foot" aria-hidden="true"><span>↵ open</span><span>↑↓ move</span><span>esc</span></div>
|
|
192
|
-
</dialog>
|
|
193
|
-
</>
|
|
194
|
-
);
|
|
195
|
-
}
|