@gemboss/ui 0.35.7 → 0.39.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.
@@ -0,0 +1,244 @@
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
+ import "../editor.css"; // the field and the 44px rule under a finger (`.gemboss-ei-fld`)
20
+ import "../editor-repeater.css"; // its own part (16/10/2026): a surface that never draws a list never loads it
21
+ import { useEffect, useId, useRef, useState } from "react";
22
+ import type React from "react";
23
+ import { useRowDrag } from "./sortable";
24
+
25
+ let lastKey = 0;
26
+ const freshKey = () => `row-${++lastKey}`;
27
+
28
+ export interface KeyedRows<T> {
29
+ /** The rows shown: the stored list, or the one just written while its save is on the way. */
30
+ rows: readonly T[];
31
+ /** One stable key per row, in the same order. */
32
+ keys: readonly string[];
33
+ move: (from: number, to: number) => void;
34
+ /** A copy right after it, with a key of its own. */
35
+ duplicate: (index: number) => void;
36
+ remove: (index: number) => void;
37
+ add: (row: T) => void;
38
+ /**
39
+ * 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
40
+ * was drawn. A control that commits late (a colour or a slider holds its write 250 ms, and flushes it when it unmounts)
41
+ * 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
42
+ * row with that key: nothing is written). Review of #4506: by index and whole row, that write doubled one row and lost another.
43
+ */
44
+ patch: (key: string, change: (row: T) => T) => void;
45
+ }
46
+
47
+ /**
48
+ * A stored list with a stable key per row. Each change writes the WHOLE list (`commit`) and shows it at once.
49
+ *
50
+ * · `commit` may answer (a Promise): `false` or a rejection puts the stored list back, unless a later write is on its way
51
+ * (its answer decides). Without an answer what was written stays shown until the stored list says otherwise.
52
+ * · the stored list is read by CONTENT: a caller that hands a new array each render (`rows ?? []`) does not reset what is
53
+ * shown (review of #4506: a row added to an unset list vanished).
54
+ * · a stored list equal to one of our writes is that write coming back: what is shown (maybe newer) stays; any other is a
55
+ * change from elsewhere (an undo, the chat) and replaces it, the keys kept by position.
56
+ */
57
+ export function useKeyedRows<T>(value: readonly T[], commit: (next: T[]) => void | PromiseLike<unknown>): KeyedRows<T> {
58
+ const [state, setState] = useState(() => ({ rows: value.slice(), keys: value.map(() => freshKey()) }));
59
+ const latest = useRef(state);
60
+ latest.current = state;
61
+ const seen = JSON.stringify(value);
62
+ // The stored list, its text, and the keys it is shown with: what a refused write goes back to.
63
+ const stored = useRef<{ rows: readonly T[]; json: string; keys: string[] }>({ rows: value, json: seen, keys: state.keys });
64
+ // Our writes on their way, oldest first.
65
+ const pending = useRef<Array<{ n: number; json: string; keys: string[] }>>([]);
66
+ const writes = useRef(0);
67
+ useEffect(() => {
68
+ if (seen === stored.current.json) return;
69
+ const mine = pending.current.findIndex((p) => p.json === seen);
70
+ if (mine >= 0) {
71
+ stored.current = { rows: value, json: seen, keys: pending.current[mine]!.keys };
72
+ pending.current = pending.current.slice(mine + 1);
73
+ return;
74
+ }
75
+ pending.current = [];
76
+ const keys = value.map((_, i) => latest.current.keys[i] ?? freshKey());
77
+ stored.current = { rows: value, json: seen, keys };
78
+ latest.current = { rows: value.slice(), keys };
79
+ setState(latest.current);
80
+ // By content (`seen`): a new array with the same rows is not a change.
81
+ // eslint-disable-next-line react-hooks/exhaustive-deps
82
+ }, [seen]);
83
+ const refused = (n: number) => {
84
+ pending.current = pending.current.filter((p) => p.n !== n);
85
+ if (n !== writes.current) return;
86
+ latest.current = { rows: stored.current.rows.slice(), keys: stored.current.keys.slice() };
87
+ setState(latest.current);
88
+ };
89
+ const write = (rows: T[], keys: string[]) => {
90
+ const n = ++writes.current;
91
+ pending.current.push({ n, json: JSON.stringify(rows), keys });
92
+ latest.current = { rows, keys };
93
+ setState(latest.current);
94
+ const answer = commit(rows);
95
+ if (answer && typeof (answer as PromiseLike<unknown>).then === "function") {
96
+ (answer as PromiseLike<unknown>).then((ok) => { if (ok === false) refused(n); }, () => refused(n));
97
+ }
98
+ };
99
+ const splice = <X,>(xs: readonly X[], at: number, remove: number, ...add: X[]) => { const next = xs.slice(); next.splice(at, remove, ...add); return next; };
100
+ return {
101
+ rows: state.rows,
102
+ keys: state.keys,
103
+ move: (from, to) => {
104
+ const { rows, keys } = latest.current;
105
+ if (from === to || to < 0 || to >= rows.length) return;
106
+ const r = splice(rows, from, 1), k = splice(keys, from, 1);
107
+ write(splice(r, to, 0, rows[from]!), splice(k, to, 0, keys[from]!));
108
+ },
109
+ duplicate: (index) => {
110
+ const { rows, keys } = latest.current;
111
+ if (index < 0 || index >= rows.length) return;
112
+ write(splice(rows, index + 1, 0, JSON.parse(JSON.stringify(rows[index])) as T), splice(keys, index + 1, 0, freshKey()));
113
+ },
114
+ remove: (index) => {
115
+ const { rows, keys } = latest.current;
116
+ if (index < 0 || index >= rows.length) return;
117
+ write(rows.filter((_, n) => n !== index), keys.filter((_, n) => n !== index));
118
+ },
119
+ add: (row) => { const { rows, keys } = latest.current; write([...rows, row], [...keys, freshKey()]); },
120
+ patch: (key, change) => {
121
+ const { rows, keys } = latest.current;
122
+ const at = keys.indexOf(key);
123
+ if (at < 0) return;
124
+ const next = change(rows[at]!);
125
+ if (JSON.stringify(next) === JSON.stringify(rows[at])) return;
126
+ write(rows.map((r, n) => (n === at ? next : r)), keys.slice());
127
+ },
128
+ };
129
+ }
130
+
131
+ export interface RepeaterListProps {
132
+ /** The list's name ("Cards"), and the noun of its buttons' names ("Move Cards item 2 up"). */
133
+ label: string;
134
+ /** Under the name: the setting's sentence. */
135
+ hint?: React.ReactNode;
136
+ /** One per row, stable across moves (useKeyedRows). */
137
+ keys: readonly string[];
138
+ /** A row's name in its header (its first field); empty says "Item N". */
139
+ title: (index: number) => string;
140
+ /** A row's controls, drawn while it is open. */
141
+ body: (index: number) => React.ReactNode;
142
+ /** `to` is the index after the move. */
143
+ onMove: (from: number, to: number) => void;
144
+ onRemove: (index: number) => void;
145
+ /** Absent: no duplicate button (a list whose rows must differ). */
146
+ onDuplicate?: (index: number) => void;
147
+ /** Absent: no add button. */
148
+ onAdd?: () => void;
149
+ /** Default "+ Add item". */
150
+ addLabel?: string;
151
+ /** False: the add and duplicate buttons are off (a list at its limit). */
152
+ canAdd?: boolean;
153
+ disabled?: boolean;
154
+ /** Instead of the rows when there are none. Default "No items yet." */
155
+ empty?: React.ReactNode;
156
+ /** Attributes on a row's element (a page editor's `data-page-repeater-row`). */
157
+ rowAttrs?: (index: number) => Record<string, string | number | undefined>;
158
+ /** 44px buttons with a mouse too (`.gemboss-ei-rep--touch`): a surface whose every control is 44px (the page editor's
159
+ * content panel, check:page-revision-touchable). Under a finger they are 44px everywhere. */
160
+ touch?: boolean;
161
+ }
162
+
163
+ // A function, not an element built at import: a page that compiles JSX against a global React (the admin's gates) sets that
164
+ // global after its imports run.
165
+ 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>;
166
+
167
+ /** `.gemboss-ei-fld.gemboss-ei-rep` › label (+ count) › `.gemboss-ei-rep-list` › one `.gemboss-ei-rep-item` per row › add. */
168
+ export function RepeaterList({ label, hint, keys, title, body, onMove, onRemove, onDuplicate, onAdd, addLabel = "+ Add item", canAdd = true, disabled, empty, rowAttrs, touch }: RepeaterListProps) {
169
+ const uid = useId();
170
+ const [open, setOpen] = useState<ReadonlySet<string>>(() => new Set(keys.length <= 1 ? keys : []));
171
+ // A row that arrives after the list was drawn opens. Compared by content: a caller may hand a new array each render.
172
+ const [seen, setSeen] = useState<readonly string[]>(keys);
173
+ if (seen.join("\n") !== keys.join("\n")) {
174
+ const fresh = keys.filter((k) => !seen.includes(k));
175
+ setSeen(keys);
176
+ if (fresh.length) setOpen((o) => new Set([...o, ...fresh]));
177
+ }
178
+ const toggle = (key: string) => setOpen((o) => { const next = new Set(o); if (!next.delete(key)) next.add(key); return next; });
179
+ const drag = useRowDrag(keys, onMove);
180
+ // A keyboard move keeps the focus on the moved row's name once it has landed (as SortableLayerList).
181
+ const refocus = useRef<{ key: string; from: number } | null>(null);
182
+ const listRef = useRef<HTMLDivElement>(null);
183
+ useEffect(() => {
184
+ const want = refocus.current;
185
+ if (!want) return;
186
+ const at = keys.indexOf(want.key);
187
+ if (at === want.from) return;
188
+ refocus.current = null;
189
+ if (at < 0) return;
190
+ const row = [...(listRef.current?.children ?? [])].find((r) => (r as HTMLElement).dataset.key === want.key);
191
+ row?.querySelector<HTMLButtonElement>(".gemboss-ei-rep-toggle")?.focus();
192
+ }, [keys]);
193
+
194
+ return (
195
+ <div className={touch ? "gemboss-ei-fld gemboss-ei-rep gemboss-ei-rep--touch" : "gemboss-ei-fld gemboss-ei-rep"}>
196
+ <label>{label}{keys.length ? <span className="gemboss-ei-rep-count">{keys.length}</span> : null}</label>
197
+ {hint}
198
+ {keys.length === 0 ? (empty ?? <p className="gemboss-ei-hint">No items yet.</p>) : (
199
+ <div className="gemboss-ei-rep-list" ref={listRef}>
200
+ {keys.map((key, i) => {
201
+ const isOpen = open.has(key);
202
+ const name = title(i) || `Item ${i + 1}`;
203
+ const bodyId = `${uid}-${key}`;
204
+ return (
205
+ <div key={key} data-key={key} className={drag.dragging === key ? "gemboss-ei-rep-item is-dragging" : "gemboss-ei-rep-item"}
206
+ data-drop={drag.dropAt(key)} {...rowAttrs?.(i)}
207
+ // A list that went off mid-drag (a save started) takes no drop: the move would race that save.
208
+ onDragOver={disabled ? undefined : drag.over(key)} onDrop={disabled ? drag.end : drag.drop}>
209
+ <div className="gemboss-ei-rep-head">
210
+ {/* Kept while the list is off (a write in flight), inert: taking it away moved every row's name 16px on each save. */}
211
+ <span className="gemboss-ei-rep-grip" aria-hidden draggable={!disabled} title={disabled ? undefined : "Drag to reorder"}
212
+ 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); }}
213
+ onDragEnd={drag.end}>⋮⋮</span>
214
+ <button type="button" className="gemboss-ei-rep-toggle" aria-expanded={isOpen} aria-controls={isOpen ? bodyId : undefined}
215
+ aria-keyshortcuts={disabled ? undefined : "Alt+ArrowUp Alt+ArrowDown"} onClick={() => toggle(key)}
216
+ onKeyDown={(e) => {
217
+ if (disabled || !e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
218
+ const to = e.key === "ArrowUp" ? i - 1 : i + 1;
219
+ if (to < 0 || to >= keys.length) return;
220
+ e.preventDefault();
221
+ refocus.current = { key, from: i };
222
+ onMove(i, to);
223
+ }}>
224
+ <span className="gemboss-ei-rep-title">{name}</span>
225
+ </button>
226
+ <span className="gemboss-ei-rep-acts">
227
+ <button type="button" title="Move up" aria-label={`Move ${label} item ${i + 1} up`} disabled={disabled || i === 0} onClick={() => onMove(i, i - 1)}>↑</button>
228
+ <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>
229
+ {onDuplicate ? (
230
+ <button type="button" title="Duplicate" aria-label={`Duplicate ${label} item ${i + 1}`} disabled={disabled || !canAdd} onClick={() => onDuplicate(i)}><DuplicateIcon /></button>
231
+ ) : null}
232
+ <button type="button" title="Remove" aria-label={`Remove ${label} item ${i + 1}`} disabled={disabled} onClick={() => onRemove(i)}>✕</button>
233
+ </span>
234
+ </div>
235
+ {isOpen ? <div id={bodyId} className="gemboss-ei-rep-body" role="group" aria-label={name}>{body(i)}</div> : null}
236
+ </div>
237
+ );
238
+ })}
239
+ </div>
240
+ )}
241
+ {onAdd ? <button type="button" className="gemboss-ei-rep-add" disabled={disabled || !canAdd} onClick={onAdd}>{addLabel}</button> : null}
242
+ </div>
243
+ );
244
+ }
@@ -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 [drag, setDrag] = useState<{ id: string; over: string; place: "before" | "after" } | null>(null);
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?.id === it.id ? "is-dragging" : undefined,
54
- "data-drop": drag && drag.over === it.id && drag.id !== it.id ? drag.place : undefined,
85
+ className: drag.dragging === it.id ? "is-dragging" : undefined,
86
+ "data-drop": drag.dropAt(it.id),
55
87
  draggable: true,
56
- onDragStart: (e) => { e.dataTransfer.effectAllowed = "move"; e.dataTransfer.setData("text/plain", it.id); setDrag({ id: it.id, over: it.id, place: "before" }); },
57
- onDragOver: (e) => {
58
- if (!drag) return;
59
- e.preventDefault();
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",
@@ -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
+ }
@@ -0,0 +1 @@
1
+ .gemboss-ei-rep>label{display:flex;align-items:center;gap:6px}.gemboss-ei-rep-count{min-width:18px;padding:0 6px;border-radius:9px;background:var(--gemboss-hover,#eceef2);color:var(--gemboss-text-muted,#686b71);font-size:11px;line-height:18px;font-weight:550;text-align:center}.gemboss-ei-rep-list{display:flex;flex-direction:column;gap:6px;margin-bottom:8px}.gemboss-ei-rep-item{border:1px solid var(--gemboss-divider,#e5e7ea);border-radius:8px;background:var(--gemboss-surface,#fff)}.gemboss-ei-rep-item.is-dragging{opacity:.4}.gemboss-ei-rep-item[data-drop=before]{box-shadow:0 -2px 0 var(--gemboss-primary,#1b1c1e)}.gemboss-ei-rep-item[data-drop=after]{box-shadow:0 2px 0 var(--gemboss-primary,#1b1c1e)}.gemboss-ei-rep-head{display:flex;align-items:center;gap:2px;padding:2px 4px 2px 2px}.gemboss-ei-rep-grip{flex:none;width:16px;color:var(--gemboss-text-muted,#686b71);font-size:11px;letter-spacing:-2px;line-height:1;text-align:center;cursor:grab;user-select:none}.gemboss-ei-rep-grip[draggable=false]{opacity:.3;cursor:default}.gemboss-ei-rep-toggle{flex:1;min-width:0;display:flex;align-items:center;gap:8px;min-height:28px;padding:0 6px;border:0;border-radius:6px;background:none;color:var(--gemboss-text,#1b1c1e);font:inherit;font-size:13px;text-align:left;cursor:pointer}.gemboss-ei-rep-toggle:before{content:"";flex:none;width:5px;height:5px;margin:0 2px;border:solid currentColor;border-width:0 1.5px 1.5px 0;transform:rotate(-45deg);opacity:.6;transition:transform .12s}.gemboss-ei-rep-toggle[aria-expanded=true]:before{transform:rotate(45deg)}.gemboss-ei-rep-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gemboss-ei-rep-acts{display:flex;gap:2px;flex:none}.gemboss-ei-rep-acts button{display:grid;place-items:center;width:28px;height:28px;padding:0;border:0;border-radius:6px;background:none;color:var(--gemboss-text-muted,#686b71);font:inherit;font-size:13px;line-height:1;cursor:pointer}:is(.gemboss-ei-rep-acts button,.gemboss-ei-rep-add):hover:not(:disabled),.gemboss-ei-rep-toggle:hover{background:var(--gemboss-hover,#eceef2)}:is(.gemboss-ei-rep-acts button,.gemboss-ei-rep-toggle):focus-visible{outline:2px solid var(--gemboss-primary,#1b1c1e);outline-offset:-2px}.gemboss-ei-rep-acts button:disabled{opacity:.3;cursor:default}.gemboss-ei-rep-body{padding:8px 10px 10px;border-top:1px solid var(--gemboss-divider-soft,#edeff2)}.gemboss-ei-rep-add{width:100%;min-height:32px;padding:6px;border:1px dashed var(--gemboss-divider,#e5e7ea);border-radius:8px;background:none;color:inherit;font:inherit;font-size:13px;cursor:pointer}.gemboss-ei-rep-add:disabled{opacity:.4;cursor:default}.gemboss-ei-rep--touch .gemboss-ei-rep-acts button{width:44px;height:44px}.gemboss-ei-rep--touch :is(.gemboss-ei-rep-toggle,.gemboss-ei-rep-add){min-height:44px}@media (prefers-reduced-motion:reduce){.gemboss-ei-rep-toggle:before{transition:none}}
@@ -0,0 +1 @@
1
+ .gemboss-ei-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(72px,1fr));gap:6px}.gemboss-ei-tile{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;min-width:0;min-height:56px;padding:6px 4px;border:1px solid var(--gemboss-divider,#e5e7ea);border-radius:8px;background:var(--gemboss-surface,#fff);color:var(--gemboss-text,#1b1c1e);font:inherit;font-size:12px;line-height:16px;cursor:pointer}.gemboss-ei-tile:hover:not(:disabled){background:var(--gemboss-hover,#eceef2)}.gemboss-ei-tile[aria-pressed=true]{border-color:var(--gemboss-primary,#1b1c1e);box-shadow:inset 0 0 0 1px var(--gemboss-primary,#1b1c1e);font-weight:550}.gemboss-ei-tile:focus-visible{outline:2px solid var(--gemboss-primary,#1b1c1e);outline-offset:1px}.gemboss-ei-tile:disabled{opacity:.4;cursor:default}.gemboss-ei-tile-pic{display:grid;place-items:center;width:26px;height:26px}.gemboss-ei-tile-shape{display:block;box-sizing:border-box;border:1.5px solid currentColor;border-radius:3px;opacity:.75}.gemboss-ei-tile-label{max-width:100%;overflow:hidden;text-align:center;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}