@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +512 -1
  2. package/README.md +426 -14
  3. package/catalog/editor.html +1027 -0
  4. package/catalog/index.html +1215 -90
  5. package/gemboss-ui.css +3 -4698
  6. package/package.json +24 -5
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/app-shell.css +1 -256
  11. package/src/assistant/engine.tsx +607 -0
  12. package/src/assistant/host.tsx +1 -0
  13. package/src/assistant/index.ts +1 -0
  14. package/src/assistant-engine.css +1 -0
  15. package/src/assistant.css +1 -558
  16. package/src/auth.css +1 -606
  17. package/src/auth.tsx +1 -0
  18. package/src/board-move.ts +194 -0
  19. package/src/chat-composer.css +1 -110
  20. package/src/chat.css +1 -432
  21. package/src/collections.css +1 -350
  22. package/src/collections.tsx +170 -40
  23. package/src/dialog.tsx +3 -135
  24. package/src/editor/controls.tsx +207 -0
  25. package/src/editor/css-url.ts +11 -0
  26. package/src/editor/frame.tsx +243 -0
  27. package/src/editor/index.ts +52 -0
  28. package/src/editor/layers.tsx +136 -0
  29. package/src/editor/panel.tsx +189 -0
  30. package/src/editor/panes.tsx +45 -0
  31. package/src/editor/sortable.tsx +79 -0
  32. package/src/editor/swatch.ts +45 -0
  33. package/src/editor/tree.tsx +61 -0
  34. package/src/editor-layers-plain.js +1 -0
  35. package/src/editor-plain.js +1 -0
  36. package/src/editor.css +1 -1099
  37. package/src/gemboss-field.css +1 -112
  38. package/src/gemboss-tokens.css +1 -164
  39. package/src/icons.tsx +2 -0
  40. package/src/index-table.tsx +20 -56
  41. package/src/index.ts +16 -6
  42. package/src/kit-plain.css +1 -499
  43. package/src/kit-plain.js +1 -575
  44. package/src/launcher.tsx +1 -0
  45. package/src/menu-layer.ts +94 -0
  46. package/src/menu.tsx +28 -2
  47. package/src/portal.css +1 -139
  48. package/src/portal.tsx +1 -0
  49. package/src/reactions-data.ts +59 -0
  50. package/src/reactions.css +1 -0
  51. package/src/reactions.tsx +140 -0
  52. package/src/resource-list-select.tsx +73 -0
  53. package/src/search-dialog.tsx +146 -0
  54. package/src/segmented-click.ts +20 -0
  55. package/src/segmented.tsx +98 -0
  56. package/src/selection.tsx +79 -0
  57. package/src/shell.css +1 -1079
  58. package/src/theme-dark.css +1 -66
  59. package/src/theme-script.ts +10 -0
  60. package/src/theme.tsx +5 -5
  61. package/src/ui.css +1 -610
@@ -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
- /** 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 }) {
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 <a className="ui-table__sort" href={col.sortHref}>{col.header}</a>;
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
- <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}
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 function CompactList({ items, label, variant, linkAs: Link = "a", flush, className }: { items: CompactItem[]; label: string; variant?: "activity"; linkAs?: React.ElementType; flush?: boolean; className?: string }) {
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?: "16/10" | "4/3";
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" ? "4/3" : undefined} role={listItem ? "listitem" : undefined}>
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: React.ReactNode[];
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
- /** 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 => {
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) => <li key={from + i}>{c}</li>)}</ol>
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
- {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
- ))}
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, so no column moves when rows arrive. */ check?: boolean; actions?: boolean }) {
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`: the Featurebase / admin command search. A topbar button (or ⌘K / Ctrl+K / "/")
13
- * opens it; what to show before typing comes from `initial`, results while typing from `search`
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, { useCallback, useEffect, useId, useRef, useState } from "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
- }