@gemboss/ui 0.21.0 → 0.25.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 +150 -0
- package/README.md +177 -6
- package/catalog/editor.html +426 -10
- package/catalog/index.html +184 -32
- package/gemboss-ui.css +3 -3
- package/package.json +11 -3
- package/parts/components.css +5 -0
- package/parts/core.css +5 -0
- package/parts/shell.css +5 -0
- package/src/app-shell.css +1 -1
- package/src/assistant/engine.tsx +607 -0
- package/src/assistant/host.tsx +1 -0
- package/src/assistant/index.ts +1 -0
- package/src/assistant-engine.css +1 -0
- package/src/auth.tsx +1 -0
- package/src/collections.tsx +1 -0
- package/src/dialog.tsx +3 -135
- package/src/editor/controls.tsx +1 -0
- package/src/editor/frame.tsx +1 -0
- package/src/editor/index.ts +9 -0
- package/src/editor/layers.tsx +136 -0
- package/src/editor/panel.tsx +1 -0
- package/src/editor/panes.tsx +4 -3
- package/src/editor/sortable.tsx +79 -0
- package/src/editor/tree.tsx +61 -0
- package/src/editor-layers-plain.js +1 -0
- package/src/editor.css +1 -1
- package/src/index-table.tsx +1 -0
- package/src/index.ts +4 -2
- package/src/kit-plain.css +1 -1
- package/src/launcher.tsx +1 -0
- package/src/menu-layer.ts +9 -2
- package/src/portal.tsx +1 -0
- package/src/reactions.tsx +1 -0
- package/src/resource-list-select.tsx +1 -0
- package/src/search-dialog.tsx +146 -0
- package/src/selection.tsx +1 -0
- package/src/theme-script.ts +10 -0
- package/src/theme.tsx +5 -5
package/src/dialog.tsx
CHANGED
|
@@ -9,12 +9,10 @@
|
|
|
9
9
|
* sign-in and "Create a new post" open like this (Featurebase does both as popups: Chris 04/10/2026,
|
|
10
10
|
* "signup login là bật ra popup đó nhanh và tiện hơn").
|
|
11
11
|
*
|
|
12
|
-
* `SearchDialog
|
|
13
|
-
*
|
|
14
|
-
* (debounced, the latest answer wins); ↑ ↓ move, Enter opens, Esc closes. Results are links, so a
|
|
15
|
-
* page without JavaScript loses only the popup, never the search itself (`fallbackHref`).
|
|
12
|
+
* `SearchDialog` lives in search-dialog.tsx since 0.23.0: it brings portal.css (its own look), and a
|
|
13
|
+
* surface that only opens a Dialog should not download it.
|
|
16
14
|
*/
|
|
17
|
-
import React, {
|
|
15
|
+
import React, { useEffect, useId, useRef } from "react";
|
|
18
16
|
import { Icon } from "./icons";
|
|
19
17
|
|
|
20
18
|
const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
|
|
@@ -63,133 +61,3 @@ export function Dialog({ open, onClose, title, children, footer, wide = false, c
|
|
|
63
61
|
</dialog>
|
|
64
62
|
);
|
|
65
63
|
}
|
|
66
|
-
|
|
67
|
-
export type SearchItem = { href: string; title: string; meta?: React.ReactNode };
|
|
68
|
-
export type SearchSection = { title: string; items: SearchItem[] };
|
|
69
|
-
|
|
70
|
-
// One constant, not a default `= []`: a fresh array each render, read by an effect, re-ran it forever
|
|
71
|
-
// ("Maximum update depth exceeded", review of 0.7.0 in Chromium).
|
|
72
|
-
const NO_SECTIONS: SearchSection[] = [];
|
|
73
|
-
|
|
74
|
-
export function SearchDialog({ search, initial = NO_SECTIONS, placeholder = "Search…", label = "Search", fallbackHref, shortcut = true, renderItem }: {
|
|
75
|
-
/** Results for a query. Called at most every 150 ms; an answer to an older query is dropped. */
|
|
76
|
-
search: (q: string) => Promise<SearchSection[]>;
|
|
77
|
-
/** What the popup shows before anything is typed (Featurebase: "Trending requests", "Latest updates"). */
|
|
78
|
-
initial?: SearchSection[];
|
|
79
|
-
placeholder?: string;
|
|
80
|
-
label?: string;
|
|
81
|
-
/** Where the topbar button goes without JavaScript (a search page), used as given. */
|
|
82
|
-
fallbackHref?: string;
|
|
83
|
-
/** ⌘K / Ctrl+K and "/" open it. */
|
|
84
|
-
shortcut?: boolean;
|
|
85
|
-
renderItem?: (item: SearchItem) => React.ReactNode;
|
|
86
|
-
}) {
|
|
87
|
-
const [open, setOpen] = useState(false);
|
|
88
|
-
const [q, setQ] = useState("");
|
|
89
|
-
const [sections, setSections] = useState<SearchSection[]>(initial);
|
|
90
|
-
const [active, setActive] = useState(0);
|
|
91
|
-
const [busy, setBusy] = useState(false);
|
|
92
|
-
const ref = useRef<HTMLDialogElement>(null);
|
|
93
|
-
const input = useRef<HTMLInputElement>(null);
|
|
94
|
-
const seq = useRef(0);
|
|
95
|
-
const listId = useId();
|
|
96
|
-
const flat = sections.flatMap((s) => s.items);
|
|
97
|
-
// The latest `search` and `initial`, read when needed: a consumer's inline arrow or array (a new one
|
|
98
|
-
// each render) must not restart the debounce or the effect.
|
|
99
|
-
const searchRef = useRef(search);
|
|
100
|
-
const initialRef = useRef(initial);
|
|
101
|
-
searchRef.current = search;
|
|
102
|
-
initialRef.current = initial;
|
|
103
|
-
|
|
104
|
-
const show = useCallback(() => {
|
|
105
|
-
const d = ref.current;
|
|
106
|
-
if (d && !d.open) { d.showModal(); setOpen(true); requestAnimationFrame(() => input.current?.focus()); }
|
|
107
|
-
}, []);
|
|
108
|
-
useEffect(() => {
|
|
109
|
-
if (!shortcut) return undefined;
|
|
110
|
-
const onKey = (e: KeyboardEvent) => {
|
|
111
|
-
const t = e.target as HTMLElement | null;
|
|
112
|
-
const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
|
|
113
|
-
// A handler that already took the key (the admin's own ⌘K palette) keeps it.
|
|
114
|
-
if (e.defaultPrevented) return;
|
|
115
|
-
if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) { e.preventDefault(); show(); }
|
|
116
|
-
};
|
|
117
|
-
document.addEventListener("keydown", onKey);
|
|
118
|
-
return () => document.removeEventListener("keydown", onKey);
|
|
119
|
-
}, [shortcut, show]);
|
|
120
|
-
useEffect(() => {
|
|
121
|
-
if (!open) return undefined;
|
|
122
|
-
const query = q.trim();
|
|
123
|
-
// Every change takes a new number: an answer still on its way for an older query is dropped,
|
|
124
|
-
// including when the box was just cleared (it used to land under an empty box).
|
|
125
|
-
const mine = ++seq.current;
|
|
126
|
-
if (!query) { setSections(initialRef.current); setActive(0); setBusy(false); return undefined; }
|
|
127
|
-
setBusy(true);
|
|
128
|
-
const t = setTimeout(async () => {
|
|
129
|
-
try {
|
|
130
|
-
const res = await searchRef.current(query);
|
|
131
|
-
if (mine === seq.current) { setSections(res); setActive(0); }
|
|
132
|
-
} catch {
|
|
133
|
-
if (mine === seq.current) setSections(NO_SECTIONS);
|
|
134
|
-
} finally {
|
|
135
|
-
if (mine === seq.current) setBusy(false);
|
|
136
|
-
}
|
|
137
|
-
}, 150);
|
|
138
|
-
return () => { clearTimeout(t); if (mine === seq.current) setBusy(false); };
|
|
139
|
-
}, [q, open]);
|
|
140
|
-
// Before anything is typed, show the latest suggestions the page passed.
|
|
141
|
-
useEffect(() => { if (open && !q.trim()) setSections(initial); }, [initial, open, q]);
|
|
142
|
-
// The highlighted result stays in view as the arrows move it.
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
if (!open) return;
|
|
145
|
-
document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" });
|
|
146
|
-
}, [active, open, listId]);
|
|
147
|
-
|
|
148
|
-
const go = (item: SearchItem | undefined) => { if (item) window.location.assign(item.href); };
|
|
149
|
-
let n = -1;
|
|
150
|
-
return (
|
|
151
|
-
<>
|
|
152
|
-
<a className="gb-search-trigger" href={fallbackHref ?? "#"} aria-label={label} aria-haspopup="dialog"
|
|
153
|
-
onClick={(e) => { e.preventDefault(); show(); }}>
|
|
154
|
-
<Icon name="search" size={18} />
|
|
155
|
-
</a>
|
|
156
|
-
<dialog ref={ref} className="ui-modal gb-search" aria-label={label}
|
|
157
|
-
onClose={() => { setOpen(false); setQ(""); }}
|
|
158
|
-
onClick={(e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }}>
|
|
159
|
-
<div className="gb-search-box">
|
|
160
|
-
<Icon name="search" size={18} className="gb-search-icon" />
|
|
161
|
-
<input ref={input} className="gb-search-input" type="search" placeholder={placeholder} aria-label={label}
|
|
162
|
-
value={q} onChange={(e) => setQ(e.target.value)} role="combobox" aria-expanded="true" aria-controls={listId}
|
|
163
|
-
aria-activedescendant={flat[active] ? `${listId}-${active}` : undefined} autoComplete="off"
|
|
164
|
-
onKeyDown={(e) => {
|
|
165
|
-
if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, flat.length - 1)); }
|
|
166
|
-
else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
|
|
167
|
-
// Enter that confirms an IME composition (Vietnamese, Chinese) is not "open".
|
|
168
|
-
else if (e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); go(flat[active]); }
|
|
169
|
-
}} />
|
|
170
|
-
</div>
|
|
171
|
-
<p className="gb-search-empty" aria-live="polite">
|
|
172
|
-
{sections.every((s) => !s.items.length) && q.trim() ? (busy ? "Searching…" : "Nothing matches.") : ""}
|
|
173
|
-
</p>
|
|
174
|
-
<div className="gb-search-results" id={listId} role="listbox" aria-busy={busy}>
|
|
175
|
-
{sections.filter((s) => s.items.length).map((s) => (
|
|
176
|
-
<div key={s.title} className="gb-search-section" role="group" aria-label={s.title}>
|
|
177
|
-
<div className="gb-search-section-title">{s.title}</div>
|
|
178
|
-
{s.items.map((it) => {
|
|
179
|
-
n += 1;
|
|
180
|
-
const i = n;
|
|
181
|
-
return (
|
|
182
|
-
<a key={`${s.title}-${it.href}`} id={`${listId}-${i}`} href={it.href} role="option" aria-selected={i === active}
|
|
183
|
-
className={cx("gb-search-item", i === active && "is-active")} onMouseMove={() => setActive(i)}>
|
|
184
|
-
{renderItem ? renderItem(it) : (<><span className="gb-search-item-title">{it.title}</span>{it.meta ? <span className="gb-search-item-meta">{it.meta}</span> : null}</>)}
|
|
185
|
-
</a>
|
|
186
|
-
);
|
|
187
|
-
})}
|
|
188
|
-
</div>
|
|
189
|
-
))}
|
|
190
|
-
</div>
|
|
191
|
-
<div className="gb-search-foot" aria-hidden="true"><span>↵ open</span><span>↑↓ move</span><span>esc</span></div>
|
|
192
|
-
</dialog>
|
|
193
|
-
</>
|
|
194
|
-
);
|
|
195
|
-
}
|
package/src/editor/controls.tsx
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// · the slider commits on every change (its number);
|
|
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
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
13
14
|
import { useEffect, useState } from "react";
|
|
14
15
|
import type React from "react";
|
|
15
16
|
import { cssUrl } from "./css-url";
|
package/src/editor/frame.tsx
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// by the plugin editor and the studio canvas, so together they ride in the chunk the toggle already had in the admin.
|
|
4
4
|
// Split into one module per piece, the admin build grew a lazy chunk to place them (its runtime +52 bytes, every
|
|
5
5
|
// screen's first load +20 B brotli). Measured 06/10/2026.
|
|
6
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
6
7
|
import type React from "react";
|
|
7
8
|
|
|
8
9
|
/** Which half a phone shows (≤760px shows one at a time; wider shows both). Values are the CSS's `data-mobile-pane`. */
|
package/src/editor/index.ts
CHANGED
|
@@ -41,3 +41,12 @@ export {
|
|
|
41
41
|
} from "./controls";
|
|
42
42
|
export { swatchState, type SwatchState } from "./swatch";
|
|
43
43
|
export { cssUrl } from "./css-url";
|
|
44
|
+
// Step 4 (0.22.0): the page's layers and the drawer (`layers`, stateless; the page editor draws them), a list whose rows
|
|
45
|
+
// reorder (`sortable`, "use client": a drag holds state; no admin screen uses it), and the element outline (`tree`, the
|
|
46
|
+
// inspector's). Same rule: grouped by who draws what.
|
|
47
|
+
export {
|
|
48
|
+
EditorDrawer, LayerList, layerName,
|
|
49
|
+
type EditorDrawerProps, type LayerItem, type LayerListProps,
|
|
50
|
+
} from "./layers";
|
|
51
|
+
export { SortableLayerList, type SortableLayerListProps } from "./sortable";
|
|
52
|
+
export { LayerTree, type LayerTreeProps, type LayerTreeRow } from "./tree";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// The editor's LAYERS and its DRAWER (step 4 of 5 of the editor kit): the page's sections as rows (LayerList) and the
|
|
2
|
+
// panel that slides over the editor from a side (EditorDrawer). Moved from the admin's page editor (Layers, Page settings,
|
|
3
|
+
// Edit content) with the markup and the classes they had, so those draw and behave exactly as before; the look is
|
|
4
|
+
// editor.css (`.gemboss-layer*`, `.gemboss-settings*`, `.gemboss-picker-*`). Stateless, so no "use client".
|
|
5
|
+
//
|
|
6
|
+
// Reordering by drag and Alt+↑ / Alt+↓ is SortableLayerList (sortable.tsx, its own module): a list that does not reorder
|
|
7
|
+
// (the admin's) does not carry that code. A section's elements as an indented outline (LayerTree) is tree.tsx.
|
|
8
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
9
|
+
import type React from "react";
|
|
10
|
+
|
|
11
|
+
// ── LayerList ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface LayerItem {
|
|
14
|
+
id: string;
|
|
15
|
+
/** The row's first line (`.gemboss-layer-type`, 13px / 600, capitalised by CSS). */
|
|
16
|
+
title: React.ReactNode;
|
|
17
|
+
/** The second line, muted and cut with an ellipsis (`.gemboss-layer-head`). Drawn only when truthy. */
|
|
18
|
+
subtitle?: React.ReactNode;
|
|
19
|
+
/** The row's name for a screen reader where the title is not plain text ("Hide <name>", "Move <name>"). */
|
|
20
|
+
name?: string;
|
|
21
|
+
/** Nesting: each level indents the row by `indent` px. 0 / omitted draws no style. */
|
|
22
|
+
depth?: number;
|
|
23
|
+
/** Hidden on the page: the row is drawn faded (`.is-hidden`) and its eye button is pressed. */
|
|
24
|
+
hidden?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LayerListProps<T extends LayerItem = LayerItem> {
|
|
28
|
+
items: ReadonlyArray<T>;
|
|
29
|
+
/** The selected row (`.is-sel`, and its button says `aria-current`). */
|
|
30
|
+
selectedId?: string | null;
|
|
31
|
+
/** A click on a row's main button. */
|
|
32
|
+
onSelect?: (item: T, index: number) => void;
|
|
33
|
+
/** The row's own buttons, after the main one (`.gemboss-layer-acts`, 26px each). */
|
|
34
|
+
actions?: (item: T, index: number) => React.ReactNode;
|
|
35
|
+
/** Shown instead of the rows when there are none (`.gemboss-picker-empty`). */
|
|
36
|
+
empty?: React.ReactNode;
|
|
37
|
+
/** Hide / show, on when given: an eye button first among the row's actions (`aria-pressed` = hidden). */
|
|
38
|
+
onToggleHidden?: (item: T, hidden: boolean) => void;
|
|
39
|
+
/** Px per `depth` level. Default 14 (the element outline's step). */
|
|
40
|
+
indent?: number;
|
|
41
|
+
/** Extra props for a row's element and its main button, and what goes before the button: how SortableLayerList adds
|
|
42
|
+
* dragging and keys without a second copy of the markup. */
|
|
43
|
+
rowProps?: (item: T, index: number) => React.HTMLAttributes<HTMLDivElement> & { "data-drop"?: string; className?: string };
|
|
44
|
+
mainProps?: (item: T, index: number) => React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
45
|
+
lead?: (item: T, index: number) => React.ReactNode;
|
|
46
|
+
/** The list element's ref. */
|
|
47
|
+
listRef?: React.Ref<HTMLDivElement>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const layerName = (it: LayerItem) => it.name ?? (typeof it.title === "string" ? it.title : it.id);
|
|
51
|
+
const EYE = "M1.5 8s2.4-4.5 6.5-4.5S14.5 8 14.5 8s-2.4 4.5-6.5 4.5S1.5 8 1.5 8Z";
|
|
52
|
+
|
|
53
|
+
/** `.gemboss-layers-list` › one `.gemboss-layer` per item: the main button (title · subtitle), then the actions. */
|
|
54
|
+
export function LayerList<T extends LayerItem>({ items, selectedId, onSelect, actions, empty, onToggleHidden, indent = 14, rowProps, mainProps, lead, listRef }: LayerListProps<T>) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="gemboss-layers-list" ref={listRef}>
|
|
57
|
+
{items.length === 0 ? (empty != null ? <div className="gemboss-picker-empty">{empty}</div> : null) : items.map((it, i) => {
|
|
58
|
+
const { className: extra, ...row } = rowProps?.(it, i) ?? {};
|
|
59
|
+
const cls = `gemboss-layer${selectedId === it.id ? " is-sel" : ""}${it.hidden ? " is-hidden" : ""}${extra ? ` ${extra}` : ""}`;
|
|
60
|
+
// The eye's name stays "Hide <name>" whichever way it is set: a toggle says its state with aria-pressed, and its
|
|
61
|
+
// tooltip says the same thing.
|
|
62
|
+
const eyeName = `Hide ${layerName(it)}`;
|
|
63
|
+
return (
|
|
64
|
+
<div key={it.id} className={cls} data-id={it.id} style={it.depth ? { paddingLeft: 6 + it.depth * indent } : undefined} {...row}>
|
|
65
|
+
{lead?.(it, i)}
|
|
66
|
+
<button type="button" className="gemboss-layer-main" aria-current={selectedId === it.id ? "true" : undefined}
|
|
67
|
+
onClick={onSelect ? () => onSelect(it, i) : undefined} {...mainProps?.(it, i)}>
|
|
68
|
+
<span className="gemboss-layer-type">{it.title}</span>
|
|
69
|
+
{it.subtitle ? <span className="gemboss-layer-head">{it.subtitle}</span> : null}
|
|
70
|
+
</button>
|
|
71
|
+
{actions || onToggleHidden ? (
|
|
72
|
+
<div className="gemboss-layer-acts">
|
|
73
|
+
{onToggleHidden ? (
|
|
74
|
+
<button type="button" className="gemboss-layer-eye" aria-pressed={!!it.hidden} aria-label={eyeName}
|
|
75
|
+
title={eyeName} onClick={() => onToggleHidden(it, !it.hidden)}>
|
|
76
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" aria-hidden>
|
|
77
|
+
<path d={EYE} /><circle cx="8" cy="8" r="2" />{it.hidden ? <path d="M2.5 13.5 13.5 2.5" data-gb-slash="" /> : null}
|
|
78
|
+
</svg>
|
|
79
|
+
</button>
|
|
80
|
+
) : null}
|
|
81
|
+
{actions?.(it, i)}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── EditorDrawer ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export interface EditorDrawerProps {
|
|
94
|
+
/**
|
|
95
|
+
* `right` (default): `aside.gemboss-settings`, min(380px, 100%) wide (Page settings, Edit content). `left`:
|
|
96
|
+
* `aside.gemboss-layers`, 300px (Layers). Both full height, over a backdrop that closes them.
|
|
97
|
+
*/
|
|
98
|
+
side?: "left" | "right";
|
|
99
|
+
/** The head's text (`.gemboss-picker-head`, 15px / 600) beside ✕. */
|
|
100
|
+
title: React.ReactNode;
|
|
101
|
+
onClose: () => void;
|
|
102
|
+
/** ✕'s accessible name. Default "Close". */
|
|
103
|
+
closeLabel?: string;
|
|
104
|
+
/** The drawer's accessible name. */
|
|
105
|
+
label?: string;
|
|
106
|
+
/** Added to the aside's class (the admin's `gemboss-page-section-content`). */
|
|
107
|
+
className?: string;
|
|
108
|
+
/** Above the head (the admin's "a newer revision exists" notice). */
|
|
109
|
+
before?: React.ReactNode;
|
|
110
|
+
/** The drawer's buttons, right-aligned at the bottom (`.gemboss-settings-foot`). */
|
|
111
|
+
footer?: React.ReactNode;
|
|
112
|
+
/** The body: a `.gemboss-settings-body` of `.gemboss-settings-field`s, a LayerList, … */
|
|
113
|
+
children?: React.ReactNode;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The panel that slides over the editor from a side, with a backdrop; ✕ and a click on the backdrop call `onClose`.
|
|
118
|
+
* Mount it while open (the caller holds whether it is): nothing is drawn while closed.
|
|
119
|
+
*/
|
|
120
|
+
export function EditorDrawer({ side = "right", title, onClose, closeLabel = "Close", label, className, before, footer, children }: EditorDrawerProps) {
|
|
121
|
+
const base = side === "left" ? "gemboss-layers" : "gemboss-settings";
|
|
122
|
+
return (
|
|
123
|
+
<>
|
|
124
|
+
<div className="gemboss-picker-backdrop" onClick={onClose} />
|
|
125
|
+
<aside className={className ? `${base} ${className}` : base} aria-label={label}>
|
|
126
|
+
{before}
|
|
127
|
+
<div className="gemboss-picker-head">
|
|
128
|
+
<span>{title}</span>
|
|
129
|
+
<button type="button" onClick={onClose} aria-label={closeLabel}>✕</button>
|
|
130
|
+
</div>
|
|
131
|
+
{children}
|
|
132
|
+
{footer ? <div className="gemboss-settings-foot">{footer}</div> : null}
|
|
133
|
+
</aside>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
package/src/editor/panel.tsx
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// the look is editor.css (`.gemboss-ec-tabs`, `.gemboss-ei-*`), unchanged by the move. Stateless except
|
|
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
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
7
8
|
import { useState } from "react";
|
|
8
9
|
import type React from "react";
|
|
9
10
|
|
package/src/editor/panes.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
1
2
|
import type { EditorPane } from "./frame";
|
|
2
3
|
|
|
3
4
|
// The two pane controls only an editor with a collapsible left column and a phone pane switch draws (the admin's theme
|
|
@@ -5,14 +6,14 @@ import type { EditorPane } from "./frame";
|
|
|
5
6
|
|
|
6
7
|
/** The panel icon that shows or hides the left column (EditorTopbar's `chatToggle`): the left third is filled while the
|
|
7
8
|
* column is open, hollow and pressed when it is collapsed. */
|
|
8
|
-
export function EditorChatToggle({ collapsed, onToggle }: { collapsed?: boolean; onToggle: () => void }) {
|
|
9
|
+
export function EditorChatToggle({ collapsed, onToggle, name = "chat panel" }: { collapsed?: boolean; onToggle: () => void; /** What the column is (0.24.0): "Hide <name>". */ name?: string }) {
|
|
9
10
|
return (
|
|
10
11
|
<button
|
|
11
12
|
className={`gemboss-editor-chat-toggle${collapsed ? " collapsed" : ""}`}
|
|
12
13
|
onClick={onToggle}
|
|
13
14
|
aria-pressed={collapsed}
|
|
14
|
-
title={collapsed ?
|
|
15
|
-
aria-label={collapsed ?
|
|
15
|
+
title={collapsed ? `Show ${name}` : name === "chat panel" ? "Hide chat — full-width preview" : `Hide ${name}`}
|
|
16
|
+
aria-label={collapsed ? `Show ${name}` : `Hide ${name}`}
|
|
16
17
|
>
|
|
17
18
|
{/* A real SVG panel icon (geometrically centred): the ▭/◫ box glyphs' ink sits high in the em box. */}
|
|
18
19
|
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// SortableLayerList (step 4 of 5 of the editor kit): LayerList whose rows reorder. Its own module, so a list that does
|
|
3
|
+
// not reorder (the admin's Layers, which move with ↑ / ↓ buttons) does not carry this code.
|
|
4
|
+
//
|
|
5
|
+
// · drag a row: a grip before it, the dragged row faded (`.is-dragging`), a 2px line where it will land (`data-drop`);
|
|
6
|
+
// · Alt+↑ / Alt+↓ on a row's button move it one place, and the focus stays on it once the move has landed (also when
|
|
7
|
+
// `onMove` answers later, after a server write: the focus waits for the row to reach its new place).
|
|
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
|
+
// HTML drag and drop does not start from a touch: a list that must reorder on a phone keeps ↑ / ↓ in its `actions`.
|
|
10
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
11
|
+
import { useEffect, useRef, useState } from "react";
|
|
12
|
+
import type React from "react";
|
|
13
|
+
import { LayerList, type LayerItem, type LayerListProps } from "./layers";
|
|
14
|
+
|
|
15
|
+
export interface SortableLayerListProps<T extends LayerItem = LayerItem> extends Omit<LayerListProps<T>, "rowProps" | "mainProps" | "lead" | "listRef"> {
|
|
16
|
+
/** A row moved: `to` is its index AFTER the move. */
|
|
17
|
+
onMove: (item: T, to: number) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
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);
|
|
22
|
+
// 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
|
+
// focus on a node React moves (measured: the Alt+↑/↓ tests pass without this); a browser that runs the DOM's focus
|
|
24
|
+
// fixup on the re-insert would drop it to <body>, so it is put back.
|
|
25
|
+
const refocus = useRef<{ id: string; from: number } | null>(null);
|
|
26
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
const want = refocus.current;
|
|
29
|
+
if (!want) return;
|
|
30
|
+
const at = items.findIndex((it) => it.id === want.id);
|
|
31
|
+
if (at === want.from) return; // not landed yet (onMove may answer later)
|
|
32
|
+
refocus.current = null;
|
|
33
|
+
if (at < 0) return;
|
|
34
|
+
const row = [...(listRef.current?.children ?? [])].find((r) => (r as HTMLElement).dataset.id === want.id);
|
|
35
|
+
row?.querySelector<HTMLButtonElement>(".gemboss-layer-main")?.focus();
|
|
36
|
+
}, [items]);
|
|
37
|
+
|
|
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
|
+
return (
|
|
50
|
+
<LayerList {...rest} items={items} listRef={listRef}
|
|
51
|
+
lead={() => <span className="gemboss-layer-grip" aria-hidden>⋮⋮</span>}
|
|
52
|
+
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,
|
|
55
|
+
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),
|
|
66
|
+
})}
|
|
67
|
+
mainProps={(it, i) => ({
|
|
68
|
+
"aria-keyshortcuts": "Alt+ArrowUp Alt+ArrowDown",
|
|
69
|
+
onKeyDown: (e) => {
|
|
70
|
+
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
|
71
|
+
const to = e.key === "ArrowUp" ? i - 1 : i + 1;
|
|
72
|
+
if (to < 0 || to >= items.length) return;
|
|
73
|
+
e.preventDefault();
|
|
74
|
+
refocus.current = { id: it.id, from: i };
|
|
75
|
+
onMove(it, to);
|
|
76
|
+
},
|
|
77
|
+
})} />
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// LayerTree (step 4 of 5 of the editor kit): a section's elements as an indented outline, the element inspector's
|
|
2
|
+
// Elements tab (moved from apps/admin ElementInspector.tsx with its markup and classes, `.gemboss-ei-tree` in
|
|
3
|
+
// editor.css). Its own module: the inspector draws it, the page editor's Layers drawer (layers.tsx) does not. Stateless,
|
|
4
|
+
// so no "use client".
|
|
5
|
+
import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
|
|
6
|
+
import type React from "react";
|
|
7
|
+
|
|
8
|
+
// ── LayerTree ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface LayerTreeRow {
|
|
11
|
+
id: string;
|
|
12
|
+
/** The row's name (13px / 600). */
|
|
13
|
+
label: React.ReactNode;
|
|
14
|
+
/** A muted aside after it, cut with an ellipsis (`small`: the element's id in the admin). */
|
|
15
|
+
detail?: React.ReactNode;
|
|
16
|
+
/** 0 for the root. Each level indents by `indent` px, after 6px. */
|
|
17
|
+
depth: number;
|
|
18
|
+
/** Its place among its siblings (0-based) and how many there are: `aria-posinset` / `aria-setsize`. */
|
|
19
|
+
index: number;
|
|
20
|
+
siblings: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LayerTreeProps<T extends LayerTreeRow = LayerTreeRow> {
|
|
24
|
+
/** Flattened, in reading order. */
|
|
25
|
+
rows: ReadonlyArray<T>;
|
|
26
|
+
selectedId?: string | null;
|
|
27
|
+
/** A click on a row's name. Toggling (a second click clears the selection) is the caller's: the admin's does. */
|
|
28
|
+
onSelect?: (row: T) => void;
|
|
29
|
+
/** Icon buttons after the name (`.gemboss-ei-tacts`: shown on hover, on the selected row and on focus). */
|
|
30
|
+
actions?: (row: T) => React.ReactNode;
|
|
31
|
+
/** Under the row, full width (`flex-basis:100%`): the admin's "Add an element…" picker. */
|
|
32
|
+
below?: (row: T) => React.ReactNode;
|
|
33
|
+
/** The tree's accessible name. */
|
|
34
|
+
label?: string;
|
|
35
|
+
/** Px per level. Default 14. */
|
|
36
|
+
indent?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** `ul.gemboss-ei-tree[role=tree]` › `li.gemboss-ei-tnode[role=treeitem]` (level, position, selection said aloud). */
|
|
40
|
+
export function LayerTree<T extends LayerTreeRow>({ rows, selectedId, onSelect, actions, below, label, indent = 14 }: LayerTreeProps<T>) {
|
|
41
|
+
return (
|
|
42
|
+
<ul className="gemboss-ei-tree" role="tree" aria-label={label}>
|
|
43
|
+
{rows.map((r) => {
|
|
44
|
+
const on = r.id === selectedId;
|
|
45
|
+
return (
|
|
46
|
+
// Flattened for layout (the indent is inline padding), so each row carries its own level and position or the
|
|
47
|
+
// outline announces as an empty tree.
|
|
48
|
+
<li key={r.id} role="treeitem" aria-level={r.depth + 1} aria-selected={on} aria-posinset={r.index + 1} aria-setsize={r.siblings}
|
|
49
|
+
className={`gemboss-ei-tnode${on ? " on" : ""}`} style={{ paddingLeft: 6 + r.depth * indent }} data-id={r.id}>
|
|
50
|
+
<button type="button" className="gemboss-ei-tname" aria-current={on ? "true" : undefined} onClick={onSelect ? () => onSelect(r) : undefined}>
|
|
51
|
+
{r.label}
|
|
52
|
+
{r.detail != null ? <small>{r.detail}</small> : null}
|
|
53
|
+
</button>
|
|
54
|
+
<span className="gemboss-ei-tacts">{actions?.(r)}</span>
|
|
55
|
+
{below?.(r)}
|
|
56
|
+
</li>
|
|
57
|
+
);
|
|
58
|
+
})}
|
|
59
|
+
</ul>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(v){"use strict";var m=v.gembossUI;if(!m){v.console&&v.console.error("@gemboss/ui editor-layers-plain.js: load kit-plain.js first (it defines gembossUI)");return}if(m.initEditorLayers)return;var d=function(r,s,t){r.dispatchEvent(new CustomEvent(s,{bubbles:!0,detail:t}))},u=function(r){return Array.prototype.filter.call(r.children,function(s){return s.classList.contains("gemboss-layer")})},f=function(r){return r.getAttribute("data-id")};function A(r){var s=r.parentNode;u(s).forEach(function(t){var o=t===r,e=t.querySelector(".gemboss-layer-main");t.classList.toggle("is-sel",o),e&&(o?e.setAttribute("aria-current","true"):e.removeAttribute("aria-current"))}),d(r,"gemboss:editor-layer",{id:f(r),index:u(s).indexOf(r)})}function L(r,s){var t=s.getAttribute("aria-pressed")!=="true";s.setAttribute("aria-pressed",String(t)),r.classList.toggle("is-hidden",t);var o=s.querySelector("svg"),e=o&&o.querySelector("[data-gb-slash]");o&&t&&!e&&(e=document.createElementNS("http://www.w3.org/2000/svg","path"),e.setAttribute("d","M2.5 13.5 13.5 2.5"),e.setAttribute("data-gb-slash",""),o.appendChild(e)),e&&!t&&e.parentNode.removeChild(e),d(r,"gemboss:editor-layer-hidden",{id:f(r),hidden:t})}function y(r,s,t){var o=r.parentNode,e=u(o),a=e.indexOf(r);if(!(s===a||s<0||s>=e.length)){var i=e.filter(function(l){return l!==r});if(o.insertBefore(r,i[s]||null),t){var n=r.querySelector(".gemboss-layer-main");n&&n.focus()}d(r,"gemboss:editor-layer-move",{id:f(r),from:a,to:s})}}function w(r){var s=r.closest(".gemboss-ei-tree"),t=r.getAttribute("aria-selected")==="true";Array.prototype.forEach.call(s.querySelectorAll(".gemboss-ei-tnode"),function(o){var e=o===r&&!t,a=o.querySelector(".gemboss-ei-tname");o.setAttribute("aria-selected",String(e)),o.classList.toggle("on",e),a&&(e?a.setAttribute("aria-current","true"):a.removeAttribute("aria-current"))}),d(r,"gemboss:editor-tree",{id:t?null:r.getAttribute("data-id")})}function h(r){var s=r.previousElementSibling;return{aside:r,backdrop:s&&s.classList.contains("gemboss-picker-backdrop")?s:null}}function g(r,s){var t=h(r);!r.hidden!==s&&(s&&Array.prototype.forEach.call(document.querySelectorAll(".gemboss-layers:not([hidden]), .gemboss-settings:not([hidden])"),function(o){o!==r&&h(o).backdrop&&g(o,!1)}),r.hidden=!s,t.backdrop&&(t.backdrop.hidden=!s),d(r,"gemboss:editor-drawer",{id:r.id||null,open:s}))}var b=typeof WeakSet=="function"?new WeakSet:null,p=function(r){return r.__gbList?!1:(r.__gbList=!0,!0)};function E(r){var s=r||document;if(b?b.has(s):s._gbList)return 0;b?b.add(s):s._gbList=!0;var t=null;s.addEventListener("click",function(e){var a=e.target,i;if(!(!a.closest||!p(e))){if(i=a.closest("[data-gb-drawer]")){var n=document.getElementById(i.getAttribute("data-gb-drawer"));n&&g(n,!0);return}if(i=a.closest(".gemboss-picker-head > button, [data-gb-drawer-close]")){var l=i.closest(".gemboss-layers, .gemboss-settings");l&&g(l,!1);return}if(i=a.closest(".gemboss-picker-backdrop")){var c=i.nextElementSibling;c&&c.matches(".gemboss-layers, .gemboss-settings")&&g(c,!1);return}if((i=a.closest(".gemboss-layer-eye"))&&!i.disabled)return L(i.closest(".gemboss-layer"),i);if(i=a.closest(".gemboss-layer-main"))return A(i.closest(".gemboss-layer"));if(i=a.closest(".gemboss-ei-tname"))return w(i.closest(".gemboss-ei-tnode"))}}),s.addEventListener("keydown",function(e){var a=e.target;if(!(!a.closest||!p(e))&&!(!e.altKey||e.key!=="ArrowUp"&&e.key!=="ArrowDown")){var i=a.classList&&a.classList.contains("gemboss-layer-main")&&a.closest('.gemboss-layer[draggable="true"]');i&&(e.preventDefault(),y(i,u(i.parentNode).indexOf(i)+(e.key==="ArrowUp"?-1:1),!0))}}),s.addEventListener("dragstart",function(e){var a=e.target.closest&&e.target.closest('.gemboss-layer[draggable="true"]');!a||!p(e)||(t={row:a,over:null,place:"before"},a.classList.add("is-dragging"),e.dataTransfer&&(e.dataTransfer.effectAllowed="move",e.dataTransfer.setData("text/plain",f(a)||"")))}),s.addEventListener("dragover",function(e){if(t){var a=e.target.closest&&e.target.closest(".gemboss-layer");if(!a||a.parentNode!==t.row.parentNode){t.over&&(t.over.removeAttribute("data-drop"),t.over=null);return}e.preventDefault();var i=a.getBoundingClientRect(),n=e.clientY<i.top+i.height/2?"before":"after";t.over&&t.over!==a&&t.over.removeAttribute("data-drop"),t.over=a,t.place=n,a===t.row?a.removeAttribute("data-drop"):a.setAttribute("data-drop",n)}}),s.addEventListener("drop",function(e){if(t){var a=e.target.closest&&e.target.closest(".gemboss-layer");if(!t.over||!a||a.parentNode!==t.row.parentNode){o();return}e.preventDefault();var i=u(t.row.parentNode),n=i.indexOf(t.row),l=i.indexOf(t.over),c=t.place==="before"?l>n?l-1:l:l>=n?l:l+1,k=t.row;o(),y(k,c,!1)}});var o=function(){t&&(t.row.classList.remove("is-dragging"),t.over&&t.over.removeAttribute("data-drop"),t=null)};return s.addEventListener("dragend",o),1}m.initEditorLayers=E})(typeof globalThis<"u"?globalThis:this);
|