@gemboss/ui 0.13.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +512 -1
  2. package/README.md +426 -14
  3. package/catalog/editor.html +1027 -0
  4. package/catalog/index.html +1215 -90
  5. package/gemboss-ui.css +3 -4698
  6. package/package.json +24 -5
  7. package/parts/components.css +5 -0
  8. package/parts/core.css +5 -0
  9. package/parts/shell.css +5 -0
  10. package/src/app-shell.css +1 -256
  11. package/src/assistant/engine.tsx +607 -0
  12. package/src/assistant/host.tsx +1 -0
  13. package/src/assistant/index.ts +1 -0
  14. package/src/assistant-engine.css +1 -0
  15. package/src/assistant.css +1 -558
  16. package/src/auth.css +1 -606
  17. package/src/auth.tsx +1 -0
  18. package/src/board-move.ts +194 -0
  19. package/src/chat-composer.css +1 -110
  20. package/src/chat.css +1 -432
  21. package/src/collections.css +1 -350
  22. package/src/collections.tsx +170 -40
  23. package/src/dialog.tsx +3 -135
  24. package/src/editor/controls.tsx +207 -0
  25. package/src/editor/css-url.ts +11 -0
  26. package/src/editor/frame.tsx +243 -0
  27. package/src/editor/index.ts +52 -0
  28. package/src/editor/layers.tsx +136 -0
  29. package/src/editor/panel.tsx +189 -0
  30. package/src/editor/panes.tsx +45 -0
  31. package/src/editor/sortable.tsx +79 -0
  32. package/src/editor/swatch.ts +45 -0
  33. package/src/editor/tree.tsx +61 -0
  34. package/src/editor-layers-plain.js +1 -0
  35. package/src/editor-plain.js +1 -0
  36. package/src/editor.css +1 -1099
  37. package/src/gemboss-field.css +1 -112
  38. package/src/gemboss-tokens.css +1 -164
  39. package/src/icons.tsx +2 -0
  40. package/src/index-table.tsx +20 -56
  41. package/src/index.ts +16 -6
  42. package/src/kit-plain.css +1 -499
  43. package/src/kit-plain.js +1 -575
  44. package/src/launcher.tsx +1 -0
  45. package/src/menu-layer.ts +94 -0
  46. package/src/menu.tsx +28 -2
  47. package/src/portal.css +1 -139
  48. package/src/portal.tsx +1 -0
  49. package/src/reactions-data.ts +59 -0
  50. package/src/reactions.css +1 -0
  51. package/src/reactions.tsx +140 -0
  52. package/src/resource-list-select.tsx +73 -0
  53. package/src/search-dialog.tsx +146 -0
  54. package/src/segmented-click.ts +20 -0
  55. package/src/segmented.tsx +98 -0
  56. package/src/selection.tsx +79 -0
  57. package/src/shell.css +1 -1079
  58. package/src/theme-dark.css +1 -66
  59. package/src/theme-script.ts +10 -0
  60. package/src/theme.tsx +5 -5
  61. package/src/ui.css +1 -610
@@ -0,0 +1,189 @@
1
+ "use client";
2
+ // The settings PANEL's pieces (0.17.0, step 3 of 5 of the editor kit): the tab rows, the panel header, the collapsible
3
+ // group and the field row. The admin draws its Configure column, its element inspector and its schema form with these;
4
+ // the look is editor.css (`.gemboss-ec-tabs`, `.gemboss-ei-*`), unchanged by the move. Stateless except
5
+ // InspectorGroup's own open/closed when the caller does not hold it. Controls (Seg, Color, Range, EdgeBox, Image) are
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
8
+ import { useState } from "react";
9
+ import type React from "react";
10
+
11
+ // ── PanelTabs ───────────────────────────────────────────────────────────────────────────────────────────────────────
12
+
13
+ export interface PanelTab<T extends string = string> {
14
+ id: T;
15
+ label: React.ReactNode;
16
+ /** Drawn before the label (the column tabs' 14px line icons). */
17
+ icon?: React.ReactNode;
18
+ /** The id of the panel this tab shows: `aria-controls` (and editor-plain.js shows it, hiding its siblings'). */
19
+ controls?: string;
20
+ }
21
+
22
+ export interface PanelTabsProps<T extends string = string> {
23
+ tabs: ReadonlyArray<PanelTab<T>>;
24
+ value: T;
25
+ onChange: (id: T) => void;
26
+ /**
27
+ * `column`: the left column's row (`.gemboss-ec-tabs`, Configure · Chat; a selected tab has a 2px primary underline).
28
+ * `inspector`: the settings panel's segmented row (`.gemboss-ei-tabs`, Content · Design · Advanced · Elements).
29
+ */
30
+ variant?: "column" | "inspector";
31
+ /** The tablist's accessible name. */
32
+ label?: string;
33
+ /**
34
+ * Roving focus (default on): only the selected tab is in the Tab order, ←/→ move the selection and the focus with
35
+ * it, wrapping at the ends. Off, every tab is a plain stop and arrows do nothing (the admin's Configure · Chat row,
36
+ * kept exactly as it was in step 3).
37
+ */
38
+ roving?: boolean;
39
+ }
40
+
41
+ /** A row of `role="tab"` buttons. The caller owns the selection and draws the panel under it. */
42
+ export function PanelTabs<T extends string = string>({ tabs, value, onChange, variant = "column", label, roving = true }: PanelTabsProps<T>) {
43
+ const inspector = variant === "inspector";
44
+ const onKeyDown = roving ? (e: React.KeyboardEvent<HTMLDivElement>) => {
45
+ if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
46
+ e.preventDefault();
47
+ const i = tabs.findIndex((t) => t.id === value);
48
+ const ni = e.key === "ArrowRight" ? (i + 1) % tabs.length : (i - 1 + tabs.length) % tabs.length;
49
+ onChange(tabs[ni].id);
50
+ // Focus follows the selection (roving tabindex).
51
+ (e.currentTarget.children[ni] as HTMLButtonElement | undefined)?.focus();
52
+ } : undefined;
53
+ return (
54
+ <div className={inspector ? "gemboss-ei-tabs" : "gemboss-ec-tabs"} role="tablist" aria-label={label} onKeyDown={onKeyDown}>
55
+ {tabs.map((t) => {
56
+ const on = t.id === value;
57
+ return (
58
+ // data-value: what a page without React reads back (editor-plain.js), since the label can be anything.
59
+ <button key={t.id} type="button" role="tab" data-value={t.id}
60
+ className={inspector ? `gemboss-ei-tab${on ? " on" : ""}` : "gemboss-ec-tab"}
61
+ aria-selected={on} aria-controls={t.controls} tabIndex={roving ? (on ? 0 : -1) : undefined} onClick={() => onChange(t.id)}>
62
+ {t.icon}{t.label}
63
+ </button>
64
+ );
65
+ })}
66
+ </div>
67
+ );
68
+ }
69
+
70
+ // ── PanelHeader ─────────────────────────────────────────────────────────────────────────────────────────────────────
71
+
72
+ export interface PanelHeaderProps {
73
+ /** What the panel edits (a section's name). */
74
+ title: React.ReactNode;
75
+ /** The line under it, monospaced and muted (the section's type). Drawn whenever it is not undefined. */
76
+ subtitle?: React.ReactNode;
77
+ /** Icon buttons after the title: `PanelAction`s. The first sits at the right edge's start (margin-left: auto). */
78
+ actions?: React.ReactNode;
79
+ /** Draws ✕ last. Omit it for a docked panel (nothing to close). */
80
+ onClose?: () => void;
81
+ /** The close button's accessible name. Default "Close". */
82
+ closeLabel?: string;
83
+ /** A floating panel's grab handle: adds `.gemboss-ei-drag` (grab cursor) and takes the mouse-down that starts a drag. */
84
+ onDragStart?: React.MouseEventHandler<HTMLElement>;
85
+ }
86
+
87
+ /** `header.gemboss-ei-head`: title (+ subtitle) · actions · ✕. */
88
+ export function PanelHeader({ title, subtitle, actions, onClose, closeLabel = "Close", onDragStart }: PanelHeaderProps) {
89
+ return (
90
+ <header className={onDragStart ? "gemboss-ei-head gemboss-ei-drag" : "gemboss-ei-head"} onMouseDown={onDragStart}>
91
+ <span className="gemboss-ei-title">
92
+ {title}
93
+ {subtitle !== undefined ? <small>{subtitle}</small> : null}
94
+ </span>
95
+ {actions}
96
+ {onClose ? <button type="button" className="gemboss-ei-x" title="Close" aria-label={closeLabel} onClick={onClose}>✕</button> : null}
97
+ </header>
98
+ );
99
+ }
100
+
101
+ /** A 26px icon button for `PanelHeader`'s actions (`.gemboss-ei-act`). `label` is both its name and its tooltip unless `title` differs. */
102
+ export function PanelAction({ label, title, disabled, onClick, children }: {
103
+ label: string; title?: string; disabled?: boolean; onClick?: React.MouseEventHandler<HTMLButtonElement>; children: React.ReactNode;
104
+ }) {
105
+ return (
106
+ <button type="button" className="gemboss-ei-act" title={title ?? label} aria-label={label} disabled={disabled} onClick={onClick}>
107
+ {children}
108
+ </button>
109
+ );
110
+ }
111
+
112
+ // ── InspectorGroup ──────────────────────────────────────────────────────────────────────────────────────────────────
113
+
114
+ export interface InspectorGroupProps {
115
+ /** The header: 11px / 700, uppercase, muted (`.gemboss-ei-gh`). */
116
+ title?: React.ReactNode;
117
+ /** A short aside after the title, in the header's own line ("· all screens"). Collapsible groups only. */
118
+ note?: React.ReactNode;
119
+ /** Controlled: the caller holds whether the group is open (and keeps it across tabs, say). */
120
+ open?: boolean;
121
+ /** Uncontrolled: where it starts. Default open. */
122
+ defaultOpen?: boolean;
123
+ onOpenChange?: (open: boolean) => void;
124
+ /**
125
+ * Default true: the inspector's group, `.gemboss-ei-grp` with a header BUTTON (▾ / ▸, `aria-expanded`); closed, CSS
126
+ * hides everything after the header (`.gemboss-ei-gh[data-open="closed"] ~ *`), so the body stays mounted.
127
+ * `false`: the schema form's static group, `section.gemboss-ei-group` with an `h4.gemboss-ei-grouphead` (no toggle).
128
+ */
129
+ collapsible?: boolean;
130
+ /** A muted line after the body (`p.gemboss-ei-hint`). */
131
+ hint?: React.ReactNode;
132
+ children?: React.ReactNode;
133
+ }
134
+
135
+ export function InspectorGroup({ title, note, open, defaultOpen = true, onOpenChange, collapsible = true, hint, children }: InspectorGroupProps) {
136
+ const [own, setOwn] = useState(defaultOpen);
137
+ const hintNode = hint ? <p className="gemboss-ei-hint">{hint}</p> : null;
138
+ if (!collapsible) {
139
+ return (
140
+ <section className="gemboss-ei-group">
141
+ {title ? <h4 className="gemboss-ei-grouphead">{title}</h4> : null}
142
+ {children}
143
+ {hintNode}
144
+ </section>
145
+ );
146
+ }
147
+ const isOpen = open ?? own;
148
+ return (
149
+ <div className="gemboss-ei-grp">
150
+ <button type="button" className="gemboss-ei-gh" data-open={isOpen ? "open" : "closed"} aria-expanded={isOpen}
151
+ onClick={() => { if (open === undefined) setOwn(!isOpen); onOpenChange?.(!isOpen); }}>
152
+ <span className="gemboss-ei-chev" aria-hidden>{isOpen ? "▾" : "▸"}</span>{title}
153
+ {note ? <small className="gemboss-ei-gh-note">· {note}</small> : null}
154
+ </button>
155
+ {children}
156
+ {hintNode}
157
+ </div>
158
+ );
159
+ }
160
+
161
+ // ── Field ───────────────────────────────────────────────────────────────────────────────────────────────────────────
162
+
163
+ export interface FieldProps {
164
+ /** 13px / 500 above the control. `undefined` / `null` draws no label (a control named by its own aria-label). */
165
+ label?: React.ReactNode;
166
+ /** The control's id, so a click on the label focuses it. */
167
+ htmlFor?: string;
168
+ /** A value shown at the label's right edge, bold and tabular (a range's current value: `.gemboss-ei-rangeval`). */
169
+ labelValue?: React.ReactNode;
170
+ /** A muted line under the control (`p.gemboss-ei-hint`). Put it in `children` instead to place it elsewhere. */
171
+ hint?: React.ReactNode;
172
+ /** Added to `.gemboss-ei-fld` (the admin's `togrow`). */
173
+ className?: string;
174
+ style?: React.CSSProperties;
175
+ children?: React.ReactNode;
176
+ }
177
+
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) {
180
+ 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}
185
+ {children}
186
+ {hint ? <p className="gemboss-ei-hint">{hint}</p> : null}
187
+ </div>
188
+ );
189
+ }
@@ -0,0 +1,45 @@
1
+ import "../editor.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
2
+ import type { EditorPane } from "./frame";
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
+ // editor): their own module, so the page and section editors do not carry them.
6
+
7
+ /** The panel icon that shows or hides the left column (EditorTopbar's `chatToggle`): the left third is filled while the
8
+ * column is open, hollow and pressed when it is collapsed. */
9
+ export function EditorChatToggle({ collapsed, onToggle, name = "chat panel" }: { collapsed?: boolean; onToggle: () => void; /** What the column is (0.24.0): "Hide <name>". */ name?: string }) {
10
+ return (
11
+ <button
12
+ className={`gemboss-editor-chat-toggle${collapsed ? " collapsed" : ""}`}
13
+ onClick={onToggle}
14
+ aria-pressed={collapsed}
15
+ title={collapsed ? `Show ${name}` : name === "chat panel" ? "Hide chat — full-width preview" : `Hide ${name}`}
16
+ aria-label={collapsed ? `Show ${name}` : `Hide ${name}`}
17
+ >
18
+ {/* A real SVG panel icon (geometrically centred): the ▭/◫ box glyphs' ink sits high in the em box. */}
19
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
20
+ <rect x="1.75" y="2.75" width="12.5" height="10.5" rx="2" />
21
+ <line x1="6.25" y1="2.75" x2="6.25" y2="13.25" />
22
+ {!collapsed && <rect x="1.75" y="2.75" width="4.5" height="10.5" fill="currentColor" stroke="none" />}
23
+ </svg>
24
+ </button>
25
+ );
26
+ }
27
+
28
+ /**
29
+ * The phone's Chat | Preview switch (`.gemboss-editor-mobile-tabs`; CSS shows it ≤760px only, wider screens show both
30
+ * panes side by side). Put it FIRST in EditorFrame's `children` and give the frame the same `mobilePane`.
31
+ */
32
+ export function EditorPaneTabs({ value, onChange, labels }: {
33
+ value: EditorPane; onChange: (pane: EditorPane) => void; labels?: { chat?: string; preview?: string };
34
+ }) {
35
+ return (
36
+ <div className="gemboss-editor-mobile-tabs" role="tablist" aria-label="Editor panes">
37
+ <button type="button" role="tab" aria-selected={value === "chat"}
38
+ className={`gemboss-editor-mobile-tab${value === "chat" ? " active" : ""}`}
39
+ onClick={() => onChange("chat")}>{labels?.chat ?? "Chat"}</button>
40
+ <button type="button" role="tab" aria-selected={value === "preview"}
41
+ className={`gemboss-editor-mobile-tab${value === "preview" ? " active" : ""}`}
42
+ onClick={() => onChange("preview")}>{labels?.preview ?? "Preview"}</button>
43
+ </div>
44
+ );
45
+ }
@@ -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,45 @@
1
+ // What a colour swatch can and cannot show (moved from the admin's components/ui/color-field-value.ts in 0.17.0; the
2
+ // admin re-exports it from here, so the colour control and the admin's other colour boxes keep ONE answer).
3
+ //
4
+ // `<input type="color">` holds exactly one shape: a 7-character hex. Anything else it SANITISES to
5
+ // `#000000` — silently, with no error and no visual difference from a value someone actually chose.
6
+ //
7
+ // gemsite's `cssValue` decides whether a background paints, and it accepts five shapes. Measured:
8
+ //
9
+ // #ff0000 ✔ renders ✔ the swatch can show it
10
+ // #f00 ✔ renders ✘ shown as #000000 — even a 3-digit hex
11
+ // rgba(0,0,0,.5) ✔ renders ✘ shown as #000000
12
+ // var(--gem-color-accent) ✔ renders ✘ shown as #000000
13
+ // linear-gradient(90deg,#f00,#00f) ✔ renders ✘ shown as #000000
14
+ //
15
+ // So four of five were BOTH invisible and destructible: the panel showed black for a value that is
16
+ // not black, and touching the swatch overwrote it with the black it was pretending to show. A seller
17
+ // could lose a gradient by opening the panel to look at it.
18
+ //
19
+ // None of this is hypothetical. `gem_background` renders a gradient today, measured in chromium, and
20
+ // every one of these shapes is something the CLONE pipeline can read off a real storefront.
21
+ //
22
+ // Plain TypeScript, no JSX: the admin's node tests import it through the admin's re-export.
23
+
24
+ /** The only shape `<input type="color">` can hold without rewriting it. */
25
+ const SWATCHABLE = /^#[0-9a-fA-F]{6}$/;
26
+
27
+ export type SwatchState = {
28
+ /** What to put in the swatch. Never a colour the author did not choose. */
29
+ swatch: string;
30
+ /** False when the swatch would misrepresent the value — the text box is then the only truth. */
31
+ usable: boolean;
32
+ };
33
+
34
+ /**
35
+ * `whenEmpty` is the swatch shown for a BLANK value, and it is a parameter rather than a constant on
36
+ * purpose. Three surfaces call this now and they disagree: the element inspector and the schema form
37
+ * open an empty swatch on black, the page editor's "Background color" opens on white. That difference
38
+ * is harmless — an empty value misrepresents nothing, which is why `usable` stays true — and folding
39
+ * it into one answer would repaint a live control while fixing an unrelated bug. The lie is what gets
40
+ * consolidated here; the taste does not.
41
+ */
42
+ export function swatchState(value: string, whenEmpty = '#000000'): SwatchState {
43
+ if (!value) return { swatch: whenEmpty, usable: true };
44
+ return SWATCHABLE.test(value) ? { swatch: value, usable: true } : { swatch: '#000000', usable: false };
45
+ }
@@ -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);
@@ -0,0 +1 @@
1
+ (function(S){"use strict";var b=S.gembossUI;if(!b){S.console&&S.console.error("@gemboss/ui editor-plain.js: load kit-plain.js first (it defines gembossUI)");return}if(b.initEditorFrame)return;var k=5,z=360,p=typeof WeakSet=="function"?new WeakSet:null,l=function(t,e,r){t.dispatchEvent(new CustomEvent(e,{bubbles:!0,detail:r}))},w=function(t,e){var r=Number(t);return isFinite(r)&&r>0?r:e};function c(t){return t.querySelector(".gemboss-editor-body")}function g(t){var e=c(t);return e&&e.querySelector(":scope > .gemboss-editor-resizer")}function v(t){var e=g(t);return{min:w(e&&e.getAttribute("aria-valuemin"),300),cap:w(e&&(e.getAttribute("data-gb-max")||e.getAttribute("aria-valuemax")),680)}}function x(t){var e=v(t),r=c(t),i=r?r.getBoundingClientRect().width:0;return i>0?Math.max(e.min,Math.min(e.cap,i-z-k)):e.cap}var h=typeof WeakMap=="function"?new WeakMap:null;function q(t){var e=h?h.get(t):t._gbWant;return e??y(t)}function C(t,e){h?h.set(t,e):t._gbWant=e}function d(t,e,r,i){var n=c(t),a=g(t);if(n){var s=i??x(t),f=v(t),o=Math.round(Math.min(s,Math.max(f.min,e)));if(n.style.setProperty("--gemboss-chat-w",o+"px"),a&&(a.setAttribute("aria-valuenow",String(o)),a.setAttribute("aria-valuemax",String(s))),r){C(t,o);var E=n.getAttribute("data-gb-storage");if(E)try{localStorage.setItem(E,String(o))}catch{}l(t,"gemboss:editor-split",{width:o})}return o}}function y(t){var e=g(t);return w(e&&e.getAttribute("aria-valuenow"),384)}function F(t){var e=c(t),r=g(t);if(!(!e||!r)){r.hasAttribute("data-gb-max")||r.setAttribute("data-gb-max",r.getAttribute("aria-valuemax")||"680");var i=e.getAttribute("data-gb-storage"),n=y(t);if(i)try{var a=Number(localStorage.getItem(i));a>=v(t).min&&a<=v(t).cap&&(n=a)}catch{}C(t,n),e.classList.contains("is-chat-collapsed")||d(t,n,!1)}}function O(t,e){var r=c(t);if(r&&!(e.button!==0||e.isPrimary===!1)){e.preventDefault();var i=r.getBoundingClientRect().left,n=x(t),a=0,s=y(t);document.body.classList.add("ui-resizing");var f=function(E){s=E.clientX-i-k,a||(a=requestAnimationFrame(function(){a=0,d(t,s,!1,n)}))},o=function(){a&&cancelAnimationFrame(a),document.body.classList.remove("ui-resizing"),removeEventListener("pointermove",f),removeEventListener("pointerup",o),removeEventListener("pointercancel",o),removeEventListener("blur",o),d(t,s,!0,n)};addEventListener("pointermove",f),addEventListener("pointerup",o),addEventListener("pointercancel",o),addEventListener("blur",o)}}function D(t,e){if(!(e.altKey||e.metaKey||e.ctrlKey)){var r=e.shiftKey?64:16,i=y(t),n=v(t);if(e.key==="ArrowLeft")i-=r;else if(e.key==="ArrowRight")i+=r;else if(e.key==="Home")i=n.min;else if(e.key==="End")i=x(t);else return;e.preventDefault(),d(t,i,!0)}}function T(t,e){var r=c(t);if(r){var i=!r.classList.contains("is-chat-collapsed");r.classList.toggle("is-chat-collapsed",i),i?r.style.setProperty("--gemboss-chat-w","0px"):d(t,q(t),!1),e.classList.toggle("collapsed",i),e.setAttribute("aria-pressed",String(i)),e.setAttribute("aria-label",i?"Show chat panel":"Hide chat panel"),e.title=i?"Show chat panel":"Hide chat \u2014 full-width preview";var n=e.querySelector("rect[fill='currentColor']");n&&(n.style.display=i?"none":""),l(t,"gemboss:editor-chat",{collapsed:i})}}function U(t,e){var r=e.closest(".gemboss-pv-devices");r&&(r.querySelectorAll(".gemboss-pv-device").forEach(function(i){var n=i===e;i.classList.toggle("active",n),i.setAttribute("aria-pressed",String(n))}),l(t,"gemboss:editor-device",{device:e.getAttribute("data-device")||(e.title||"").toLowerCase()}))}function K(t,e){var r=e.getAttribute("data-pane")||(Array.prototype.indexOf.call(e.parentNode.children,e)===0?"chat":"preview"),i=c(t);i&&(i.setAttribute("data-mobile-pane",r),e.parentNode.querySelectorAll(".gemboss-editor-mobile-tab").forEach(function(n){var a=n===e;n.classList.toggle("active",a),n.setAttribute("aria-selected",String(a))}),l(t,"gemboss:editor-pane",{pane:r}))}var u=null;function M(t){var e=t.trigger.getBoundingClientRect();t.menu.style.top=e.bottom+6+"px",t.menu.style.right=Math.max(8,innerWidth-e.right)+"px";var r=t.menu.getBoundingClientRect(),i=e.right-r.right;r.left+i<8&&(i=8-r.left),Math.abs(i)>=.5&&(t.menu.style.right=parseFloat(t.menu.style.right)-i+"px")}function m(t){var e=u;e&&(u=null,e.menu.hidden=!0,e.trigger.setAttribute("aria-expanded","false"),e.trigger.classList.remove("active"),t&&e.trigger.focus(),l(e.root,"gemboss:editor-menu",{open:!1,menu:e.menu}))}function H(t,e){var r=e.nextElementSibling;if(!r||!r.classList.contains("gemboss-editor-menu"))return!1;var i=u&&u.trigger===e;return m(!1),i||(u={root:t,trigger:e,menu:r},r.hidden=!1,e.setAttribute("aria-expanded","true"),e.classList.add("active"),M(u),l(t,"gemboss:editor-menu",{open:!0,menu:r})),!0}var P=!1;function j(){P||(P=!0,document.addEventListener("pointerdown",function(t){u&&!u.menu.contains(t.target)&&!u.trigger.contains(t.target)&&m(!1)}),addEventListener("keydown",function(t){t.key!=="Escape"||!u||t.isComposing||t.keyCode===229||(t.preventDefault(),t.stopImmediatePropagation(),m(!0))},!0),addEventListener("resize",function(){u&&M(u)}),addEventListener("blur",function(){m(!1)}))}function G(t){var e=t&&t.classList&&t.classList.contains("gemboss-editor")?[t]:Array.prototype.slice.call((t||document).querySelectorAll(".gemboss-editor"));return j(),e.forEach(function(r){if(!(p?p.has(r):r._gbEditor)){p?p.add(r):r._gbEditor=!0,F(r);var i=c(r);i&&typeof ResizeObserver=="function"&&new ResizeObserver(function(){i.classList.contains("is-chat-collapsed")||d(r,q(r),!1)}).observe(i),r.addEventListener("pointerdown",function(n){var a=n.target.closest(".gemboss-editor-resizer");a&&a===g(r)&&O(r,n)}),r.addEventListener("keydown",function(n){n.target.classList&&n.target.classList.contains("gemboss-editor-resizer")&&n.target===g(r)&&D(r,n)}),r.addEventListener("click",function(n){var a=n.target,s;if(s=a.closest(".gemboss-editor-chat-toggle"))return T(r,s);if(s=a.closest(".gemboss-pv-devices .gemboss-pv-device"))return U(r,s);if(s=a.closest(".gemboss-editor-mobile-tab"))return K(r,s);if((s=a.closest('[aria-haspopup="menu"]'))&&H(r,s)){n.stopPropagation();return}u&&(s=a.closest(".gemboss-editor-menu-item"))&&u.menu.contains(s)&&m(!0)})}}),e.length}b.initEditorFrame=G;var V=/^#[0-9a-fA-F]{6}$/,I=function(t){var e=t.getAttribute("data-value");return e??(t.textContent||"").trim()},N=function(t){return Array.prototype.filter.call(t.children,function(e){return e.getAttribute("role")==="tab"})};function W(t,e){var r=t.parentNode,i=N(r),n=i.some(function(a){return a.hasAttribute("tabindex")});i.forEach(function(a){var s=a===t;a.setAttribute("aria-selected",String(s)),a.classList.contains("gemboss-ei-tab")&&a.classList.toggle("on",s),n&&a.setAttribute("tabindex",s?"0":"-1");var f=a.getAttribute("aria-controls"),o=f&&document.getElementById(f);o&&(o.hidden=!s)}),e&&t.focus(),l(t,"gemboss:editor-tab",{tab:I(t),index:i.indexOf(t)})}function $(t,e){if(!(e.key!=="ArrowRight"&&e.key!=="ArrowLeft")){var r=N(t);if(r.some(function(a){return a.hasAttribute("tabindex")})){var i=r.findIndex(function(a){return a.getAttribute("aria-selected")==="true"}),n=e.key==="ArrowRight"?(i+1)%r.length:(i-1+r.length)%r.length;e.preventDefault(),W(r[n],!0)}}}function X(t){var e=t.getAttribute("data-open")!=="open";t.setAttribute("data-open",e?"open":"closed"),t.setAttribute("aria-expanded",String(e));var r=t.querySelector(".gemboss-ei-chev");r&&(r.textContent=e?"\u25BE":"\u25B8"),l(t,"gemboss:editor-group",{open:e})}function J(t){var e=t.closest(".gemboss-ei-seg");Array.prototype.forEach.call(e.querySelectorAll("button"),function(r){var i=r===t;r.classList.toggle("active",i),r.classList.toggle("on",i),r.hasAttribute("aria-pressed")&&r.setAttribute("aria-pressed",String(i))}),l(e,"gemboss:editor-seg",{value:I(t)})}function _(t){var e=t.querySelector(".gemboss-ei-swatch"),r=t.querySelector("input:not([type=color])");if(!(!e||!r)){var i=r.value,n=!i||V.test(i);e.value=i&&n?i:"#000000",e.disabled=r.disabled||!n,n?e.removeAttribute("title"):e.title=i+" \u2014 not a plain 6-digit hex; edit it in the box below"}}function Q(t){for(var e=t.labels||[],r=null,i=0;i<e.length&&!r;i++)r=e[i].querySelector(".gemboss-ei-rangeval");r&&(r.textContent=t.value+(r.getAttribute("data-suffix")||""))}function Y(t){var e={},r=["t","r","b","l"];return Array.prototype.forEach.call(t.querySelectorAll("input"),function(i,n){e[r[n]]=i.value.trim()}),e}function B(t){return'url("'+t.replace(/["\\]/g,"\\$&").replace(/[\u0000-\u001f\u007f]/g,function(e){return"\\"+e.charCodeAt(0).toString(16)+" "})+'")'}b.cssUrl=B;function Z(t,e){var r=t.querySelector(".gemboss-ei-img-thumb");r&&(r.style.backgroundImage=e?B(e):"",r.textContent=e?"":"\u2014")}function R(t,e){var r=t.getAttribute("data-gb-value")!=null?t.getAttribute("data-gb-value"):e.defaultValue;e.value!==r&&(t.setAttribute("data-gb-value",e.value),Z(t,e.value),l(t,"gemboss:editor-image",{value:e.value}))}var A=typeof WeakSet=="function"?new WeakSet:null,L=function(t){return t.__gbPanel?!1:(t.__gbPanel=!0,!0)};function ee(t){var e=t||document;return(A?A.has(e):e._gbPanel)?0:(A?A.add(e):e._gbPanel=!0,Array.prototype.forEach.call(e.querySelectorAll(".gemboss-ei-color"),_),e.addEventListener("click",function(r){var i=r.target,n;if(!(!i.closest||!L(r))){if(n=i.closest('.gemboss-ec-tabs > [role="tab"], .gemboss-ei-tabs > [role="tab"]'))return W(n,!1);if(n=i.closest(".gemboss-ei-gh"))return X(n);if((n=i.closest(".gemboss-ei-seg button"))&&!n.disabled)return J(n);if((n=i.closest("[data-gb-image-clear]"))&&!n.disabled){var a=n.closest(".gemboss-ei-img"),s=a&&a.querySelector(".gemboss-ei-img-ctl input");s&&(s.value="",R(a,s))}}}),e.addEventListener("keydown",function(r){var i=r.target;if(!(!i.closest||!L(r))){var n=i.closest('.gemboss-ec-tabs[role="tablist"], .gemboss-ei-tabs[role="tablist"]');if(n&&i.getAttribute("role")==="tab")return $(n,r);r.key==="Enter"&&i.tagName==="INPUT"&&i.type!=="color"&&i.type!=="range"&&i.closest(".gemboss-ei-color, .gemboss-ei-edgebox, .gemboss-ei-img")&&(r.preventDefault(),i.blur())}}),e.addEventListener("input",function(r){var i=r.target;if(!(!i.closest||!L(r))){if(i.classList.contains("gemboss-ei-range")){Q(i),l(i,"gemboss:editor-range",{value:Number(i.value)});return}if(i.classList.contains("gemboss-ei-swatch")){var n=i.closest(".gemboss-ei-color"),a=n&&n.querySelector("input:not([type=color])");a&&(a.value=i.value),l(n||i,"gemboss:editor-color",{value:i.value})}}}),e.addEventListener("focusout",function(r){var i=r.target;if(!(!i.closest||i.tagName!=="INPUT"||!L(r))){var n;if(i.type!=="color"&&(n=i.closest(".gemboss-ei-color"))){_(n),l(n,"gemboss:editor-color",{value:i.value});return}if(n=i.closest(".gemboss-ei-edgebox")){l(n,"gemboss:editor-edges",Y(n));return}(n=i.closest(".gemboss-ei-img"))&&R(n,i)}}),1)}b.initEditorPanel=ee})(typeof globalThis<"u"?globalThis:this);