@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.
- package/CHANGELOG.md +207 -0
- package/README.md +71 -11
- package/catalog/editor.html +33 -15
- package/catalog/index.html +143 -7
- package/gemboss-ui.css +3 -3
- package/package.json +9 -2
- package/parts/components.css +2 -2
- package/parts/core.css +1 -1
- package/parts/shell.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/editor/controls.tsx +105 -4
- package/src/editor/frame.tsx +8 -4
- package/src/editor/index.ts +7 -3
- package/src/editor/panel.tsx +34 -10
- package/src/editor/repeater.tsx +244 -0
- package/src/editor/sortable.tsx +50 -24
- package/src/editor/tiles.tsx +51 -0
- package/src/editor-repeater.css +1 -0
- package/src/editor-tiles.css +1 -0
- package/src/editor.css +1 -1
- package/src/index.ts +3 -1
- 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 +10 -3
- package/src/services.ts +4 -2
- package/src/toggle-bar.tsx +60 -0
- package/src/ui.css +1 -1
package/src/editor/controls.tsx
CHANGED
|
@@ -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
|
-
|
|
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>
|
package/src/editor/frame.tsx
CHANGED
|
@@ -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",
|
|
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}
|
|
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
|
|
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
|
);
|
package/src/editor/index.ts
CHANGED
|
@@ -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";
|
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 ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
179
|
-
|
|
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={
|
|
182
|
-
{label
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
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",
|