@gemboss/ui 0.36.1 → 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.
@@ -11,7 +11,7 @@
11
11
  // · the edge box hands each keystroke to the caller (`onChange(side, raw)`) and says when to commit (`onCommit`, on
12
12
  // blur): the caller decides what an empty edge means, and the admin has three answers to that on purpose.
13
13
  import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
14
- import { useEffect, useState } from "react";
14
+ import { useEffect, useRef, useState } from "react";
15
15
  import type React from "react";
16
16
  import { cssUrl } from "./css-url";
17
17
  import { swatchState } from "./swatch";
@@ -27,6 +27,8 @@ export interface SegOption<T extends string = string> {
27
27
  value: T;
28
28
  /** Default: the value itself. */
29
29
  label?: React.ReactNode;
30
+ /** The button's own name, for a label that is a glyph (an arrow), said by a screen reader and shown on hover. */
31
+ name?: string;
30
32
  }
31
33
 
32
34
  export interface SegProps<T extends string = string> {
@@ -49,7 +51,69 @@ export interface SegProps<T extends string = string> {
49
51
  export function Seg<T extends string = string>({ options, value, onChange, disabled, label, style }: SegProps<T>) {
50
52
  return (
51
53
  <Segmented<T> fill className="gemboss-ei-seg" style={style} ariaLabel={label ?? ""} value={(value ?? "") as T | ""} onChange={onChange} disabled={disabled}
52
- options={options.map((o) => ({ value: o.value, label: o.label ?? o.value }))} />
54
+ options={options.map((o) => ({ value: o.value, label: o.label ?? o.value, ...(o.name ? { ariaLabel: o.name, title: o.name } : {}) }))} />
55
+ );
56
+ }
57
+
58
+ // ── ValueControl ────────────────────────────────────────────────────────────────────────────────────────────────────
59
+
60
+ export interface ValueControlProps {
61
+ /** The number box's id (the Field's `htmlFor`). */
62
+ id?: string;
63
+ /** The field's name: the chips are announced as "<label> presets". */
64
+ label: string;
65
+ /** The named values, smallest first, each `value` as the box would hold it ("" = none). Shown as "name · number". */
66
+ presets: ReadonlyArray<{ value: string; label: string; title?: string }>;
67
+ /** What the box holds now: the stored value or the text being typed. */
68
+ text: string;
69
+ /** What an empty box follows (another screen's value, the theme's), greyed. */
70
+ placeholder?: string;
71
+ /** The unit after the box ("px", "%", "ms"). */
72
+ suffix?: string;
73
+ /** A slider under the box, for a bounded range of ≤ ~100 stops. `value` is where its thumb sits. */
74
+ slider?: { min: number; max: number; step?: number; value: number };
75
+ /** A chip: the caller saves it at once. */
76
+ onPreset: (value: string) => void;
77
+ /** Each key typed. */
78
+ onText: (raw: string) => void;
79
+ /** The box let go (blur, Enter). */
80
+ onCommit: () => void;
81
+ /** Each move of the slider. */
82
+ onSlide?: (value: number) => void;
83
+ /** Keys in the box (↑ ↓ nudges are the caller's). */
84
+ onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
85
+ /** A line under it (e.g. why a typed value was not kept as typed). */
86
+ note?: string;
87
+ /** The keyboard a phone shows: "decimal" (default) has no minus key on iOS, so a value that goes below 0 asks for "text". */
88
+ inputMode?: "decimal" | "text";
89
+ disabled?: boolean;
90
+ }
91
+
92
+ /**
93
+ * `.gemboss-ei-value` (0.38.0): a setting you can click, drag or type (Chris 15/10/2026: "vừa bấm được vừa nhập được, lười thì
94
+ * bấm"). The named values first, as the kit's segmented control (one lights when the box holds it; a value of its own lights
95
+ * none), then the number box with its unit and, for a bounded range, a slider. The caller owns the value: it turns chips, keys
96
+ * and moves into saves.
97
+ */
98
+ export function ValueControl({ id, label, presets, text, placeholder, suffix, slider, onPreset, onText, onCommit, onSlide, onKeyDown, note, inputMode = "decimal", disabled }: ValueControlProps) {
99
+ const lit = presets.find((p) => p.value === text.trim())?.value;
100
+ return (
101
+ // A chip pressed right after typing keeps the box's focus: its blur would save what was typed, the save locks the field, and
102
+ // the chip's click landed on a disabled chip (the spacing box's own guard, review of slice 5). The chip is the choice.
103
+ <div className="gemboss-ei-value" onMouseDown={(e) => { if ((e.target as HTMLElement).closest('[role="group"] button')) e.preventDefault(); }}>
104
+ {presets.length ? <Seg label={`${label} presets`} value={lit} disabled={disabled} onChange={onPreset}
105
+ options={presets.map((p) => ({ value: p.value, label: p.label }))} /> : null}
106
+ <div className="gemboss-ei-value-row">
107
+ <span className="gemboss-ei-value-box">
108
+ <input id={id} className="gemboss-field gemboss-field--narrow gemboss-ei-inp" inputMode={inputMode} value={text} placeholder={placeholder}
109
+ disabled={disabled} aria-label={label} onChange={(e) => onText(e.target.value)} onBlur={onCommit}
110
+ onKeyDown={(e) => { onKeyDown?.(e); if (!e.defaultPrevented) blurOnEnter(e); }} />
111
+ {suffix ? <span className="gemboss-ei-suffix">{suffix}</span> : null}
112
+ </span>
113
+ {slider && onSlide ? <RangeControl min={slider.min} max={slider.max} step={slider.step} value={slider.value} disabled={disabled} onChange={onSlide} /> : null}
114
+ </div>
115
+ {note ? <p className="gemboss-ei-hint" role="status">{note}</p> : null}
116
+ </div>
53
117
  );
54
118
  }
55
119
 
@@ -132,11 +196,26 @@ export interface EdgeBoxProps {
132
196
  narrow?: boolean;
133
197
  /** Ids for the boxes, per side. */
134
198
  ids?: Partial<Record<EdgeSide, string>>;
199
+ /**
200
+ * A side's letter dragged sideways (Figma's and Webflow's scrub, 0.37.0): `dx`, the pixels moved since the press, and the keys
201
+ * held now, for the caller to turn into a value. A mouse or a pen only: a finger on the letter scrolls the panel as before, and
202
+ * the box takes the number (its 44px under a finger is the control there). Present, each letter is a 24px drag row.
203
+ */
204
+ onScrub?: (side: EdgeSide, dx: number, keys: { shiftKey: boolean; altKey: boolean }) => void;
205
+ /** The drag let go: `moved` when it changed anything (the caller saves once, one Undo step); not moved, it was a click. */
206
+ onScrubEnd?: (side: EdgeSide, moved: boolean) => void;
135
207
  }
136
208
 
137
209
  /** `.gemboss-ei-edgebox`: four px boxes, T · R · B · L, mono and centred. */
138
- export function EdgeBox({ label, values, placeholders, onChange, onCommit, disabled, narrow = true, ids }: EdgeBoxProps) {
210
+ export function EdgeBox({ label, values, placeholders, onChange, onCommit, disabled, narrow = true, ids, onScrub, onScrubEnd }: EdgeBoxProps) {
139
211
  const cls = narrow ? "gemboss-field gemboss-field--narrow gemboss-ei-inp" : "gemboss-field gemboss-ei-inp";
212
+ const drag = useRef<{ side: EdgeSide; id: number; x: number; moved: boolean } | null>(null);
213
+ const end = (e: React.PointerEvent<HTMLSpanElement>) => {
214
+ const d = drag.current;
215
+ if (!d || d.id !== e.pointerId) return;
216
+ drag.current = null;
217
+ onScrubEnd?.(d.side, d.moved);
218
+ };
140
219
  return (
141
220
  <div className="gemboss-ei-edgebox">
142
221
  {SIDES.map((s) => (
@@ -144,7 +223,29 @@ export function EdgeBox({ label, values, placeholders, onChange, onCommit, disab
144
223
  <input className={cls} inputMode="numeric" placeholder={placeholders?.[s] ?? "0"}
145
224
  aria-label={`${label} ${SIDE_NAME[s]}`} id={ids?.[s]} value={values[s] ?? ""} disabled={disabled}
146
225
  onChange={(e) => onChange(s, e.target.value)} onBlur={onCommit} onKeyDown={blurOnEnter} />
147
- <span>{s.toUpperCase()}</span>
226
+ {onScrub ? (
227
+ // Rendered while disabled too: a field that locks in the middle of a drag (another save landing) must not drop the
228
+ // drag's handlers, or it never ends and nothing is saved (review of 3a). A drag only refuses to START when disabled.
229
+ <span className="gemboss-ei-edge-scrub" title={`Drag to change ${SIDE_NAME[s]}`} aria-disabled={disabled || undefined}
230
+ onPointerDown={(e) => {
231
+ if (disabled || e.pointerType === "touch" || e.button !== 0) return;
232
+ e.preventDefault();
233
+ e.currentTarget.setPointerCapture(e.pointerId);
234
+ drag.current = { side: s, id: e.pointerId, x: e.clientX, moved: false };
235
+ }}
236
+ onPointerMove={(e) => {
237
+ const d = drag.current;
238
+ if (!d || d.id !== e.pointerId) return;
239
+ const dx = e.clientX - d.x;
240
+ // A press that wanders a pixel is still a click.
241
+ if (!d.moved && Math.abs(dx) < 2) return;
242
+ d.moved = true;
243
+ onScrub(s, dx, { shiftKey: e.shiftKey, altKey: e.altKey });
244
+ }}
245
+ onPointerUp={end} onPointerCancel={end}
246
+ // Its own capture only (the event bubbles from nothing here, but the check is the fleet's rule).
247
+ onLostPointerCapture={(e) => { if (e.target === e.currentTarget) end(e); }}>{s.toUpperCase()}</span>
248
+ ) : <span>{s.toUpperCase()}</span>}
148
249
  </div>
149
250
  ))}
150
251
  </div>
@@ -156,20 +156,24 @@ export function EditorBack({ as: Comp = "button", label = "Back", ...rest }:
156
156
 
157
157
  /**
158
158
  * The auto-save pill: "Saving…" with a spinner while an edit is in flight, "✓ Draft saved" once it lands.
159
- * `idle` draws nothing unless `idleText` says what autosave means here (the section editor's "Autosaves", dimmed).
159
+ * `idle` draws nothing unless `idleText` says what autosave means here (the section editor's "Autosaves", muted).
160
+ * `live` (default true): "Saving…" and "✓ Draft saved" are a polite live region. `live={false}` for a surface that speaks
161
+ * through a live region of its own, one that is in the page from its first render and says only what changed (the page
162
+ * editor's save pill): the pill is then words with a title, and nothing is announced twice.
160
163
  */
161
164
  export function EditorSaveState({
162
165
  state, savedText = "✓ Draft saved", savingText = "Saving…", savingTitle = "Saving draft…",
163
- savedTitle = "Every change is saved to the draft automatically", idleText,
166
+ savedTitle = "Every change is saved to the draft automatically", idleText, live = true,
164
167
  }: {
165
168
  state: "idle" | "saving" | "saved";
166
169
  savedText?: React.ReactNode; savingText?: React.ReactNode; savingTitle?: string; savedTitle?: string; idleText?: React.ReactNode;
170
+ live?: boolean;
167
171
  }) {
168
172
  if (state === "idle") {
169
- return idleText != null && idleText !== false ? <span className="gemboss-editor-savestate" title={savedTitle} style={{ opacity: 0.55 }}>{idleText}</span> : null;
173
+ return idleText != null && idleText !== false ? <span className="gemboss-editor-savestate is-idle" title={savedTitle}>{idleText}</span> : null;
170
174
  }
171
175
  return (
172
- <span className={`gemboss-editor-savestate is-${state}`} role="status" aria-live="polite" title={state === "saving" ? savingTitle : savedTitle}>
176
+ <span className={`gemboss-editor-savestate is-${state}`} {...(live ? { role: "status", "aria-live": "polite" as const } : null)} title={state === "saving" ? savingTitle : savedTitle}>
173
177
  {state === "saving" ? <><span className="gemboss-narr-spin" aria-hidden />{savingText}</> : savedText}
174
178
  </span>
175
179
  );
@@ -36,8 +36,8 @@ 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, ValueControl,
40
+ type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps, type ValueControlProps,
41
41
  } from "./controls";
42
42
  export { swatchState, type SwatchState } from "./swatch";
43
43
  export { cssUrl } from "./css-url";
@@ -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";
@@ -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 ───────────────────────────────────────────────────────────────────────────────────────────────────────
@@ -161,29 +161,53 @@ export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenCh
161
161
  // ── Field ───────────────────────────────────────────────────────────────────────────────────────────────────────────
162
162
 
163
163
  export interface FieldProps {
164
- /** 13px / 500 above the control. `undefined` / `null` draws no label (a control named by its own aria-label). */
164
+ /** 13px above the control (beside it in a `row`). `undefined` / `null` draws no label (a control named by its own aria-label). */
165
165
  label?: React.ReactNode;
166
166
  /** The control's id, so a click on the label focuses it. */
167
167
  htmlFor?: string;
168
168
  /** A value shown at the label's right edge, bold and tabular (a range's current value: `.gemboss-ei-rangeval`). */
169
169
  labelValue?: React.ReactNode;
170
- /** A muted line under the control (`p.gemboss-ei-hint`). Put it in `children` instead to place it elsewhere. */
170
+ /** The setting's help (`p.gemboss-ei-hint.gemboss-ei-help-text`). In a plugin's form a line under the control; inside an
171
+ * inspector group (`InspectorGroup`) a ? beside the label opens and closes it (0.39.0). */
171
172
  hint?: React.ReactNode;
173
+ /** A one-value setting (a number, a select, a colour, chips): inside an inspector group the label goes LEFT and the control
174
+ * right, one row (0.39.0). Outside a group nothing changes. */
175
+ row?: boolean;
172
176
  /** Added to `.gemboss-ei-fld` (the admin's `togrow`). */
173
177
  className?: string;
174
178
  style?: React.CSSProperties;
175
179
  children?: React.ReactNode;
176
180
  }
177
181
 
178
- /** One labelled row of a panel: `div.gemboss-ei-fld` › label · control · hint. 12px under each row but the last. */
179
- export function Field({ label, htmlFor, labelValue, hint, className, style, children }: FieldProps) {
182
+ /**
183
+ * One labelled setting: `div.gemboss-ei-fld` › label · control · help. 12px under each but the last.
184
+ *
185
+ * INSIDE AN INSPECTOR GROUP (0.39.0, Chris 16/10/2026: the settings panel looked bigger than the kit and Polaris; research
186
+ * `_local/gemboss/research/inspector-density-2026-10-16.md`). The controls were Polaris-sized already (field 32, chips 28);
187
+ * the panel was long: a label line over every control (70px a setting) and help text under it, 22% of a countdown's panel.
188
+ * There a `row` is label left, control right (GemPages: 52px a setting), the label is quiet (bodyMd, secondary) and the help
189
+ * is behind a ? (GemPages, PageFly). Outside a group, a plugin's settings form, a help line under a wide field is the
190
+ * Polaris form and stays.
191
+ */
192
+ export function Field({ label, htmlFor, labelValue, hint, row = false, className, style, children }: FieldProps) {
193
+ const helpId = useId();
194
+ const [helpOpen, setHelpOpen] = useState(false);
195
+ const cls = ["gemboss-ei-fld", row && label != null ? "gemboss-ei-fld--row" : "", className].filter(Boolean).join(" ");
196
+ const labelNode = label != null ? (
197
+ <label htmlFor={htmlFor}>{label}{labelValue != null ? <span className="gemboss-ei-rangeval">{labelValue}</span> : null}</label>
198
+ ) : null;
180
199
  return (
181
- <div className={className ? `gemboss-ei-fld ${className}` : "gemboss-ei-fld"} style={style}>
182
- {label != null ? (
183
- <label htmlFor={htmlFor}>{label}{labelValue != null ? <span className="gemboss-ei-rangeval">{labelValue}</span> : null}</label>
184
- ) : null}
200
+ <div className={cls} style={style}>
201
+ {/* A ? is a button, so it sits BESIDE the label, not in it (a label may hold no other labelable element). */}
202
+ {hint && labelNode ? (
203
+ <div className="gemboss-ei-lbl">
204
+ {labelNode}
205
+ <button type="button" className="gemboss-ei-help" aria-label={typeof label === "string" ? `About ${label}` : "About this setting"} aria-describedby={helpId} aria-expanded={helpOpen}
206
+ onClick={() => setHelpOpen((o) => !o)} onKeyDown={(e) => { if (e.key === "Escape") setHelpOpen(false); }}>?</button>
207
+ </div>
208
+ ) : labelNode}
185
209
  {children}
186
- {hint ? <p className="gemboss-ei-hint">{hint}</p> : null}
210
+ {hint ? <p className="gemboss-ei-hint gemboss-ei-help-text" id={helpId}>{hint}</p> : null}
187
211
  </div>
188
212
  );
189
213
  }
@@ -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",