@gemboss/ui 0.13.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,7 @@
16
16
  * without React writes these classes by hand and gets the same thing.
17
17
  */
18
18
  import React from "react";
19
+ import { attachBoardMove } from "./board-move";
19
20
 
20
21
  const cx = (...c: Array<string | false | null | undefined>) => c.filter(Boolean).join(" ");
21
22
 
@@ -38,6 +39,11 @@ export type CollectionColumn = {
38
39
  label?: string;
39
40
  /** Cap the cell at this width with an ellipsis (the whole text belongs in the cell's `title`). */
40
41
  truncate?: number | string;
42
+ /** DataTable (0.16.0): the column is never narrower than this, for cells that wrap (a title + a sub line, a note), so
43
+ * a narrow frame scrolls instead of squeezing them to a word a line. Polaris `firstColumnMinWidth`, for any column.
44
+ * A row header's column has a floor of its own (9.0625rem, Polaris's first column) that this replaces: `0` removes
45
+ * it for a row header that is always short (a date, an id). */
46
+ minWidth?: number | string;
41
47
  };
42
48
 
43
49
  export type CollectionRow = { id: string; cells: Record<string, React.ReactNode> };
@@ -48,16 +54,23 @@ export function ariaSort(col: CollectionColumn, sort?: CollectionSort | null): R
48
54
  return sort && sort.key === col.key ? sort.direction : "none";
49
55
  }
50
56
 
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 }) {
57
+ /** Extra props for every sort link (0.16.0), passed to `linkAs`: `{ scroll: false }` for a Next `Link` keeps the page
58
+ * where it is, since a sort is the same page in another order. Plain data, so a server component can pass it. */
59
+ export type SortLinkProps = Record<string, unknown>;
60
+
61
+ /** The header cell's contents: the label, as a link (`sortHref`) or a button (`onSort`) when the column sorts. The link
62
+ * is `linkAs` (0.16.0; default `<a>`), the same element as the rows' titles: a router link sorts without reloading the page. */
63
+ export function SortHeader({ col, sort, onSort, linkAs: Link = "a", linkProps }: { col: CollectionColumn; sort?: CollectionSort | null; onSort?: (key: string, direction: SortDirection) => void;
64
+ linkAs?: React.ElementType; linkProps?: SortLinkProps }) {
53
65
  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>;
66
+ if (col.sortHref) return <Link {...linkProps} className="ui-table__sort" href={col.sortHref}>{col.header}</Link>;
55
67
  if (col.sortable && onSort) return <button type="button" className="ui-table__sort" onClick={() => onSort(col.key, next)}>{col.header}</button>;
56
68
  return <>{col.header}</>;
57
69
  }
58
70
 
59
71
  const truncStyle = (t: CollectionColumn["truncate"]): React.CSSProperties | undefined =>
60
72
  t == null ? undefined : ({ ["--ui-trunc" as string]: typeof t === "number" ? `${t}px` : t } as React.CSSProperties);
73
+ const minStyle = (w: CollectionColumn["minWidth"]): React.CSSProperties | undefined => (w == null ? undefined : { minWidth: w });
61
74
 
62
75
  export function Truncate({ children, title, width }: { children: React.ReactNode; title?: string; width?: number | string }) {
63
76
  return <span className="ui-trunc" title={title} style={truncStyle(width)}>{children}</span>;
@@ -72,6 +85,11 @@ export type DataTableProps = {
72
85
  label: string;
73
86
  sort?: CollectionSort | null;
74
87
  onSort?: (key: string, direction: SortDirection) => void;
88
+ /** The link element for the sort links (`sortHref`): a Next `Link` from a client module, so a sort does not reload the
89
+ * page (0.16.0). Default `<a>`. */
90
+ linkAs?: React.ElementType;
91
+ /** Extra props for those links, e.g. `{ scroll: false }` for a Next `Link` (0.16.0). */
92
+ sortLinkProps?: SortLinkProps;
75
93
  /** The first column stays put while the table scrolls sideways (what a data table does below 768). */
76
94
  stickyFirstColumn?: boolean;
77
95
  /** Rows 32 instead of 36: only for more than 20 rows. */
@@ -87,7 +105,7 @@ export type DataTableProps = {
87
105
  className?: string;
88
106
  };
89
107
 
90
- export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
108
+ export function DataTable({ columns, rows, label, sort, onSort, linkAs, sortLinkProps, stickyFirstColumn, density = "default", rowHeaders = true, totals, footer, state = "ready", stateSlot, className }: DataTableProps) {
91
109
  if (state === "loading") return <CollectionSkeleton pattern="table" columns={columns} label={label} />;
92
110
  const showRows = state === "ready" || state === "busy";
93
111
  return (
@@ -95,7 +113,7 @@ export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColum
95
113
  <table className={cx("ui-table", stickyFirstColumn && "ui-table--sticky-first", density === "compact" && "ui-table--compact")}>
96
114
  <caption className="ui-sr-only">{label}</caption>
97
115
  <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>
116
+ <tr>{columns.map((c) => <th key={c.key} scope="col" className={c.numeric ? "is-num" : undefined} style={minStyle(c.minWidth)} aria-sort={ariaSort(c, sort)}><SortHeader col={c} sort={sort} onSort={onSort} linkAs={linkAs} linkProps={sortLinkProps} /></th>)}</tr>
99
117
  </thead>
100
118
  {showRows ? (
101
119
  <tbody>
@@ -104,7 +122,7 @@ export function DataTable({ columns, rows, label, sort, onSort, stickyFirstColum
104
122
  {columns.map((c, i) => {
105
123
  const body = c.truncate != null ? <span className="ui-trunc" style={truncStyle(c.truncate)}>{r.cells[c.key]}</span> : r.cells[c.key];
106
124
  return i === 0 && rowHeaders
107
- ? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"}>{body}</th>
125
+ ? <th key={c.key} scope="row" className={c.numeric ? "ui-table__rowhead is-num" : "ui-table__rowhead"} style={minStyle(c.minWidth)}>{body}</th>
108
126
  : <td key={c.key} className={c.numeric ? "is-num" : undefined}>{body}</td>;
109
127
  })}
110
128
  </tr>
@@ -141,13 +159,17 @@ export type ResourceItemProps = {
141
159
  actions?: React.ReactNode;
142
160
  selected?: boolean;
143
161
  id?: string;
162
+ /** The selection checkbox, first in the media slot (as Polaris ResourceItem's). `SelectableResourceList` fills it. */
163
+ check?: React.ReactNode;
144
164
  };
145
165
 
146
- export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id }: ResourceItemProps) {
166
+ export function ResourceItem({ title, href, linkAs: Link = "a", titleText, media, subtitle, meta, badges, aside, actions, selected, id, check }: ResourceItemProps) {
147
167
  const shown = (badges || []).slice(0, 3), more = (badges || []).length - shown.length;
148
168
  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}
169
+ // Not aria-selected: a listitem does not take it (jsx-a11y role-supports-aria-props). With a checkbox the box says
170
+ // it; without one, `selected` marks the item open beside the list (master-detail): aria-current.
171
+ <li className={cx("ui-list__item", selected && "is-selected")} id={id} aria-current={selected && !check ? "true" : undefined}>
172
+ {media || check ? <div className="ui-list__media">{check}{media}</div> : null}
151
173
  <div className="ui-list__body">
152
174
  {href ? <Link className="ui-list__title" href={href} title={titleText}>{title}</Link> : <span className="ui-list__title" title={titleText}>{title}</span>}
153
175
  {subtitle ? <p className="ui-list__sub">{subtitle}</p> : null}
@@ -192,23 +214,41 @@ export type CompactItem = {
192
214
  icon?: React.ReactNode;
193
215
  meta?: React.ReactNode;
194
216
  current?: boolean;
217
+ /** The full label as text, for `title` when the label can be cut (not under `wrap`, where it is all on screen). */
218
+ labelText?: string;
195
219
  /** activity: the line under the label, and the dot's tone. */
196
220
  detail?: React.ReactNode;
221
+ /** activity (0.15.0): the event's own text under the label and the detail (a summary, a note, a post): body size,
222
+ * wraps, never cut (`.ui-list__content`). Paragraphs as `<p>`. Not drawn by the one-line variant. */
223
+ body?: React.ReactNode;
197
224
  time?: React.ReactNode;
198
225
  dateTime?: string;
199
226
  tone?: "success" | "warning" | "critical" | "info";
200
227
  };
201
228
 
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 }) {
229
+ export type CompactListProps = {
230
+ items: CompactItem[];
231
+ label: string;
232
+ variant?: "activity";
233
+ linkAs?: React.ElementType;
234
+ flush?: boolean;
235
+ /** 0.15.0: the label (and an activity's detail) wrap instead of ending in "…" (`ui-list--wrap`). For a list whose
236
+ * label carries data a finger must be able to read (a company, a colleague, who wrote it): `title` is mouse-only. */
237
+ wrap?: boolean;
238
+ className?: string;
239
+ };
240
+
241
+ export function CompactList({ items, label, variant, linkAs: Link = "a", flush, wrap, className }: CompactListProps) {
203
242
  const activity = variant === "activity";
204
243
  return (
205
- <ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", flush && "ui-list--flush", className)} aria-label={label}>
244
+ <ul className={cx("ui-list ui-list--compact", activity && "ui-list--activity", wrap && "ui-list--wrap", flush && "ui-list--flush", className)} aria-label={label}>
206
245
  {items.map((it) => activity ? (
207
246
  <li key={it.id} className="ui-list__item">
208
247
  <span className={cx("ui-list__dot", it.tone && `ui-list__dot--${it.tone}`)} aria-hidden="true" />
209
248
  <div>
210
- {it.href ? <Link className="ui-list__label" href={it.href}>{it.label}</Link> : <span className="ui-list__label">{it.label}</span>}
249
+ {it.href ? <Link className="ui-list__label" href={it.href} title={wrap ? undefined : it.labelText}>{it.label}</Link> : <span className="ui-list__label" title={wrap ? undefined : it.labelText}>{it.label}</span>}
211
250
  {it.detail ? <span className="ui-list__detail">{it.detail}</span> : null}
251
+ {it.body ? <div className="ui-list__content">{it.body}</div> : null}
212
252
  </div>
213
253
  {it.time ? <time className="ui-list__time" dateTime={it.dateTime}>{it.time}</time> : <span />}
214
254
  </li>
@@ -219,7 +259,7 @@ export function CompactList({ items, label, variant, linkAs: Link = "a", flush,
219
259
  ...(it.href ? { href: it.href, "aria-current": it.current ? "page" : undefined } : it.onClick ? { type: "button", onClick: it.onClick } : {}),
220
260
  }, <>
221
261
  {it.icon ? <span className="ui-list__icon" aria-hidden="true">{it.icon}</span> : null}
222
- <span className="ui-list__label">{it.label}</span>
262
+ <span className="ui-list__label" title={wrap ? undefined : it.labelText}>{it.label}</span>
223
263
  {it.meta != null ? <span className="ui-list__meta">{it.meta}</span> : null}
224
264
  </>)}
225
265
  </li>
@@ -247,15 +287,16 @@ export type MediaCardProps = {
247
287
  /** The ⋯ menu, in the picture's corner. */
248
288
  menu?: React.ReactNode;
249
289
  foot?: React.ReactNode;
250
- /** 16/10 by default; 4/3 for a theme or a page. */
251
- ratio?: "16/10" | "4/3";
290
+ /** 16/10 by default; 4/3 for a theme or a page; 1/1 (0.18.0) for a square picture: a moodboard, a product shot, a
291
+ * social post. These three only (no free ratio: a grid of mixed shapes loses its rows); any other value draws 16/10. */
292
+ ratio?: "16/10" | "4/3" | "1/1";
252
293
  /** Inside CardGrid with a `label`, each card is a list item. */
253
294
  listItem?: boolean;
254
295
  };
255
296
 
256
297
  export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges, menu, foot, ratio = "16/10", listItem }: MediaCardProps) {
257
298
  return (
258
- <article className="ui-card ui-card--media" data-ratio={ratio === "4/3" ? "4/3" : undefined} role={listItem ? "listitem" : undefined}>
299
+ <article className="ui-card ui-card--media" data-ratio={ratio === "4/3" || ratio === "1/1" ? ratio : undefined} role={listItem ? "listitem" : undefined}>
259
300
  {media == null ? null : href ? <Link className="ui-card__media" href={href} tabIndex={-1} aria-hidden="true">{media}</Link> : <div className="ui-card__media">{media}</div>}
260
301
  {menu ? <div className="ui-card__menu">{menu}</div> : null}
261
302
  <div className="ui-card__body">
@@ -270,48 +311,134 @@ export function MediaCard({ title, href, linkAs: Link = "a", media, meta, badges
270
311
 
271
312
  // ── 6 · Board ──────────────────────────────────────────────────────────────────────────────────────────────────────
272
313
 
314
+ /** A card with its id (0.18.0): what a move reports and what server paging de-duplicates by. `name` is what the live
315
+ * region says ("Moved <name> to Review"); default the card's title text. */
316
+ export type BoardCard = { id: string; card: React.ReactNode; name?: string };
317
+
273
318
  export type BoardColumn = {
274
319
  id: string;
275
320
  title: React.ReactNode;
276
321
  tone?: "success" | "warning" | "critical" | "info";
277
322
  /** The cards, already rendered (MediaCard, or a `ui-card ui-card--media` without a picture). */
278
- cards: React.ReactNode[];
323
+ cards?: React.ReactNode[];
324
+ /** 0.18.0: the cards with their ids, instead of `cards`. Needed for `onMove` and for server paging. */
325
+ items?: BoardCard[];
279
326
  /** The total when the column holds more than it was given (a server that sent the first page). */
280
327
  count?: number;
281
328
  empty?: React.ReactNode;
329
+ /** 0.18.0, server paging: the server has more cards for this column than it sent. The column then shows every card
330
+ * it was given (the server already cut it: `boardShown(page)` cards, 12 then 24 more a page) and ends in ONE
331
+ * "Show N more" that asks the server, instead of the nested `<details>` that need every card in the HTML. Default:
332
+ * true when `moreHref` is set; `false` on the last page. */
333
+ hasMore?: boolean;
334
+ /** The URL of this page with this column showing one more page (`?col=ideas&page=2`): the position lives in the URL
335
+ * like `ui-pager`'s, so it works with no script, Back and a sent link keep it. kit-plain.js fetches it and appends the
336
+ * new cards in place (`data-gb-more`); in React it is `linkAs` (a Next `Link` re-renders the server page). */
337
+ moreHref?: string;
338
+ /** A request for the next page is under way (`onLoadMore`): the button becomes a spinner + "Loading…". */
339
+ loading?: boolean;
282
340
  };
283
341
 
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 => {
342
+ /** Cards a server-paged column shows at `page` (1-based): 12, then 24 more each page (Chris 05/10/2026). */
343
+ export function boardShown(page: number, pageSize = 12, step = 24): number {
344
+ // A page read from a query string that is missing or not a number (NaN) is the first page, not an empty column.
345
+ const p = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1;
346
+ return pageSize + (p - 1) * step;
347
+ }
348
+
349
+ export type BoardProps = {
350
+ columns: BoardColumn[];
351
+ label: string;
352
+ /** The board's DOM id, and the prefix of the ids inside (column headings, the move help). Set it when a page has two
353
+ * boards: kit-plain.js reads a fetched page's column from the board with the same id. */
354
+ id?: string;
355
+ pageSize?: number;
356
+ step?: number;
357
+ moreLabel?: (n: number) => React.ReactNode;
358
+ emptyLabel?: React.ReactNode;
359
+ /** 0.18.0: server paging from a client parent: "Show N more" calls this with the column's id (with `moreHref` too,
360
+ * the link keeps its href for a new tab). The parent appends the cards and sets `loading` meanwhile. */
361
+ onLoadMore?: (columnId: string) => void;
362
+ /** The element for the "Show more" links (`moreHref`): a Next `Link` keeps the page in the app. Default `<a>`. */
363
+ linkAs?: React.ElementType;
364
+ /** Extra props for those links, e.g. `{ scroll: false }` for a Next `Link` (the page stays where it was). */
365
+ moreLinkProps?: Record<string, unknown>;
366
+ /** The page is plain HTML driven by kit-plain.js (React only renders it, nothing hydrates: GemFactory): the "Show more"
367
+ * links get `data-gb-more`, so kit-plain.js fetches the next page and appends it in place. Never on a board React
368
+ * owns: kit-plain.js would put foreign nodes in React's lists. */
369
+ fetchMore?: boolean;
370
+ /** 0.18.0: cards move between columns, by dragging or with the keyboard (Space, arrows, Space; board-move.ts). Called
371
+ * once per drop with the card's id, the target column's id and its index there (0 = top, counted without the card).
372
+ * Only cards given as `items` move. Without it the board is read-only, as before. */
373
+ onMove?: (cardId: string, toColumn: string, toIndex: number) => void;
374
+ className?: string;
375
+ };
376
+
377
+ /** The words a screen reader hears on a movable card (aria-describedby), and the plain-HTML contract's `<p hidden>`. */
378
+ export const BOARD_MOVE_HELP = "Press Space to pick the card up, arrow keys to move it, Space to drop it, Escape to cancel.";
379
+
380
+ /** 12 cards a column, then "Show 24 more" (nested `<details>`, no script): the GemFactory rule, now everyone's. With
381
+ * `hasMore` (0.18.0) the server pages instead; with `onMove` cards move between columns. */
382
+ export function Board({ columns, label, id, pageSize = 12, step = 24, moreLabel = (n) => `Show ${n} more`, emptyLabel = "Nothing here",
383
+ onLoadMore, linkAs: Link = "a", moreLinkProps, fetchMore, onMove, className }: BoardProps) {
384
+ const movable = !!onMove;
385
+ const prefix = id ?? "ui-board";
386
+ const help = `${prefix}-move-help`;
387
+ // A callback ref, not an effect: this module stays plain (a server component renders the board; refs do not run
388
+ // there). attachBoardMove wires the element once and reads the latest onMove at drop time.
389
+ const ref = movable ? (el: HTMLDivElement | null) => { if (el) attachBoardMove(el, (cardId, to, index) => onMove!(cardId, to, index)); } : undefined;
390
+ const entries = (col: BoardColumn): BoardCard[] => col.items ?? (col.cards || []).map((card, i) => ({ id: "", card, name: String(i) }));
391
+ const li = (c: BoardCard, key: React.Key) => c.id
392
+ ? <li key={c.id} data-id={c.id} data-name={c.name} {...(movable ? { tabIndex: 0, draggable: true, "aria-describedby": help } : {})}>{c.card}</li>
393
+ : <li key={key}>{c.card}</li>;
394
+ const rest = (cards: BoardCard[], from: number): React.ReactNode => {
288
395
  if (from >= cards.length) return null;
289
396
  const n = Math.min(step, cards.length - from);
290
397
  return (
291
398
  <details className="ui-board__more">
292
399
  <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>
400
+ <ol className="ui-board__list">{cards.slice(from, from + n).map((c, i) => li(c, from + i))}</ol>
294
401
  {rest(cards, from + n)}
295
402
  </details>
296
403
  );
297
404
  };
405
+ const next = (col: BoardColumn, shown: number): React.ReactNode => {
406
+ if (!(col.hasMore ?? col.moreHref != null)) return null;
407
+ const n = col.count != null ? Math.min(step, Math.max(col.count - shown, 0)) : step;
408
+ if (col.loading) return <div className="ui-more ui-board__next" aria-busy="true"><span className="ui-spinner" aria-hidden="true" /><span role="status">Loading…</span></div>;
409
+ const words = n > 0 ? moreLabel(n) : "Show more";
410
+ const ask = onLoadMore ? (e: React.MouseEvent) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return; e.preventDefault(); onLoadMore(col.id); } : undefined;
411
+ return (
412
+ <div className="ui-more ui-board__next">
413
+ {col.moreHref
414
+ ? <Link {...moreLinkProps} className="ui-btn ui-btn--sm" href={col.moreHref} data-gb-more={fetchMore ? "" : undefined} onClick={ask}>{words}</Link>
415
+ : <button type="button" className="ui-btn ui-btn--sm" onClick={ask} disabled={!onLoadMore}>{words}</button>}
416
+ </div>
417
+ );
418
+ };
298
419
  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
- ))}
420
+ <div className={cx("ui-board", className)} id={id} role="region" aria-label={label} ref={ref} data-gb-move={movable ? "" : undefined}>
421
+ {movable ? <p id={help} hidden>{BOARD_MOVE_HELP}</p> : null}
422
+ {movable ? <div className="ui-sr-only" aria-live="assertive" data-gb-live="" /> : null}
423
+ {columns.map((col) => {
424
+ const cards = entries(col);
425
+ const server = col.hasMore != null || col.moreHref != null;
426
+ return (
427
+ <section key={col.id} className="ui-board__col" data-col={col.id} aria-labelledby={`${prefix}-${col.id}`}>
428
+ <header className="ui-board__head">
429
+ <span className={cx("ui-board__dot", col.tone && `ui-board__dot--${col.tone}`)} aria-hidden="true" />
430
+ <span className="ui-board__name" id={`${prefix}-${col.id}`}>{col.title}</span>
431
+ <span className="ui-board__count">{col.count ?? cards.length}</span>
432
+ </header>
433
+ {cards.length ? (
434
+ server
435
+ ? <ol className="ui-board__list">{cards.map((c, i) => li(c, i))}</ol>
436
+ : <><ol className="ui-board__list">{cards.slice(0, pageSize).map((c, i) => li(c, i))}</ol>{rest(cards, pageSize)}</>
437
+ ) : <p className="ui-board__empty">{col.empty ?? emptyLabel}</p>}
438
+ {server ? next(col, cards.length) : null}
439
+ </section>
440
+ );
441
+ })}
315
442
  </div>
316
443
  );
317
444
  }
@@ -356,7 +483,8 @@ export type SkeletonPattern = "table" | "index" | "list" | "compact" | "grid" |
356
483
  */
357
484
  export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "thumbnail", label = "Loading", flush, loadingText = "Loading…", check, actions }:
358
485
  { 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 }) {
486
+ /** index: the checkbox and actions columns the real table will have; list (0.16.0): each row's box before its
487
+ * media. So nothing moves when rows arrive. */ check?: boolean; actions?: boolean }) {
360
488
  const n = Math.max(1, Math.min(10, rows));
361
489
  const line = (w?: string) => <span className="ui-skeleton__line" style={w ? { width: w } : undefined} />;
362
490
  const status = <span className="ui-sr-only" role="status">{loadingText}</span>;
@@ -379,7 +507,8 @@ export function CollectionSkeleton({ pattern, rows = 5, columns = [], media = "t
379
507
  <ul className={cx("ui-list", pattern === "list" ? "ui-skeleton--list" : "ui-list--compact ui-skeleton--compact", flush && "ui-list--flush")} aria-hidden="true">
380
508
  {Array.from({ length: n }, (_, i) => pattern === "list" ? (
381
509
  <li key={i} className="ui-list__item">
382
- {media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
510
+ {check ? <span className="ui-list__media"><span className="ui-list__check" />{media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}</span>
511
+ : media !== "none" ? <span className={cx("ui-skeleton__block", media === "avatar" && "ui-skeleton__block--round")} /> : null}
383
512
  <span className="ui-list__body">{line()}{line()}</span>
384
513
  </li>
385
514
  ) : (
@@ -0,0 +1,206 @@
1
+ "use client";
2
+ // The settings panel's CONTROLS (0.17.0, step 3 of 5 of the editor kit): segmented, colour, slider, the four-edge px box
3
+ // and the image slot. Each takes a value and gives one back through `onChange`; nothing here knows what is edited, what
4
+ // schema a value came from or where it is saved. Wrap each in `Field` (panel.tsx) for its label and hint.
5
+ //
6
+ // Moved from the admin (apps/admin: FieldRenderer.tsx, element-inspector-controls.tsx) with the markup, the class names
7
+ // and the commit rules they had there, so the admin's editors draw and behave exactly as before:
8
+ // · text-like boxes keep a DRAFT and commit on blur, Enter blurs (a live preview is not hammered per keystroke);
9
+ // · the colour swatch commits on every change, the colour's text box on blur / Enter;
10
+ // · the slider commits on every change (its number);
11
+ // · the edge box hands each keystroke to the caller (`onChange(side, raw)`) and says when to commit (`onCommit`, on
12
+ // blur): the caller decides what an empty edge means, and the admin has three answers to that on purpose.
13
+ import { useEffect, useState } from "react";
14
+ import type React from "react";
15
+ import { cssUrl } from "./css-url";
16
+ import { swatchState } from "./swatch";
17
+ import { Segmented } from "../segmented";
18
+
19
+ const blurOnEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
20
+ if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); }
21
+ };
22
+
23
+ // ── Seg ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
24
+
25
+ export interface SegOption<T extends string = string> {
26
+ value: T;
27
+ /** Default: the value itself. */
28
+ label?: React.ReactNode;
29
+ }
30
+
31
+ export interface SegProps<T extends string = string> {
32
+ options: ReadonlyArray<SegOption<T>>;
33
+ /** The pressed option. A value that matches none presses none. */
34
+ value?: string;
35
+ onChange: (value: T) => void;
36
+ disabled?: boolean;
37
+ /** The group's accessible name (`role="group"`): the field's label, said again for a screen reader. */
38
+ label?: string;
39
+ /** On the group (the admin narrows two-option rows with a max-width). */
40
+ style?: React.CSSProperties;
41
+ }
42
+
43
+ /**
44
+ * The settings panel's switch: the kit's `Segmented` stretched to its row (`.ui-seg--fill`), the same look as every
45
+ * other switch in the editors (0.20.0; it was a grey track with a raised white tab of its own). `gemboss-ei-seg` stays on
46
+ * the group as the hook editor-plain.js finds, and each button carries its `data-value`.
47
+ */
48
+ export function Seg<T extends string = string>({ options, value, onChange, disabled, label, style }: SegProps<T>) {
49
+ return (
50
+ <Segmented<T> fill className="gemboss-ei-seg" style={style} ariaLabel={label ?? ""} value={(value ?? "") as T | ""} onChange={onChange} disabled={disabled}
51
+ options={options.map((o) => ({ value: o.value, label: o.label ?? o.value }))} />
52
+ );
53
+ }
54
+
55
+ // ── ColorControl ────────────────────────────────────────────────────────────────────────────────────────────────────
56
+
57
+ export interface ColorControlProps {
58
+ /** The text box's id (the Field's `htmlFor`). */
59
+ id?: string;
60
+ value: string;
61
+ /** A committed value: on every swatch change, and on the text box's blur / Enter. */
62
+ onChange: (value: string) => void;
63
+ disabled?: boolean;
64
+ /** The field's name; the swatch is announced as "<label> swatch". */
65
+ label: string;
66
+ placeholder?: string;
67
+ }
68
+
69
+ /**
70
+ * `.gemboss-ei-color`: a native swatch beside a text box. The swatch is DISABLED (with a title saying why) for anything
71
+ * but a 6-digit hex (`swatchState`): `<input type="color">` would show black for `#f00`, `rgba()`, `var()` or a gradient,
72
+ * and overwrite it with black on the first touch.
73
+ */
74
+ export function ColorControl({ id, value, onChange, disabled, label, placeholder }: ColorControlProps) {
75
+ const [draft, setDraft] = useState(value);
76
+ useEffect(() => { setDraft(value); }, [value]);
77
+ const { swatch, usable } = swatchState(draft);
78
+ return (
79
+ <div className="gemboss-ei-color">
80
+ <input type="color" className="gemboss-ei-swatch" aria-label={`${label} swatch`} value={swatch}
81
+ disabled={disabled || !usable}
82
+ title={usable ? undefined : `${draft} — not a plain 6-digit hex; edit it in the box below`}
83
+ onChange={(e) => { setDraft(e.target.value); onChange(e.target.value); }} />
84
+ <input id={id} className="gemboss-field gemboss-ei-inp" placeholder={placeholder ?? "#000000 / var(--…)"} value={draft} disabled={disabled}
85
+ onChange={(e) => setDraft(e.target.value)} onBlur={() => onChange(draft)} onKeyDown={blurOnEnter} />
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // ── RangeControl ────────────────────────────────────────────────────────────────────────────────────────────────────
91
+
92
+ export interface RangeControlProps {
93
+ id?: string;
94
+ min: number;
95
+ max: number;
96
+ /** Default 1. */
97
+ step?: number;
98
+ value: number;
99
+ /** Every change (drag, ←/→, Home/End, PageUp/PageDown), as a number. */
100
+ onChange: (value: number) => void;
101
+ disabled?: boolean;
102
+ }
103
+
104
+ /** `input.gemboss-ei-range` (the native slider in the primary colour). Show its value with `Field`'s `labelValue`. */
105
+ export function RangeControl({ id, min, max, step, value, onChange, disabled }: RangeControlProps) {
106
+ return (
107
+ <input id={id} type="range" className="gemboss-ei-range" min={min} max={max} step={step ?? 1}
108
+ value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
109
+ );
110
+ }
111
+
112
+ // ── EdgeBox ─────────────────────────────────────────────────────────────────────────────────────────────────────────
113
+
114
+ export type EdgeSide = "t" | "r" | "b" | "l";
115
+ const SIDES: readonly EdgeSide[] = ["t", "r", "b", "l"];
116
+ const SIDE_NAME: Record<EdgeSide, string> = { t: "top", r: "right", b: "bottom", l: "left" };
117
+
118
+ export interface EdgeBoxProps {
119
+ /** The field's name; each box is announced as "<label> top / right / bottom / left". */
120
+ label: string;
121
+ /** What each box shows. A number, the raw text being typed, or empty. */
122
+ values: Partial<Record<EdgeSide, string | number>>;
123
+ /** Per side; default "0". The admin shows the inherited desktop value here while editing the phone override. */
124
+ placeholders?: Partial<Record<EdgeSide, string>>;
125
+ /** Every keystroke in one box. */
126
+ onChange: (side: EdgeSide, raw: string) => void;
127
+ /** A box lost focus (Enter blurs): commit what the boxes hold. */
128
+ onCommit: () => void;
129
+ disabled?: boolean;
130
+ /** `gemboss-field--narrow` on each box (the field family's tight lane). Default true. */
131
+ narrow?: boolean;
132
+ /** Ids for the boxes, per side. */
133
+ ids?: Partial<Record<EdgeSide, string>>;
134
+ }
135
+
136
+ /** `.gemboss-ei-edgebox`: four px boxes, T · R · B · L, mono and centred. */
137
+ export function EdgeBox({ label, values, placeholders, onChange, onCommit, disabled, narrow = true, ids }: EdgeBoxProps) {
138
+ const cls = narrow ? "gemboss-field gemboss-field--narrow gemboss-ei-inp" : "gemboss-field gemboss-ei-inp";
139
+ return (
140
+ <div className="gemboss-ei-edgebox">
141
+ {SIDES.map((s) => (
142
+ <div className="gemboss-ei-edge" key={s}>
143
+ <input className={cls} inputMode="numeric" placeholder={placeholders?.[s] ?? "0"}
144
+ aria-label={`${label} ${SIDE_NAME[s]}`} id={ids?.[s]} value={values[s] ?? ""} disabled={disabled}
145
+ onChange={(e) => onChange(s, e.target.value)} onBlur={onCommit} onKeyDown={blurOnEnter} />
146
+ <span>{s.toUpperCase()}</span>
147
+ </div>
148
+ ))}
149
+ </div>
150
+ );
151
+ }
152
+
153
+ // ── ImageControl ────────────────────────────────────────────────────────────────────────────────────────────────────
154
+
155
+ /** A dashed pill button (`.gemboss-ei-addchip`): an image slot's Library / Generate / Clear, a panel's "+ Add". */
156
+ export function ChipButton({ disabled, expanded, onClick, children }: {
157
+ disabled?: boolean;
158
+ /** `aria-expanded`, for a chip that opens something under it (the Library grid). */
159
+ expanded?: boolean;
160
+ onClick?: React.MouseEventHandler<HTMLButtonElement>;
161
+ children: React.ReactNode;
162
+ }) {
163
+ return <button type="button" className="gemboss-ei-addchip" disabled={disabled} aria-expanded={expanded} onClick={onClick}>{children}</button>;
164
+ }
165
+
166
+ export interface ImageControlProps {
167
+ /** The URL box's id. */
168
+ id?: string;
169
+ /** The committed URL: what the thumbnail shows. */
170
+ value: string;
171
+ /** A committed URL: on blur / Enter when the box differs from `value`, and "" from Clear. */
172
+ onChange: (url: string) => void;
173
+ disabled?: boolean;
174
+ placeholder?: string;
175
+ /**
176
+ * The URL box's text, when the caller holds it (to put a picked or generated URL in the box at once, before `value`
177
+ * comes back). Omitted, the control keeps its own draft and resets it whenever `value` changes.
178
+ */
179
+ draft?: string;
180
+ onDraftChange?: (text: string) => void;
181
+ /** Chips before Clear (`ChipButton`): Library, Generate. */
182
+ actions?: React.ReactNode;
183
+ /** Draw Clear while there is a value. Default true. */
184
+ clearable?: boolean;
185
+ }
186
+
187
+ /** `.gemboss-ei-img`: a 52px thumbnail (— when empty) beside the URL box and its chips. */
188
+ export function ImageControl({ id, value, onChange, disabled, placeholder, draft, onDraftChange, actions, clearable = true }: ImageControlProps) {
189
+ const [own, setOwn] = useState(value);
190
+ useEffect(() => { setOwn(value); }, [value]);
191
+ const text = draft ?? own;
192
+ const setText = (next: string) => { if (draft === undefined) setOwn(next); onDraftChange?.(next); };
193
+ return (
194
+ <div className="gemboss-ei-img">
195
+ <div className="gemboss-ei-img-thumb" style={value ? { backgroundImage: cssUrl(value) } : undefined} aria-hidden>{value ? null : "—"}</div>
196
+ <div className="gemboss-ei-img-ctl">
197
+ <input id={id} className="gemboss-field gemboss-ei-inp" placeholder={placeholder ?? "Paste image URL…"} value={text} disabled={disabled}
198
+ onChange={(e) => setText(e.target.value)} onBlur={() => { if (text !== value) onChange(text); }} onKeyDown={blurOnEnter} />
199
+ <div className="gemboss-ei-img-actions">
200
+ {actions}
201
+ {clearable && value ? <ChipButton disabled={disabled} onClick={() => { setText(""); onChange(""); }}>Clear</ChipButton> : null}
202
+ </div>
203
+ </div>
204
+ </div>
205
+ );
206
+ }
@@ -0,0 +1,11 @@
1
+ // A CSS `url("…")` for any image URL (0.20.1). Plain TypeScript, no "use client": a server component may build a
2
+ // background with it (also `@gemboss/ui/editor/css-url`).
3
+ //
4
+ // Unquoted, a URL holding ")" or a space ends the token early, the declaration is dropped, and the thumbnail shows
5
+ // nothing. Quoted, only `"` and `\` need a backslash; a control character (tab, newline, …) is written as a hex escape
6
+ // followed by a space, the one form CSS reads back as that character. (JSON.stringify is NOT this: CSS reads its `\n`
7
+ // as "n" and its `\u0001` as the text "u0001".) editor-plain.js writes the same thing; editor-panel-browser.test.tsx
8
+ // holds the two to one answer.
9
+ export function cssUrl(url: string): string {
10
+ return `url("${url.replace(/["\\]/g, "\\$&").replace(/[\u0000-\u001f\u007f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}")`;
11
+ }