@gemboss/ui 0.36.1 → 0.40.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 +441 -0
- package/README.md +617 -14
- package/catalog/editor.html +45 -25
- package/catalog/index.html +5522 -1448
- package/gemboss-ui.css +3 -3
- package/package.json +11 -2
- package/parts/components.css +2 -2
- package/parts/core.css +1 -1
- package/parts/shell.css +2 -2
- package/src/assistant/chat-markdown.ts +7 -4
- package/src/assistant.css +1 -1
- package/src/auth-field.css +1 -0
- package/src/auth-field.tsx +74 -0
- package/src/auth-sso-plain.js +1 -0
- package/src/auth.css +1 -1
- package/src/auth.tsx +64 -1
- package/src/brand-gem.tsx +19 -0
- package/src/brand.tsx +4 -7
- package/src/chat-plain.js +2 -2
- package/src/editor/controls.tsx +148 -11
- package/src/editor/frame.tsx +8 -4
- package/src/editor/index.ts +8 -4
- package/src/editor/panel.tsx +44 -14
- package/src/editor/repeater.tsx +322 -0
- package/src/editor/sortable.tsx +50 -24
- package/src/editor/swatch.ts +20 -0
- package/src/editor/tiles.tsx +51 -0
- package/src/editor-plain.js +1 -1
- package/src/editor-repeater.css +1 -0
- package/src/editor-tiles.css +1 -0
- package/src/editor.css +1 -1
- package/src/gemboss-field.css +1 -1
- package/src/icons.tsx +11 -0
- package/src/index.ts +14 -2
- package/src/kit-plain.js +1 -1
- package/src/service-switcher-plain.js +1 -1
- package/src/service-switcher.css +1 -1
- package/src/service-switcher.tsx +119 -35
- package/src/services.ts +4 -2
- package/src/shell.css +1 -1
- package/src/site.css +1 -0
- package/src/site.tsx +50 -0
- package/src/team-chat-data.ts +79 -0
- package/src/team-chat.css +1 -0
- package/src/team-chat.tsx +825 -0
- package/src/toggle-bar.tsx +60 -0
- package/src/ui.css +1 -1
package/src/editor/index.ts
CHANGED
|
@@ -36,10 +36,10 @@ export {
|
|
|
36
36
|
type FieldProps, type InspectorGroupProps, type PanelHeaderProps, type PanelTab, type PanelTabsProps,
|
|
37
37
|
} from "./panel";
|
|
38
38
|
export {
|
|
39
|
-
ChipButton, ColorControl, EdgeBox, ImageControl, RangeControl, Seg,
|
|
40
|
-
type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps,
|
|
39
|
+
ChipButton, ColorControl, EdgeBox, ImageControl, RangeControl, Seg, UnitField, ValueControl,
|
|
40
|
+
type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps, type UnitFieldProps, type ValueControlProps,
|
|
41
41
|
} from "./controls";
|
|
42
|
-
export { swatchState, type SwatchState } from "./swatch";
|
|
42
|
+
export { swatchState, swatchWhy, type SwatchState } from "./swatch";
|
|
43
43
|
export { cssUrl } from "./css-url";
|
|
44
44
|
// Step 4 (0.22.0): the page's layers and the drawer (`layers`, stateless; the page editor draws them), a list whose rows
|
|
45
45
|
// reorder (`sortable`, "use client": a drag holds state; no admin screen uses it), and the element outline (`tree`, the
|
|
@@ -48,5 +48,9 @@ export {
|
|
|
48
48
|
EditorDrawer, LayerList, layerName,
|
|
49
49
|
type EditorDrawerProps, type LayerItem, type LayerListProps,
|
|
50
50
|
} from "./layers";
|
|
51
|
-
export { SortableLayerList, type SortableLayerListProps } from "./sortable";
|
|
51
|
+
export { SortableLayerList, type SortableLayerListProps, useRowDrag, dropIndex } from "./sortable";
|
|
52
|
+
// 16/10/2026: a setting that is a list, as rows (drag, duplicate, fold); its own CSS part, editor-repeater.css.
|
|
53
|
+
export { RepeaterList, useKeyedRows, type RepeaterListProps, type KeyedRows } from "./repeater";
|
|
54
|
+
// 16/10/2026: a closed choice of shapes (an image's ratio, a layout) as picture tiles; its own CSS part, editor-tiles.css.
|
|
55
|
+
export { ChoiceTiles, type ChoiceTile, type ChoiceTilesProps } from "./tiles";
|
|
52
56
|
export { LayerTree, type LayerTreeProps, type LayerTreeRow } from "./tree";
|
package/src/editor/panel.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// InspectorGroup's own open/closed when the caller does not hold it. Controls (Seg, Color, Range, EdgeBox, Image) are
|
|
6
6
|
// controls.tsx: their own module, because only a screen that edits values draws them.
|
|
7
7
|
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
8
|
-
import { useState } from "react";
|
|
8
|
+
import { useId, useState } from "react";
|
|
9
9
|
import type React from "react";
|
|
10
10
|
|
|
11
11
|
// ── PanelTabs ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -112,7 +112,7 @@ export function PanelAction({ label, title, disabled, onClick, children }: {
|
|
|
112
112
|
// ── InspectorGroup ──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
113
113
|
|
|
114
114
|
export interface InspectorGroupProps {
|
|
115
|
-
/** The header:
|
|
115
|
+
/** The header: 13px / 650, sentence case (`.gemboss-ei-gh`). */
|
|
116
116
|
title?: React.ReactNode;
|
|
117
117
|
/** A short aside after the title, in the header's own line ("· all screens"). Collapsible groups only. */
|
|
118
118
|
note?: React.ReactNode;
|
|
@@ -129,15 +129,21 @@ export interface InspectorGroupProps {
|
|
|
129
129
|
collapsible?: boolean;
|
|
130
130
|
/** A muted line after the body (`p.gemboss-ei-hint`). */
|
|
131
131
|
hint?: React.ReactNode;
|
|
132
|
+
/**
|
|
133
|
+
* `data-*` attributes on the group itself (`.gemboss-ei-grp`, or the static `section`): what a page finds the group by, without a
|
|
134
|
+
* wrapper. A wrapper made every group the `:last-child` of its own parent, so the divider between groups was dropped (control
|
|
135
|
+
* standard §13b, 19/10/2026).
|
|
136
|
+
*/
|
|
137
|
+
attrs?: Record<`data-${string}`, string>;
|
|
132
138
|
children?: React.ReactNode;
|
|
133
139
|
}
|
|
134
140
|
|
|
135
|
-
export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenChange, collapsible = true, hint, children }: InspectorGroupProps) {
|
|
141
|
+
export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenChange, collapsible = true, hint, attrs, children }: InspectorGroupProps) {
|
|
136
142
|
const [own, setOwn] = useState(defaultOpen);
|
|
137
143
|
const hintNode = hint ? <p className="gemboss-ei-hint">{hint}</p> : null;
|
|
138
144
|
if (!collapsible) {
|
|
139
145
|
return (
|
|
140
|
-
<section className="gemboss-ei-group">
|
|
146
|
+
<section className="gemboss-ei-group" {...attrs}>
|
|
141
147
|
{title ? <h4 className="gemboss-ei-grouphead">{title}</h4> : null}
|
|
142
148
|
{children}
|
|
143
149
|
{hintNode}
|
|
@@ -146,7 +152,7 @@ export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenCh
|
|
|
146
152
|
}
|
|
147
153
|
const isOpen = open ?? own;
|
|
148
154
|
return (
|
|
149
|
-
<div className="gemboss-ei-grp">
|
|
155
|
+
<div className="gemboss-ei-grp" {...attrs}>
|
|
150
156
|
<button type="button" className="gemboss-ei-gh" data-open={isOpen ? "open" : "closed"} aria-expanded={isOpen}
|
|
151
157
|
onClick={() => { if (open === undefined) setOwn(!isOpen); onOpenChange?.(!isOpen); }}>
|
|
152
158
|
<span className="gemboss-ei-chev" aria-hidden>{isOpen ? "▾" : "▸"}</span>{title}
|
|
@@ -161,29 +167,53 @@ export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenCh
|
|
|
161
167
|
// ── Field ───────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
162
168
|
|
|
163
169
|
export interface FieldProps {
|
|
164
|
-
/** 13px
|
|
170
|
+
/** 13px above the control (beside it in a `row`). `undefined` / `null` draws no label (a control named by its own aria-label). */
|
|
165
171
|
label?: React.ReactNode;
|
|
166
172
|
/** The control's id, so a click on the label focuses it. */
|
|
167
173
|
htmlFor?: string;
|
|
168
174
|
/** A value shown at the label's right edge, bold and tabular (a range's current value: `.gemboss-ei-rangeval`). */
|
|
169
175
|
labelValue?: React.ReactNode;
|
|
170
|
-
/**
|
|
176
|
+
/** The setting's help (`p.gemboss-ei-hint.gemboss-ei-help-text`). In a plugin's form a line under the control; inside an
|
|
177
|
+
* inspector group (`InspectorGroup`) a ? beside the label opens and closes it (0.39.0). */
|
|
171
178
|
hint?: React.ReactNode;
|
|
179
|
+
/** A one-value setting (a number, a select, a colour, chips): inside an inspector group the label goes LEFT and the control
|
|
180
|
+
* right, one row (0.39.0). Outside a group nothing changes. */
|
|
181
|
+
row?: boolean;
|
|
172
182
|
/** Added to `.gemboss-ei-fld` (the admin's `togrow`). */
|
|
173
183
|
className?: string;
|
|
174
184
|
style?: React.CSSProperties;
|
|
175
185
|
children?: React.ReactNode;
|
|
176
186
|
}
|
|
177
187
|
|
|
178
|
-
/**
|
|
179
|
-
|
|
188
|
+
/**
|
|
189
|
+
* One labelled setting: `div.gemboss-ei-fld` › label · control · help. 12px under each but the last.
|
|
190
|
+
*
|
|
191
|
+
* INSIDE AN INSPECTOR GROUP (0.39.0, Chris 16/10/2026: the settings panel looked bigger than the kit and Polaris; research
|
|
192
|
+
* `_local/gemboss/research/inspector-density-2026-10-16.md`). The controls were Polaris-sized already (field 32, chips 28);
|
|
193
|
+
* the panel was long: a label line over every control (70px a setting) and help text under it, 22% of a countdown's panel.
|
|
194
|
+
* There a `row` is label left, control right (GemPages: 52px a setting), the label is quiet (bodyMd, secondary) and the help
|
|
195
|
+
* is behind a ? (GemPages, PageFly). Outside a group, a plugin's settings form, a help line under a wide field is the
|
|
196
|
+
* Polaris form and stays.
|
|
197
|
+
*/
|
|
198
|
+
export function Field({ label, htmlFor, labelValue, hint, row = false, className, style, children }: FieldProps) {
|
|
199
|
+
const helpId = useId();
|
|
200
|
+
const [helpOpen, setHelpOpen] = useState(false);
|
|
201
|
+
const cls = ["gemboss-ei-fld", row && label != null ? "gemboss-ei-fld--row" : "", className].filter(Boolean).join(" ");
|
|
202
|
+
const labelNode = label != null ? (
|
|
203
|
+
<label htmlFor={htmlFor}>{label}{labelValue != null ? <span className="gemboss-ei-rangeval">{labelValue}</span> : null}</label>
|
|
204
|
+
) : null;
|
|
180
205
|
return (
|
|
181
|
-
<div className={
|
|
182
|
-
{label
|
|
183
|
-
|
|
184
|
-
|
|
206
|
+
<div className={cls} style={style}>
|
|
207
|
+
{/* A ? is a button, so it sits BESIDE the label, not in it (a label may hold no other labelable element). */}
|
|
208
|
+
{hint && labelNode ? (
|
|
209
|
+
<div className="gemboss-ei-lbl">
|
|
210
|
+
{labelNode}
|
|
211
|
+
<button type="button" className="gemboss-ei-help" aria-label={typeof label === "string" ? `About ${label}` : "About this setting"} aria-describedby={helpId} aria-expanded={helpOpen}
|
|
212
|
+
onClick={() => setHelpOpen((o) => !o)} onKeyDown={(e) => { if (e.key === "Escape") setHelpOpen(false); }}>?</button>
|
|
213
|
+
</div>
|
|
214
|
+
) : labelNode}
|
|
185
215
|
{children}
|
|
186
|
-
{hint ? <p className="gemboss-ei-hint">{hint}</p> : null}
|
|
216
|
+
{hint ? <p className="gemboss-ei-hint gemboss-ei-help-text" id={helpId}>{hint}</p> : null}
|
|
187
217
|
</div>
|
|
188
218
|
);
|
|
189
219
|
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// REPEATER LIST (Chris 16/10/2026: the panel was missing pickers; research
|
|
3
|
+
// `_local/gemboss/research/pickers-2026-10-16/pickers.md` §9 and pattern P11). A setting that is a list (an accordion's items,
|
|
4
|
+
// a pricing table's plans, choice cards) is the element's CONTENT, and the panel drew it as boxes always open, moved one place
|
|
5
|
+
// at a time with ↑ ↓. PageFly and GemPages draw it as rows: a grip to drag, the row's name, duplicate, delete, folded until
|
|
6
|
+
// opened. This is that, for every repeater the admin draws (the Canvas panel and the page editor), one look.
|
|
7
|
+
//
|
|
8
|
+
// · the header is the row: a grip (drag it; HTML drag and drop does not start from a touch, so ↑ ↓ stay), the name (its
|
|
9
|
+
// first field, else "Item N") as the button that folds it, then ↑ ↓, duplicate and ✕;
|
|
10
|
+
// · with more than one row the list opens folded; a row that arrives later (added, duplicated) opens, it is the one
|
|
11
|
+
// being written;
|
|
12
|
+
// · Alt+↑ / Alt+↓ on a row's name move it, and the focus stays on it;
|
|
13
|
+
// · the count beside the list's name.
|
|
14
|
+
// A row's body is drawn only while it is open: a folded row holds no controls (check:page-revision-touchable measures every
|
|
15
|
+
// control the panel shows).
|
|
16
|
+
//
|
|
17
|
+
// Rows are kept by KEY (useKeyedRows), not by index: a row's open state and its controls' unsaved typing follow it when it
|
|
18
|
+
// moves, and a field writes to its row by key. The drag is useRowDrag (sortable.tsx), the same rule as the Layers list.
|
|
19
|
+
//
|
|
20
|
+
// ITEMS IN A POPOVER (item management IM1a, Chris 20/10/2026, GemPages 7.5's "Item management" read in its code): with
|
|
21
|
+
// `editIn="popover"` the list stays one line per row and the row picked shows its controls in a layer under the list (the
|
|
22
|
+
// browser's top layer, placed like the kit's menus, menu-layer.ts), one row at a time; the other rows stay in view and a click
|
|
23
|
+
// on one moves the layer to it. It closes on its ✕, on Escape (the focus goes back to the row) or on the row clicked again,
|
|
24
|
+
// never on a click elsewhere: a row's controls open pickers and dialogs of their own. The host hears the row picked
|
|
25
|
+
// (`onActive`), to show that item on the canvas. A row's picture (`thumb`, its first image) sits before its name.
|
|
26
|
+
// One row: its controls show in place, open, as the inline list draws it (nothing to choose between; a layer would cost a click).
|
|
27
|
+
// No popover API: the row picked opens in place, as the inline list does.
|
|
28
|
+
import "../editor.css"; // the field and the 44px rule under a finger (`.gemboss-ei-fld`)
|
|
29
|
+
import "../editor-repeater.css"; // its own part (16/10/2026): a surface that never draws a list never loads it
|
|
30
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
|
|
31
|
+
import type React from "react";
|
|
32
|
+
import { canFloatMenus, placeFloating } from "../menu-layer";
|
|
33
|
+
import { useRowDrag } from "./sortable";
|
|
34
|
+
|
|
35
|
+
let lastKey = 0;
|
|
36
|
+
const freshKey = () => `row-${++lastKey}`;
|
|
37
|
+
|
|
38
|
+
export interface KeyedRows<T> {
|
|
39
|
+
/** The rows shown: the stored list, or the one just written while its save is on the way. */
|
|
40
|
+
rows: readonly T[];
|
|
41
|
+
/** One stable key per row, in the same order. */
|
|
42
|
+
keys: readonly string[];
|
|
43
|
+
move: (from: number, to: number) => void;
|
|
44
|
+
/** A copy right after it, with a key of its own. */
|
|
45
|
+
duplicate: (index: number) => void;
|
|
46
|
+
remove: (index: number) => void;
|
|
47
|
+
add: (row: T) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Change the row with this KEY, from its latest version: `change` gets the row as it is now, not as it was when the field
|
|
50
|
+
* was drawn. A control that commits late (a colour or a slider holds its write 250 ms, and flushes it when it unmounts)
|
|
51
|
+
* keeps the key it was drawn with, so a move in between changes the right row, and a removed row is not brought back (no
|
|
52
|
+
* row with that key: nothing is written). Review of #4506: by index and whole row, that write doubled one row and lost another.
|
|
53
|
+
*/
|
|
54
|
+
patch: (key: string, change: (row: T) => T) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A stored list with a stable key per row. Each change writes the WHOLE list (`commit`) and shows it at once.
|
|
59
|
+
*
|
|
60
|
+
* · `commit` may answer (a Promise): `false` or a rejection puts the stored list back, unless a later write is on its way
|
|
61
|
+
* (its answer decides). Without an answer what was written stays shown until the stored list says otherwise.
|
|
62
|
+
* · the stored list is read by CONTENT: a caller that hands a new array each render (`rows ?? []`) does not reset what is
|
|
63
|
+
* shown (review of #4506: a row added to an unset list vanished).
|
|
64
|
+
* · a stored list equal to one of our writes is that write coming back: what is shown (maybe newer) stays; any other is a
|
|
65
|
+
* change from elsewhere (an undo, the chat) and replaces it, the keys kept by position.
|
|
66
|
+
*/
|
|
67
|
+
export function useKeyedRows<T>(value: readonly T[], commit: (next: T[]) => void | PromiseLike<unknown>): KeyedRows<T> {
|
|
68
|
+
const [state, setState] = useState(() => ({ rows: value.slice(), keys: value.map(() => freshKey()) }));
|
|
69
|
+
const latest = useRef(state);
|
|
70
|
+
latest.current = state;
|
|
71
|
+
const seen = JSON.stringify(value);
|
|
72
|
+
// The stored list, its text, and the keys it is shown with: what a refused write goes back to.
|
|
73
|
+
const stored = useRef<{ rows: readonly T[]; json: string; keys: string[] }>({ rows: value, json: seen, keys: state.keys });
|
|
74
|
+
// Our writes on their way, oldest first.
|
|
75
|
+
const pending = useRef<Array<{ n: number; json: string; keys: string[] }>>([]);
|
|
76
|
+
const writes = useRef(0);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (seen === stored.current.json) return;
|
|
79
|
+
const mine = pending.current.findIndex((p) => p.json === seen);
|
|
80
|
+
if (mine >= 0) {
|
|
81
|
+
stored.current = { rows: value, json: seen, keys: pending.current[mine]!.keys };
|
|
82
|
+
pending.current = pending.current.slice(mine + 1);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
pending.current = [];
|
|
86
|
+
const keys = value.map((_, i) => latest.current.keys[i] ?? freshKey());
|
|
87
|
+
stored.current = { rows: value, json: seen, keys };
|
|
88
|
+
latest.current = { rows: value.slice(), keys };
|
|
89
|
+
setState(latest.current);
|
|
90
|
+
// By content (`seen`): a new array with the same rows is not a change.
|
|
91
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
92
|
+
}, [seen]);
|
|
93
|
+
const refused = (n: number) => {
|
|
94
|
+
pending.current = pending.current.filter((p) => p.n !== n);
|
|
95
|
+
if (n !== writes.current) return;
|
|
96
|
+
latest.current = { rows: stored.current.rows.slice(), keys: stored.current.keys.slice() };
|
|
97
|
+
setState(latest.current);
|
|
98
|
+
};
|
|
99
|
+
const write = (rows: T[], keys: string[]) => {
|
|
100
|
+
const n = ++writes.current;
|
|
101
|
+
pending.current.push({ n, json: JSON.stringify(rows), keys });
|
|
102
|
+
latest.current = { rows, keys };
|
|
103
|
+
setState(latest.current);
|
|
104
|
+
const answer = commit(rows);
|
|
105
|
+
if (answer && typeof (answer as PromiseLike<unknown>).then === "function") {
|
|
106
|
+
(answer as PromiseLike<unknown>).then((ok) => { if (ok === false) refused(n); }, () => refused(n));
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const splice = <X,>(xs: readonly X[], at: number, remove: number, ...add: X[]) => { const next = xs.slice(); next.splice(at, remove, ...add); return next; };
|
|
110
|
+
return {
|
|
111
|
+
rows: state.rows,
|
|
112
|
+
keys: state.keys,
|
|
113
|
+
move: (from, to) => {
|
|
114
|
+
const { rows, keys } = latest.current;
|
|
115
|
+
if (from === to || to < 0 || to >= rows.length) return;
|
|
116
|
+
const r = splice(rows, from, 1), k = splice(keys, from, 1);
|
|
117
|
+
write(splice(r, to, 0, rows[from]!), splice(k, to, 0, keys[from]!));
|
|
118
|
+
},
|
|
119
|
+
duplicate: (index) => {
|
|
120
|
+
const { rows, keys } = latest.current;
|
|
121
|
+
if (index < 0 || index >= rows.length) return;
|
|
122
|
+
write(splice(rows, index + 1, 0, JSON.parse(JSON.stringify(rows[index])) as T), splice(keys, index + 1, 0, freshKey()));
|
|
123
|
+
},
|
|
124
|
+
remove: (index) => {
|
|
125
|
+
const { rows, keys } = latest.current;
|
|
126
|
+
if (index < 0 || index >= rows.length) return;
|
|
127
|
+
write(rows.filter((_, n) => n !== index), keys.filter((_, n) => n !== index));
|
|
128
|
+
},
|
|
129
|
+
add: (row) => { const { rows, keys } = latest.current; write([...rows, row], [...keys, freshKey()]); },
|
|
130
|
+
patch: (key, change) => {
|
|
131
|
+
const { rows, keys } = latest.current;
|
|
132
|
+
const at = keys.indexOf(key);
|
|
133
|
+
if (at < 0) return;
|
|
134
|
+
const next = change(rows[at]!);
|
|
135
|
+
if (JSON.stringify(next) === JSON.stringify(rows[at])) return;
|
|
136
|
+
write(rows.map((r, n) => (n === at ? next : r)), keys.slice());
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface RepeaterListProps {
|
|
142
|
+
/** The list's name ("Cards"), and the noun of its buttons' names ("Move Cards item 2 up"). */
|
|
143
|
+
label: string;
|
|
144
|
+
/** Under the name: the setting's sentence. */
|
|
145
|
+
hint?: React.ReactNode;
|
|
146
|
+
/** One per row, stable across moves (useKeyedRows). */
|
|
147
|
+
keys: readonly string[];
|
|
148
|
+
/** A row's name in its header (its first field); empty says "Item N". */
|
|
149
|
+
title: (index: number) => string;
|
|
150
|
+
/** A row's controls, drawn while it is open. */
|
|
151
|
+
body: (index: number) => React.ReactNode;
|
|
152
|
+
/** `to` is the index after the move. */
|
|
153
|
+
onMove: (from: number, to: number) => void;
|
|
154
|
+
onRemove: (index: number) => void;
|
|
155
|
+
/** Absent: no duplicate button (a list whose rows must differ). */
|
|
156
|
+
onDuplicate?: (index: number) => void;
|
|
157
|
+
/** Absent: no add button. */
|
|
158
|
+
onAdd?: () => void;
|
|
159
|
+
/** Default "+ Add item". */
|
|
160
|
+
addLabel?: string;
|
|
161
|
+
/** False: the add and duplicate buttons are off (a list at its limit). */
|
|
162
|
+
canAdd?: boolean;
|
|
163
|
+
disabled?: boolean;
|
|
164
|
+
/** Instead of the rows when there are none. Default "No items yet." */
|
|
165
|
+
empty?: React.ReactNode;
|
|
166
|
+
/** Attributes on a row's element (a page editor's `data-page-repeater-row`). */
|
|
167
|
+
rowAttrs?: (index: number) => Record<string, string | number | undefined>;
|
|
168
|
+
/** 44px buttons with a mouse too (`.gemboss-ei-rep--touch`): a surface whose every control is 44px (the page editor's
|
|
169
|
+
* content panel, check:page-revision-touchable). Under a finger they are 44px everywhere. */
|
|
170
|
+
touch?: boolean;
|
|
171
|
+
/** A row's picture before its name (its first image field), 20px; empty: none. */
|
|
172
|
+
thumb?: (index: number) => string | undefined;
|
|
173
|
+
/** Where a row's controls show: folded open under its header (default), or one row at a time in a layer under the list. */
|
|
174
|
+
editIn?: "inline" | "popover";
|
|
175
|
+
/** Popover mode: the row picked (null when none), so the host can show that item. */
|
|
176
|
+
onActive?: (index: number | null) => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// A function, not an element built at import: a page that compiles JSX against a global React (the admin's gates) sets that
|
|
180
|
+
// global after its imports run.
|
|
181
|
+
const DuplicateIcon = () => <svg viewBox="0 0 16 16" width="13" height="13" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.4"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 3.5v-.5A1.5 1.5 0 0 0 9 1.5H4A1.5 1.5 0 0 0 2.5 3v5A1.5 1.5 0 0 0 4 9.5h.5" /></svg>;
|
|
182
|
+
|
|
183
|
+
/** `.gemboss-ei-fld.gemboss-ei-rep` › label (+ count) › `.gemboss-ei-rep-list` › one `.gemboss-ei-rep-item` per row › add. */
|
|
184
|
+
export function RepeaterList({ label, hint, keys, title, body, onMove, onRemove, onDuplicate, onAdd, addLabel = "+ Add item", canAdd = true, disabled, empty, rowAttrs, touch, thumb, editIn = "inline", onActive }: RepeaterListProps) {
|
|
185
|
+
const uid = useId();
|
|
186
|
+
// The layer only where there is a row to choose: a list of one shows it in place.
|
|
187
|
+
const popover = editIn === "popover" && keys.length > 1;
|
|
188
|
+
// Popover mode: the row picked, by key; the layer floats only where the browser has the popover API (read after the first
|
|
189
|
+
// paint, so the server's markup and the first client render agree), else the row opens in place.
|
|
190
|
+
const [active, setActive] = useState<string | null>(null);
|
|
191
|
+
const [canFloat, setCanFloat] = useState(false);
|
|
192
|
+
useEffect(() => { setCanFloat(canFloatMenus()); }, []);
|
|
193
|
+
const activeKey = active !== null && keys.includes(active) ? active : null;
|
|
194
|
+
const pick = (key: string | null) => { setActive(key); onActive?.(key === null ? null : keys.indexOf(key)); };
|
|
195
|
+
// A row removed while picked: its layer goes, and the host hears it.
|
|
196
|
+
useEffect(() => { if (active !== null && !keys.includes(active)) { setActive(null); onActive?.(null); } }, [keys, active, onActive]);
|
|
197
|
+
const popRef = useRef<HTMLDivElement>(null);
|
|
198
|
+
const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set(keys.length <= 1 ? keys : []));
|
|
199
|
+
// A row that arrives after the list was drawn opens. Compared by content: a caller may hand a new array each render.
|
|
200
|
+
const [seen, setSeen] = useState<readonly string[]>(keys);
|
|
201
|
+
if (seen.join("\n") !== keys.join("\n")) {
|
|
202
|
+
const fresh = keys.filter((k) => !seen.includes(k));
|
|
203
|
+
setSeen(keys);
|
|
204
|
+
if (fresh.length) setOpen((o) => new Set([...o, ...fresh]));
|
|
205
|
+
}
|
|
206
|
+
const toggle = (key: string) => popover
|
|
207
|
+
? pick(activeKey === key ? null : key)
|
|
208
|
+
: setOpen((o) => { const next = new Set(o); if (!next.delete(key)) next.add(key); return next; });
|
|
209
|
+
const drag = useRowDrag(keys, onMove);
|
|
210
|
+
// A keyboard move keeps the focus on the moved row's name once it has landed (as SortableLayerList).
|
|
211
|
+
const refocus = useRef<{ key: string; from: number } | null>(null);
|
|
212
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
const want = refocus.current;
|
|
215
|
+
if (!want) return;
|
|
216
|
+
const at = keys.indexOf(want.key);
|
|
217
|
+
if (at === want.from) return;
|
|
218
|
+
refocus.current = null;
|
|
219
|
+
if (at < 0) return;
|
|
220
|
+
const row = [...(listRef.current?.children ?? [])].find((r) => (r as HTMLElement).dataset.key === want.key);
|
|
221
|
+
row?.querySelector<HTMLButtonElement>(".gemboss-ei-rep-toggle")?.focus();
|
|
222
|
+
}, [keys]);
|
|
223
|
+
// The layer under the list: in the top layer, as wide as the list, placed again when the panel or the page scrolls or the
|
|
224
|
+
// window resizes (a scroll inside the layer does not count), so it never floats away from its list.
|
|
225
|
+
useLayoutEffect(() => {
|
|
226
|
+
const el = popRef.current, list = listRef.current;
|
|
227
|
+
if (!el || !list || activeKey === null) return;
|
|
228
|
+
if (el.getAttribute("popover") !== "manual") el.setAttribute("popover", "manual");
|
|
229
|
+
if (!el.matches(":popover-open")) el.showPopover();
|
|
230
|
+
const place = () => { el.style.width = `${Math.round(list.getBoundingClientRect().width)}px`; placeFloating(el, list); };
|
|
231
|
+
place();
|
|
232
|
+
el.focus({ preventScroll: true });
|
|
233
|
+
const onMove = (e: Event) => { if (e.target instanceof Node && el.contains(e.target)) return; place(); };
|
|
234
|
+
window.addEventListener("scroll", onMove, true);
|
|
235
|
+
window.addEventListener("resize", onMove);
|
|
236
|
+
return () => {
|
|
237
|
+
window.removeEventListener("scroll", onMove, true);
|
|
238
|
+
window.removeEventListener("resize", onMove);
|
|
239
|
+
if (el.matches(":popover-open")) el.hidePopover();
|
|
240
|
+
};
|
|
241
|
+
}, [activeKey, canFloat, keys.length]);
|
|
242
|
+
const closeLayer = () => {
|
|
243
|
+
const key = activeKey;
|
|
244
|
+
pick(null);
|
|
245
|
+
const row = [...(listRef.current?.children ?? [])].find((r) => (r as HTMLElement).dataset.key === key);
|
|
246
|
+
row?.querySelector<HTMLButtonElement>(".gemboss-ei-rep-toggle")?.focus();
|
|
247
|
+
};
|
|
248
|
+
const floating = popover && canFloat;
|
|
249
|
+
const rootClass = ["gemboss-ei-fld gemboss-ei-rep", touch ? "gemboss-ei-rep--touch" : "", popover ? "gemboss-ei-rep--pop" : ""].filter(Boolean).join(" ");
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<div className={rootClass}>
|
|
253
|
+
<label>{label}{keys.length ? <span className="gemboss-ei-rep-count">{keys.length}</span> : null}</label>
|
|
254
|
+
{hint}
|
|
255
|
+
{keys.length === 0 ? (empty ?? <p className="gemboss-ei-hint">No items yet.</p>) : (
|
|
256
|
+
<div className="gemboss-ei-rep-list" ref={listRef}>
|
|
257
|
+
{keys.map((key, i) => {
|
|
258
|
+
const picked = popover && activeKey === key;
|
|
259
|
+
// Inline: its own fold. Popover: the row picked opens in place only where nothing can float.
|
|
260
|
+
const isOpen = popover ? picked && !canFloat : open.has(key);
|
|
261
|
+
const name = title(i) || `Item ${i + 1}`;
|
|
262
|
+
const bodyId = `${uid}-${key}`;
|
|
263
|
+
const src = thumb?.(i);
|
|
264
|
+
return (
|
|
265
|
+
<div key={key} data-key={key} className={["gemboss-ei-rep-item", drag.dragging === key ? "is-dragging" : "", picked ? "is-active" : ""].filter(Boolean).join(" ")}
|
|
266
|
+
data-drop={drag.dropAt(key)} {...rowAttrs?.(i)}
|
|
267
|
+
// A list that went off mid-drag (a save started) takes no drop: the move would race that save.
|
|
268
|
+
onDragOver={disabled ? undefined : drag.over(key)} onDrop={disabled ? drag.end : drag.drop}>
|
|
269
|
+
<div className="gemboss-ei-rep-head">
|
|
270
|
+
{/* Kept while the list is off (a write in flight), inert: taking it away moved every row's name 16px on each save. */}
|
|
271
|
+
<span className="gemboss-ei-rep-grip" aria-hidden draggable={!disabled} title={disabled ? undefined : "Drag to reorder"}
|
|
272
|
+
onDragStart={disabled ? undefined : (e) => { const row = e.currentTarget.closest(".gemboss-ei-rep-item"); if (row) e.dataTransfer.setDragImage(row, 12, 12); drag.start(key)(e); }}
|
|
273
|
+
onDragEnd={drag.end}>⋮⋮</span>
|
|
274
|
+
<button type="button" className="gemboss-ei-rep-toggle" aria-expanded={popover ? picked : isOpen}
|
|
275
|
+
aria-controls={picked && floating ? `${uid}-pop` : isOpen ? bodyId : undefined}
|
|
276
|
+
aria-keyshortcuts={disabled ? undefined : "Alt+ArrowUp Alt+ArrowDown"} onClick={() => toggle(key)}
|
|
277
|
+
onKeyDown={(e) => {
|
|
278
|
+
if (disabled || !e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
|
279
|
+
const to = e.key === "ArrowUp" ? i - 1 : i + 1;
|
|
280
|
+
if (to < 0 || to >= keys.length) return;
|
|
281
|
+
e.preventDefault();
|
|
282
|
+
refocus.current = { key, from: i };
|
|
283
|
+
onMove(i, to);
|
|
284
|
+
}}>
|
|
285
|
+
{src ? <img className="gemboss-ei-rep-thumb" src={src} alt="" loading="lazy" /> : null}
|
|
286
|
+
<span className="gemboss-ei-rep-title">{name}</span>
|
|
287
|
+
</button>
|
|
288
|
+
<span className="gemboss-ei-rep-acts">
|
|
289
|
+
<button type="button" title="Move up" aria-label={`Move ${label} item ${i + 1} up`} disabled={disabled || i === 0} onClick={() => onMove(i, i - 1)}>↑</button>
|
|
290
|
+
<button type="button" title="Move down" aria-label={`Move ${label} item ${i + 1} down`} disabled={disabled || i === keys.length - 1} onClick={() => onMove(i, i + 1)}>↓</button>
|
|
291
|
+
{onDuplicate ? (
|
|
292
|
+
<button type="button" title="Duplicate" aria-label={`Duplicate ${label} item ${i + 1}`} disabled={disabled || !canAdd} onClick={() => onDuplicate(i)}><DuplicateIcon /></button>
|
|
293
|
+
) : null}
|
|
294
|
+
<button type="button" title="Remove" aria-label={`Remove ${label} item ${i + 1}`} disabled={disabled} onClick={() => onRemove(i)}>✕</button>
|
|
295
|
+
</span>
|
|
296
|
+
</div>
|
|
297
|
+
{isOpen ? <div id={bodyId} className="gemboss-ei-rep-body" role="group" aria-label={name}>{body(i)}</div> : null}
|
|
298
|
+
</div>
|
|
299
|
+
);
|
|
300
|
+
})}
|
|
301
|
+
</div>
|
|
302
|
+
)}
|
|
303
|
+
{onAdd ? <button type="button" className="ui-btn gemboss-ei-rep-add" disabled={disabled || !canAdd} onClick={onAdd}>{addLabel}</button> : null}
|
|
304
|
+
{floating && activeKey !== null ? (() => {
|
|
305
|
+
const i = keys.indexOf(activeKey);
|
|
306
|
+
const name = title(i) || `Item ${i + 1}`;
|
|
307
|
+
return (
|
|
308
|
+
// A child of the list in the DOM (Tab order, React context, the host's providers), painted in the top layer.
|
|
309
|
+
<div ref={popRef} id={`${uid}-pop`} className="gemboss-ei-rep-pop gemboss-popover-surface" role="dialog" aria-label={name} tabIndex={-1}
|
|
310
|
+
onKeyDown={(e) => { if (e.key === "Escape") { e.stopPropagation(); closeLayer(); } }}>
|
|
311
|
+
<div className="gemboss-ei-rep-pophead">
|
|
312
|
+
<span className="gemboss-ei-rep-title">{name}</span>
|
|
313
|
+
<span className="gemboss-ei-rep-popat">{i + 1} / {keys.length}</span>
|
|
314
|
+
<button type="button" aria-label={`Close ${name}`} onClick={closeLayer}>✕</button>
|
|
315
|
+
</div>
|
|
316
|
+
{body(i)}
|
|
317
|
+
</div>
|
|
318
|
+
);
|
|
319
|
+
})() : null}
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
package/src/editor/sortable.tsx
CHANGED
|
@@ -7,18 +7,61 @@
|
|
|
7
7
|
// `onMove` answers later, after a server write: the focus waits for the row to reach its new place).
|
|
8
8
|
// The drag is kept by the row's ID, so rows that change under it (a section added by the chat) do not move the wrong one.
|
|
9
9
|
// HTML drag and drop does not start from a touch: a list that must reorder on a phone keeps ↑ / ↓ in its `actions`.
|
|
10
|
+
//
|
|
11
|
+
// The drag itself is `useRowDrag` (16/10/2026), shared with the settings panel's RepeaterList (repeater.tsx), so the two lists
|
|
12
|
+
// reorder by one rule.
|
|
10
13
|
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
11
14
|
import { useEffect, useRef, useState } from "react";
|
|
12
15
|
import type React from "react";
|
|
13
16
|
import { LayerList, type LayerItem, type LayerListProps } from "./layers";
|
|
14
17
|
|
|
18
|
+
/** Where a dragged row lands: before or after row `over`, as the index it has once it has left its place. */
|
|
19
|
+
export function dropIndex(from: number, over: number, place: "before" | "after"): number {
|
|
20
|
+
return place === "before" ? (over > from ? over - 1 : over) : (over >= from ? over : over + 1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A drag over rows kept by ID. `start(id)` on what is dragged, `over(id)` and `drop` on each row, `end` when it ends;
|
|
25
|
+
* `dropAt(id)` says which edge of row `id` shows the landing line, `dragging` which row is in the air. `onMove(from, to)`
|
|
26
|
+
* with `to` the index after the move, only when it moves.
|
|
27
|
+
*/
|
|
28
|
+
export function useRowDrag(ids: readonly string[], onMove: (from: number, to: number) => void) {
|
|
29
|
+
const [drag, setDrag] = useState<{ id: string; over: string; place: "before" | "after" } | null>(null);
|
|
30
|
+
const drop = () => {
|
|
31
|
+
if (!drag) return;
|
|
32
|
+
const from = ids.indexOf(drag.id), over = ids.indexOf(drag.over);
|
|
33
|
+
setDrag(null);
|
|
34
|
+
if (from < 0 || over < 0) return;
|
|
35
|
+
const to = dropIndex(from, over, drag.place);
|
|
36
|
+
if (to !== from) onMove(from, to);
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
dragging: drag?.id ?? null,
|
|
40
|
+
dropAt: (id: string): "before" | "after" | undefined => (drag && drag.over === id && drag.id !== id ? drag.place : undefined),
|
|
41
|
+
start: (id: string) => (e: React.DragEvent) => {
|
|
42
|
+
e.dataTransfer.effectAllowed = "move";
|
|
43
|
+
e.dataTransfer.setData("text/plain", id);
|
|
44
|
+
setDrag({ id, over: id, place: "before" });
|
|
45
|
+
},
|
|
46
|
+
over: (id: string) => (e: React.DragEvent<HTMLElement>) => {
|
|
47
|
+
if (!drag) return;
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
const r = e.currentTarget.getBoundingClientRect();
|
|
50
|
+
const place = e.clientY < r.top + r.height / 2 ? "before" : "after";
|
|
51
|
+
if (drag.over !== id || drag.place !== place) setDrag({ ...drag, over: id, place });
|
|
52
|
+
},
|
|
53
|
+
drop: (e: React.DragEvent) => { e.preventDefault(); drop(); },
|
|
54
|
+
end: () => setDrag(null),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
15
58
|
export interface SortableLayerListProps<T extends LayerItem = LayerItem> extends Omit<LayerListProps<T>, "rowProps" | "mainProps" | "lead" | "listRef"> {
|
|
16
59
|
/** A row moved: `to` is its index AFTER the move. */
|
|
17
60
|
onMove: (item: T, to: number) => void;
|
|
18
61
|
}
|
|
19
62
|
|
|
20
63
|
export function SortableLayerList<T extends LayerItem>({ onMove, items, ...rest }: SortableLayerListProps<T>) {
|
|
21
|
-
const
|
|
64
|
+
const drag = useRowDrag(items.map((it) => it.id), (from, to) => onMove(items[from], to));
|
|
22
65
|
// A keyboard move: the row and the index it left. The focus goes back to it once it is somewhere else. Chromium keeps the
|
|
23
66
|
// focus on a node React moves (measured: the Alt+↑/↓ tests pass without this); a browser that runs the DOM's focus
|
|
24
67
|
// fixup on the re-insert would drop it to <body>, so it is put back.
|
|
@@ -35,34 +78,17 @@ export function SortableLayerList<T extends LayerItem>({ onMove, items, ...rest
|
|
|
35
78
|
row?.querySelector<HTMLButtonElement>(".gemboss-layer-main")?.focus();
|
|
36
79
|
}, [items]);
|
|
37
80
|
|
|
38
|
-
const indexOf = (id: string) => items.findIndex((it) => it.id === id);
|
|
39
|
-
const drop = () => {
|
|
40
|
-
if (!drag) return;
|
|
41
|
-
const from = indexOf(drag.id), over = indexOf(drag.over);
|
|
42
|
-
setDrag(null);
|
|
43
|
-
if (from < 0 || over < 0) return;
|
|
44
|
-
// The final index: landing before / after row `over`, counted once the dragged row has left its place.
|
|
45
|
-
const to = drag.place === "before" ? (over > from ? over - 1 : over) : (over >= from ? over : over + 1);
|
|
46
|
-
if (to !== from) onMove(items[from], to);
|
|
47
|
-
};
|
|
48
|
-
|
|
49
81
|
return (
|
|
50
82
|
<LayerList {...rest} items={items} listRef={listRef}
|
|
51
83
|
lead={() => <span className="gemboss-layer-grip" aria-hidden>⋮⋮</span>}
|
|
52
84
|
rowProps={(it) => ({
|
|
53
|
-
className: drag
|
|
54
|
-
"data-drop": drag
|
|
85
|
+
className: drag.dragging === it.id ? "is-dragging" : undefined,
|
|
86
|
+
"data-drop": drag.dropAt(it.id),
|
|
55
87
|
draggable: true,
|
|
56
|
-
onDragStart:
|
|
57
|
-
onDragOver: (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const r = e.currentTarget.getBoundingClientRect();
|
|
61
|
-
const place = e.clientY < r.top + r.height / 2 ? "before" : "after";
|
|
62
|
-
if (drag.over !== it.id || drag.place !== place) setDrag({ ...drag, over: it.id, place });
|
|
63
|
-
},
|
|
64
|
-
onDrop: (e) => { e.preventDefault(); drop(); },
|
|
65
|
-
onDragEnd: () => setDrag(null),
|
|
88
|
+
onDragStart: drag.start(it.id),
|
|
89
|
+
onDragOver: drag.over(it.id),
|
|
90
|
+
onDrop: drag.drop,
|
|
91
|
+
onDragEnd: drag.end,
|
|
66
92
|
})}
|
|
67
93
|
mainProps={(it, i) => ({
|
|
68
94
|
"aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown",
|
package/src/editor/swatch.ts
CHANGED
|
@@ -43,3 +43,23 @@ export function swatchState(value: string, whenEmpty = '#000000'): SwatchState {
|
|
|
43
43
|
if (!value) return { swatch: whenEmpty, usable: true };
|
|
44
44
|
return SWATCHABLE.test(value) ? { swatch: value, usable: true } : { swatch: '#000000', usable: false };
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
/** The theme's colour tokens a value may name (`var(--gem-color-accent)`), as a seller reads them. */
|
|
48
|
+
const TOKEN_NAMES: Record<string, string> = { text: "Text", accent: "Accent", "accent-text": "Accent text", muted: "Muted", background: "Background",
|
|
49
|
+
surface: "Surface", border: "Border", success: "Success", warning: "Warning", danger: "Danger" };
|
|
50
|
+
const tokenName = (t: string) => TOKEN_NAMES[t] ?? t.charAt(0).toUpperCase() + t.slice(1).replace(/-/g, " ");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* WHY THE SWATCH IS OFF, said where a seller sees it (Claude Design: a disabled control says why). The editors' selection toolbar
|
|
54
|
+
* writes the theme's own colours as tokens (`var(--gem-color-accent)`), so every colour picked there turned its field's swatch grey,
|
|
55
|
+
* with only a tooltip a finger or the keyboard never reaches (inventory of 19/10/2026). Null when the swatch shows the value.
|
|
56
|
+
*/
|
|
57
|
+
export function swatchWhy(value: string): string | null {
|
|
58
|
+
// Exactly when the swatch is off (swatchState's one rule): a padded hex is off too, and says so (review of #4636).
|
|
59
|
+
if (swatchState(value).usable) return null;
|
|
60
|
+
const v = value.trim();
|
|
61
|
+
const token = /^var\(\s*--gem-color-([a-z-]+)\s*\)$/i.exec(v)?.[1]?.toLowerCase();
|
|
62
|
+
if (token) return `Theme color: ${tokenName(token)}. Type a hex to use your own.`;
|
|
63
|
+
if (/^var\(/i.test(v)) return "A theme value. Type a hex to use your own.";
|
|
64
|
+
return "Kept as written: the swatch picks 6-digit hex colors only.";
|
|
65
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// CHOICE TILES (16/10/2026, Chris: the panel was missing pickers; research `_local/gemboss/research/pickers-2026-10-16/pickers.md`
|
|
2
|
+
// §4 and pattern P9). A closed choice whose options are SHAPES (an image's ratio, a layout) drawn as picture tiles with their
|
|
3
|
+
// name under them, as GemPages' layout selector and option illustrations do: the seller sees the shape before choosing it,
|
|
4
|
+
// where a dropdown or a text box ("1-1, 4-3, 16-9") makes them read and imagine. The caller owns the value; one click writes.
|
|
5
|
+
//
|
|
6
|
+
// · `.gemboss-ei-tiles[role=group]` › one `button.gemboss-ei-tile[aria-pressed]` per option › `.gemboss-ei-tile-pic`
|
|
7
|
+
// (`picture`, or a rectangle `shape` of w×h drawn to fit 26px) · `.gemboss-ei-tile-label`;
|
|
8
|
+
// · as many to a row as fit at 72px (4 in a 350px panel, 3 at a phone's 288px, 2 wide ones for a choice of two); a name
|
|
9
|
+
// wraps to two lines; under a finger every tile is at least 44px (min-height 56px always).
|
|
10
|
+
// Its own part (`editor-tiles.css`): only a surface that draws tiles loads it.
|
|
11
|
+
import "../editor-tiles.css";
|
|
12
|
+
import type React from "react";
|
|
13
|
+
|
|
14
|
+
export interface ChoiceTile {
|
|
15
|
+
value: string;
|
|
16
|
+
label: string;
|
|
17
|
+
/** A rectangle of these proportions, drawn to fit the tile's 26px picture box. */
|
|
18
|
+
shape?: { w: number; h: number };
|
|
19
|
+
/** Any other picture (an icon), when the choice is not a rectangle. */
|
|
20
|
+
picture?: React.ReactNode;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChoiceTilesProps {
|
|
24
|
+
/** The group's accessible name (the setting's label). */
|
|
25
|
+
label: string;
|
|
26
|
+
options: readonly ChoiceTile[];
|
|
27
|
+
/** The option that is on; null or a value no tile has: none is lit. */
|
|
28
|
+
value: string | null;
|
|
29
|
+
onChange: (value: string) => void;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const BOX = 26;
|
|
34
|
+
const shapeStyle = ({ w, h }: { w: number; h: number }): React.CSSProperties =>
|
|
35
|
+
w >= h ? { width: BOX, height: Math.max(4, Math.round((BOX * h) / w)) } : { height: BOX, width: Math.max(4, Math.round((BOX * w) / h)) };
|
|
36
|
+
|
|
37
|
+
export function ChoiceTiles({ label, options, value, onChange, disabled }: ChoiceTilesProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="gemboss-ei-tiles" role="group" aria-label={label}>
|
|
40
|
+
{options.map((o) => (
|
|
41
|
+
<button key={o.value} type="button" className="gemboss-ei-tile" aria-pressed={o.value === value} disabled={disabled}
|
|
42
|
+
data-value={o.value} onClick={() => onChange(o.value)}>
|
|
43
|
+
<span className="gemboss-ei-tile-pic" aria-hidden>
|
|
44
|
+
{o.picture ?? (o.shape ? <span className="gemboss-ei-tile-shape" style={shapeStyle(o.shape)} /> : null)}
|
|
45
|
+
</span>
|
|
46
|
+
<span className="gemboss-ei-tile-label">{o.label}</span>
|
|
47
|
+
</button>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|