@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.
- package/CHANGELOG.md +362 -1
- package/README.md +253 -12
- package/catalog/editor.html +611 -0
- package/catalog/index.html +998 -25
- package/gemboss-ui.css +3 -4698
- package/package.json +15 -4
- package/src/app-shell.css +1 -256
- package/src/assistant.css +1 -558
- package/src/auth.css +1 -606
- 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 +169 -40
- package/src/editor/controls.tsx +206 -0
- package/src/editor/css-url.ts +11 -0
- package/src/editor/frame.tsx +242 -0
- package/src/editor/index.ts +43 -0
- package/src/editor/panel.tsx +188 -0
- package/src/editor/panes.tsx +44 -0
- package/src/editor/swatch.ts +45 -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 +19 -56
- package/src/index.ts +12 -4
- package/src/kit-plain.css +1 -499
- package/src/kit-plain.js +1 -575
- package/src/menu-layer.ts +87 -0
- package/src/menu.tsx +28 -2
- package/src/portal.css +1 -139
- package/src/reactions-data.ts +59 -0
- package/src/reactions.css +1 -0
- package/src/reactions.tsx +139 -0
- package/src/resource-list-select.tsx +72 -0
- package/src/segmented-click.ts +20 -0
- package/src/segmented.tsx +98 -0
- package/src/selection.tsx +78 -0
- package/src/shell.css +1 -1079
- package/src/theme-dark.css +1 -66
- package/src/ui.css +1 -610
package/src/collections.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
52
|
-
|
|
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 <
|
|
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
|
-
|
|
150
|
-
|
|
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
|
|
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
|
|
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"
|
|
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
|
|
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
|
-
/**
|
|
285
|
-
export function
|
|
286
|
-
|
|
287
|
-
const
|
|
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) =>
|
|
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
|
-
{
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
<
|
|
310
|
-
{
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
|
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
|
+
}
|