@gemboss/ui 0.12.0 → 0.21.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 +395 -1
- package/README.md +321 -6
- package/catalog/editor.html +611 -0
- package/catalog/index.html +3114 -13
- package/gemboss-ui.css +3 -4682
- package/package.json +16 -4
- package/src/app-shell.css +1 -256
- package/src/assistant.css +1 -558
- package/src/auth.css +1 -606
- package/src/board-move.ts +194 -0
- package/src/chat-composer.css +1 -110
- package/src/chat.css +1 -432
- package/src/collections.css +1 -0
- package/src/collections.tsx +543 -0
- package/src/editor/controls.tsx +206 -0
- package/src/editor/css-url.ts +11 -0
- package/src/editor/frame.tsx +242 -0
- package/src/editor/index.ts +43 -0
- package/src/editor/panel.tsx +188 -0
- package/src/editor/panes.tsx +44 -0
- package/src/editor/swatch.ts +45 -0
- package/src/editor-plain.js +1 -0
- package/src/editor.css +1 -1099
- package/src/gemboss-field.css +1 -112
- package/src/gemboss-tokens.css +1 -164
- package/src/icons.tsx +2 -0
- package/src/index-table.tsx +179 -0
- package/src/index.ts +19 -0
- package/src/kit-plain.css +1 -483
- package/src/kit-plain.js +1 -440
- package/src/menu-layer.ts +87 -0
- package/src/menu.tsx +28 -2
- package/src/portal.css +1 -139
- package/src/reactions-data.ts +59 -0
- package/src/reactions.css +1 -0
- package/src/reactions.tsx +139 -0
- package/src/resource-list-select.tsx +72 -0
- package/src/segmented-click.ts +20 -0
- package/src/segmented.tsx +98 -0
- package/src/selection.tsx +78 -0
- package/src/shell.css +1 -1079
- package/src/theme-dark.css +1 -66
- package/src/ui.css +1 -610
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
// The settings panel's CONTROLS (0.17.0, step 3 of 5 of the editor kit): segmented, colour, slider, the four-edge px box
|
|
3
|
+
// and the image slot. Each takes a value and gives one back through `onChange`; nothing here knows what is edited, what
|
|
4
|
+
// schema a value came from or where it is saved. Wrap each in `Field` (panel.tsx) for its label and hint.
|
|
5
|
+
//
|
|
6
|
+
// Moved from the admin (apps/admin: FieldRenderer.tsx, element-inspector-controls.tsx) with the markup, the class names
|
|
7
|
+
// and the commit rules they had there, so the admin's editors draw and behave exactly as before:
|
|
8
|
+
// · text-like boxes keep a DRAFT and commit on blur, Enter blurs (a live preview is not hammered per keystroke);
|
|
9
|
+
// · the colour swatch commits on every change, the colour's text box on blur / Enter;
|
|
10
|
+
// · the slider commits on every change (its number);
|
|
11
|
+
// · the edge box hands each keystroke to the caller (`onChange(side, raw)`) and says when to commit (`onCommit`, on
|
|
12
|
+
// blur): the caller decides what an empty edge means, and the admin has three answers to that on purpose.
|
|
13
|
+
import { useEffect, useState } from "react";
|
|
14
|
+
import type React from "react";
|
|
15
|
+
import { cssUrl } from "./css-url";
|
|
16
|
+
import { swatchState } from "./swatch";
|
|
17
|
+
import { Segmented } from "../segmented";
|
|
18
|
+
|
|
19
|
+
const blurOnEnter = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
20
|
+
if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); }
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
// ── Seg ─────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
export interface SegOption<T extends string = string> {
|
|
26
|
+
value: T;
|
|
27
|
+
/** Default: the value itself. */
|
|
28
|
+
label?: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SegProps<T extends string = string> {
|
|
32
|
+
options: ReadonlyArray<SegOption<T>>;
|
|
33
|
+
/** The pressed option. A value that matches none presses none. */
|
|
34
|
+
value?: string;
|
|
35
|
+
onChange: (value: T) => void;
|
|
36
|
+
disabled?: boolean;
|
|
37
|
+
/** The group's accessible name (`role="group"`): the field's label, said again for a screen reader. */
|
|
38
|
+
label?: string;
|
|
39
|
+
/** On the group (the admin narrows two-option rows with a max-width). */
|
|
40
|
+
style?: React.CSSProperties;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The settings panel's switch: the kit's `Segmented` stretched to its row (`.ui-seg--fill`), the same look as every
|
|
45
|
+
* other switch in the editors (0.20.0; it was a grey track with a raised white tab of its own). `gemboss-ei-seg` stays on
|
|
46
|
+
* the group as the hook editor-plain.js finds, and each button carries its `data-value`.
|
|
47
|
+
*/
|
|
48
|
+
export function Seg<T extends string = string>({ options, value, onChange, disabled, label, style }: SegProps<T>) {
|
|
49
|
+
return (
|
|
50
|
+
<Segmented<T> fill className="gemboss-ei-seg" style={style} ariaLabel={label ?? ""} value={(value ?? "") as T | ""} onChange={onChange} disabled={disabled}
|
|
51
|
+
options={options.map((o) => ({ value: o.value, label: o.label ?? o.value }))} />
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── ColorControl ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
export interface ColorControlProps {
|
|
58
|
+
/** The text box's id (the Field's `htmlFor`). */
|
|
59
|
+
id?: string;
|
|
60
|
+
value: string;
|
|
61
|
+
/** A committed value: on every swatch change, and on the text box's blur / Enter. */
|
|
62
|
+
onChange: (value: string) => void;
|
|
63
|
+
disabled?: boolean;
|
|
64
|
+
/** The field's name; the swatch is announced as "<label> swatch". */
|
|
65
|
+
label: string;
|
|
66
|
+
placeholder?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `.gemboss-ei-color`: a native swatch beside a text box. The swatch is DISABLED (with a title saying why) for anything
|
|
71
|
+
* but a 6-digit hex (`swatchState`): `<input type="color">` would show black for `#f00`, `rgba()`, `var()` or a gradient,
|
|
72
|
+
* and overwrite it with black on the first touch.
|
|
73
|
+
*/
|
|
74
|
+
export function ColorControl({ id, value, onChange, disabled, label, placeholder }: ColorControlProps) {
|
|
75
|
+
const [draft, setDraft] = useState(value);
|
|
76
|
+
useEffect(() => { setDraft(value); }, [value]);
|
|
77
|
+
const { swatch, usable } = swatchState(draft);
|
|
78
|
+
return (
|
|
79
|
+
<div className="gemboss-ei-color">
|
|
80
|
+
<input type="color" className="gemboss-ei-swatch" aria-label={`${label} swatch`} value={swatch}
|
|
81
|
+
disabled={disabled || !usable}
|
|
82
|
+
title={usable ? undefined : `${draft} — not a plain 6-digit hex; edit it in the box below`}
|
|
83
|
+
onChange={(e) => { setDraft(e.target.value); onChange(e.target.value); }} />
|
|
84
|
+
<input id={id} className="gemboss-field gemboss-ei-inp" placeholder={placeholder ?? "#000000 / var(--…)"} value={draft} disabled={disabled}
|
|
85
|
+
onChange={(e) => setDraft(e.target.value)} onBlur={() => onChange(draft)} onKeyDown={blurOnEnter} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── RangeControl ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
export interface RangeControlProps {
|
|
93
|
+
id?: string;
|
|
94
|
+
min: number;
|
|
95
|
+
max: number;
|
|
96
|
+
/** Default 1. */
|
|
97
|
+
step?: number;
|
|
98
|
+
value: number;
|
|
99
|
+
/** Every change (drag, ←/→, Home/End, PageUp/PageDown), as a number. */
|
|
100
|
+
onChange: (value: number) => void;
|
|
101
|
+
disabled?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `input.gemboss-ei-range` (the native slider in the primary colour). Show its value with `Field`'s `labelValue`. */
|
|
105
|
+
export function RangeControl({ id, min, max, step, value, onChange, disabled }: RangeControlProps) {
|
|
106
|
+
return (
|
|
107
|
+
<input id={id} type="range" className="gemboss-ei-range" min={min} max={max} step={step ?? 1}
|
|
108
|
+
value={value} disabled={disabled} onChange={(e) => onChange(Number(e.target.value))} />
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── EdgeBox ─────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
export type EdgeSide = "t" | "r" | "b" | "l";
|
|
115
|
+
const SIDES: readonly EdgeSide[] = ["t", "r", "b", "l"];
|
|
116
|
+
const SIDE_NAME: Record<EdgeSide, string> = { t: "top", r: "right", b: "bottom", l: "left" };
|
|
117
|
+
|
|
118
|
+
export interface EdgeBoxProps {
|
|
119
|
+
/** The field's name; each box is announced as "<label> top / right / bottom / left". */
|
|
120
|
+
label: string;
|
|
121
|
+
/** What each box shows. A number, the raw text being typed, or empty. */
|
|
122
|
+
values: Partial<Record<EdgeSide, string | number>>;
|
|
123
|
+
/** Per side; default "0". The admin shows the inherited desktop value here while editing the phone override. */
|
|
124
|
+
placeholders?: Partial<Record<EdgeSide, string>>;
|
|
125
|
+
/** Every keystroke in one box. */
|
|
126
|
+
onChange: (side: EdgeSide, raw: string) => void;
|
|
127
|
+
/** A box lost focus (Enter blurs): commit what the boxes hold. */
|
|
128
|
+
onCommit: () => void;
|
|
129
|
+
disabled?: boolean;
|
|
130
|
+
/** `gemboss-field--narrow` on each box (the field family's tight lane). Default true. */
|
|
131
|
+
narrow?: boolean;
|
|
132
|
+
/** Ids for the boxes, per side. */
|
|
133
|
+
ids?: Partial<Record<EdgeSide, string>>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `.gemboss-ei-edgebox`: four px boxes, T · R · B · L, mono and centred. */
|
|
137
|
+
export function EdgeBox({ label, values, placeholders, onChange, onCommit, disabled, narrow = true, ids }: EdgeBoxProps) {
|
|
138
|
+
const cls = narrow ? "gemboss-field gemboss-field--narrow gemboss-ei-inp" : "gemboss-field gemboss-ei-inp";
|
|
139
|
+
return (
|
|
140
|
+
<div className="gemboss-ei-edgebox">
|
|
141
|
+
{SIDES.map((s) => (
|
|
142
|
+
<div className="gemboss-ei-edge" key={s}>
|
|
143
|
+
<input className={cls} inputMode="numeric" placeholder={placeholders?.[s] ?? "0"}
|
|
144
|
+
aria-label={`${label} ${SIDE_NAME[s]}`} id={ids?.[s]} value={values[s] ?? ""} disabled={disabled}
|
|
145
|
+
onChange={(e) => onChange(s, e.target.value)} onBlur={onCommit} onKeyDown={blurOnEnter} />
|
|
146
|
+
<span>{s.toUpperCase()}</span>
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── ImageControl ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/** A dashed pill button (`.gemboss-ei-addchip`): an image slot's Library / Generate / Clear, a panel's "+ Add". */
|
|
156
|
+
export function ChipButton({ disabled, expanded, onClick, children }: {
|
|
157
|
+
disabled?: boolean;
|
|
158
|
+
/** `aria-expanded`, for a chip that opens something under it (the Library grid). */
|
|
159
|
+
expanded?: boolean;
|
|
160
|
+
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
|
161
|
+
children: React.ReactNode;
|
|
162
|
+
}) {
|
|
163
|
+
return <button type="button" className="gemboss-ei-addchip" disabled={disabled} aria-expanded={expanded} onClick={onClick}>{children}</button>;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface ImageControlProps {
|
|
167
|
+
/** The URL box's id. */
|
|
168
|
+
id?: string;
|
|
169
|
+
/** The committed URL: what the thumbnail shows. */
|
|
170
|
+
value: string;
|
|
171
|
+
/** A committed URL: on blur / Enter when the box differs from `value`, and "" from Clear. */
|
|
172
|
+
onChange: (url: string) => void;
|
|
173
|
+
disabled?: boolean;
|
|
174
|
+
placeholder?: string;
|
|
175
|
+
/**
|
|
176
|
+
* The URL box's text, when the caller holds it (to put a picked or generated URL in the box at once, before `value`
|
|
177
|
+
* comes back). Omitted, the control keeps its own draft and resets it whenever `value` changes.
|
|
178
|
+
*/
|
|
179
|
+
draft?: string;
|
|
180
|
+
onDraftChange?: (text: string) => void;
|
|
181
|
+
/** Chips before Clear (`ChipButton`): Library, Generate. */
|
|
182
|
+
actions?: React.ReactNode;
|
|
183
|
+
/** Draw Clear while there is a value. Default true. */
|
|
184
|
+
clearable?: boolean;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `.gemboss-ei-img`: a 52px thumbnail (— when empty) beside the URL box and its chips. */
|
|
188
|
+
export function ImageControl({ id, value, onChange, disabled, placeholder, draft, onDraftChange, actions, clearable = true }: ImageControlProps) {
|
|
189
|
+
const [own, setOwn] = useState(value);
|
|
190
|
+
useEffect(() => { setOwn(value); }, [value]);
|
|
191
|
+
const text = draft ?? own;
|
|
192
|
+
const setText = (next: string) => { if (draft === undefined) setOwn(next); onDraftChange?.(next); };
|
|
193
|
+
return (
|
|
194
|
+
<div className="gemboss-ei-img">
|
|
195
|
+
<div className="gemboss-ei-img-thumb" style={value ? { backgroundImage: cssUrl(value) } : undefined} aria-hidden>{value ? null : "—"}</div>
|
|
196
|
+
<div className="gemboss-ei-img-ctl">
|
|
197
|
+
<input id={id} className="gemboss-field gemboss-ei-inp" placeholder={placeholder ?? "Paste image URL…"} value={text} disabled={disabled}
|
|
198
|
+
onChange={(e) => setText(e.target.value)} onBlur={() => { if (text !== value) onChange(text); }} onKeyDown={blurOnEnter} />
|
|
199
|
+
<div className="gemboss-ei-img-actions">
|
|
200
|
+
{actions}
|
|
201
|
+
{clearable && value ? <ChipButton disabled={disabled} onClick={() => { setText(""); onChange(""); }}>Clear</ChipButton> : null}
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// A CSS `url("…")` for any image URL (0.20.1). Plain TypeScript, no "use client": a server component may build a
|
|
2
|
+
// background with it (also `@gemboss/ui/editor/css-url`).
|
|
3
|
+
//
|
|
4
|
+
// Unquoted, a URL holding ")" or a space ends the token early, the declaration is dropped, and the thumbnail shows
|
|
5
|
+
// nothing. Quoted, only `"` and `\` need a backslash; a control character (tab, newline, …) is written as a hex escape
|
|
6
|
+
// followed by a space, the one form CSS reads back as that character. (JSON.stringify is NOT this: CSS reads its `\n`
|
|
7
|
+
// as "n" and its `\u0001` as the text "u0001".) editor-plain.js writes the same thing; editor-panel-browser.test.tsx
|
|
8
|
+
// holds the two to one answer.
|
|
9
|
+
export function cssUrl(url: string): string {
|
|
10
|
+
return `url("${url.replace(/["\\]/g, "\\$&").replace(/[\u0000-\u001f\u007f]/g, (c) => `\\${c.charCodeAt(0).toString(16)} `)}")`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// What EVERY editor draws: EditorFrame, EditorTopbar (+ EditorBack, EditorSaveState) and DeviceToggle, kept in this one
|
|
2
|
+
// module on purpose (the theme editor's own pane controls are panes.tsx; see index.ts). The device toggle is also drawn
|
|
3
|
+
// by the plugin editor and the studio canvas, so together they ride in the chunk the toggle already had in the admin.
|
|
4
|
+
// Split into one module per piece, the admin build grew a lazy chunk to place them (its runtime +52 bytes, every
|
|
5
|
+
// screen's first load +20 B brotli). Measured 06/10/2026.
|
|
6
|
+
import type React from "react";
|
|
7
|
+
|
|
8
|
+
/** Which half a phone shows (≤760px shows one at a time; wider shows both). Values are the CSS's `data-mobile-pane`. */
|
|
9
|
+
export type EditorPane = "chat" | "preview";
|
|
10
|
+
|
|
11
|
+
/** The seam between the left column and the preview: its width is the frame's, the drag/keyboard logic is the caller's. */
|
|
12
|
+
export interface EditorSplit {
|
|
13
|
+
/** The left column's width in px. Written to `--gemboss-chat-w` on the body, and read out as the seam's `aria-valuenow`. */
|
|
14
|
+
width: number;
|
|
15
|
+
/** The seam's range for a screen reader. `max` should be the ceiling the box really allows, not a constant. */
|
|
16
|
+
min: number;
|
|
17
|
+
max: number;
|
|
18
|
+
/** The left column is hidden for a full-width preview: `--gemboss-chat-w` becomes 0 and the seam goes. */
|
|
19
|
+
collapsed?: boolean;
|
|
20
|
+
/** Start a drag (a POINTER event: touch is a pointer, a mouse event never fires for a finger). */
|
|
21
|
+
onPointerDown?: React.PointerEventHandler<HTMLDivElement>;
|
|
22
|
+
/** Arrow keys / Home / End: the seam is focusable, so a key must move it. */
|
|
23
|
+
onKeyDown?: React.KeyboardEventHandler<HTMLDivElement>;
|
|
24
|
+
/** Accessible name of the seam. Default "Resize chat panel". */
|
|
25
|
+
label?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface EditorFrameProps {
|
|
29
|
+
/** Added to `.gemboss-editor` (the admin's page and section editors add `gemboss-pageedit`). */
|
|
30
|
+
className?: string;
|
|
31
|
+
/** Mounted inside another surface instead of over the viewport (`.gemboss-editor--embedded`). */
|
|
32
|
+
embedded?: boolean;
|
|
33
|
+
/** Everything above the body, in order: the top bar, and any banner or notice that belongs under it. */
|
|
34
|
+
top?: React.ReactNode;
|
|
35
|
+
/** The left column. Its root is a `.gemboss-ec` (the chat / Configure column the frame sizes). */
|
|
36
|
+
left?: React.ReactNode;
|
|
37
|
+
/** The seam. Omit it for a fixed split. */
|
|
38
|
+
split?: EditorSplit;
|
|
39
|
+
/** The preview pane's content (the canvas and anything that floats over it). */
|
|
40
|
+
preview?: React.ReactNode;
|
|
41
|
+
/** The right column: a panel docked over the preview's right edge (`.gemboss-editor-dock`, 320px, ≤90%), e.g. the
|
|
42
|
+
* theme editor's Global styles. Rendered first inside the pane, so the canvas and its overlays stack above it in
|
|
43
|
+
* source order exactly as they did when each editor drew it inline. Nothing (null/false) draws nothing. */
|
|
44
|
+
right?: React.ReactNode;
|
|
45
|
+
/** Added to the dock (`.gemboss-editor-dock`). */
|
|
46
|
+
rightClassName?: string;
|
|
47
|
+
/** Phones: which pane shows (`data-mobile-pane`). Put an `EditorPaneTabs` first in `children` to let them switch. */
|
|
48
|
+
mobilePane?: EditorPane;
|
|
49
|
+
bodyRef?: React.Ref<HTMLDivElement>;
|
|
50
|
+
/** Added to `.gemboss-editor-body`. */
|
|
51
|
+
bodyClassName?: string;
|
|
52
|
+
previewRef?: React.Ref<HTMLDivElement>;
|
|
53
|
+
/** Added to `.gemboss-editor-preview-pane`. */
|
|
54
|
+
previewClassName?: string;
|
|
55
|
+
previewStyle?: React.CSSProperties;
|
|
56
|
+
/** After the body: the phone's pane switch, drawers, popups, anything laid over the whole editor. */
|
|
57
|
+
children?: React.ReactNode;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `div.gemboss-editor`: the editor, over the whole viewport (`position: fixed; inset: 0`) unless `embedded`.
|
|
62
|
+
* Markup, in order: `top` · `.gemboss-editor-body` (left · seam · preview pane, `right` docked first inside it) · `children`.
|
|
63
|
+
*/
|
|
64
|
+
export function EditorFrame({
|
|
65
|
+
className, embedded, top, left, split, preview, right, rightClassName, mobilePane,
|
|
66
|
+
bodyRef, bodyClassName, previewRef, previewClassName, previewStyle, children,
|
|
67
|
+
}: EditorFrameProps) {
|
|
68
|
+
return (
|
|
69
|
+
<div className={["gemboss-editor", embedded && "gemboss-editor--embedded", className].filter(Boolean).join(" ")}>
|
|
70
|
+
{top}
|
|
71
|
+
<div className={["gemboss-editor-body", bodyClassName, split?.collapsed && "is-chat-collapsed"].filter(Boolean).join(" ")}
|
|
72
|
+
data-mobile-pane={mobilePane} ref={bodyRef}
|
|
73
|
+
style={split ? ({ "--gemboss-chat-w": `${split.collapsed ? 0 : split.width}px` } as React.CSSProperties) : undefined}>
|
|
74
|
+
{left}
|
|
75
|
+
{split ? (
|
|
76
|
+
<div className="gemboss-editor-resizer ui-resizer" onPointerDown={split.onPointerDown} onKeyDown={split.onKeyDown}
|
|
77
|
+
role="separator" aria-orientation="vertical" aria-label={split.label ?? "Resize chat panel"}
|
|
78
|
+
aria-valuenow={split.width} aria-valuemin={split.min} aria-valuemax={split.max} tabIndex={0} />
|
|
79
|
+
) : null}
|
|
80
|
+
<div className={previewClassName ? `gemboss-editor-preview-pane ${previewClassName}` : "gemboss-editor-preview-pane"} ref={previewRef} style={previewStyle}>
|
|
81
|
+
{right != null && right !== false ? <div className={rightClassName ? `gemboss-editor-dock ${rightClassName}` : "gemboss-editor-dock"}>{right}</div> : null}
|
|
82
|
+
{preview}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
{children}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Top bar ────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The bar's layout, by what sits in it (the CSS is written per variant):
|
|
94
|
+
* theme → `--theme`: content-sized tracks above 1000px; the bar with a page picker and a Publish split button.
|
|
95
|
+
* page → `.gemboss-pageedit-top`: same tracks; a title, a type chip and a status beside ← (page and section editors).
|
|
96
|
+
* guest → `--guest`: 1fr auto 1fr; a brand, a note, sign-in (the try door, no store yet).
|
|
97
|
+
*/
|
|
98
|
+
export type EditorTopbarVariant = "theme" | "page" | "guest";
|
|
99
|
+
const VARIANT: Record<EditorTopbarVariant, string> = {
|
|
100
|
+
theme: "gemboss-editor-topbar--theme",
|
|
101
|
+
page: "gemboss-pageedit-top",
|
|
102
|
+
guest: "gemboss-editor-topbar--guest",
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export interface EditorTopbarProps {
|
|
106
|
+
variant?: EditorTopbarVariant;
|
|
107
|
+
className?: string;
|
|
108
|
+
/** The way out, first in the bar: an `EditorBack`. */
|
|
109
|
+
back?: React.ReactNode;
|
|
110
|
+
/** Show / hide the left column (the frame's `split.collapsed`): an `EditorChatToggle`. Drawn between ← and the title. */
|
|
111
|
+
chatToggle?: React.ReactNode;
|
|
112
|
+
/** What is being edited: "Theme editor", "Page editor"… (`.gemboss-editor-tb-name`). */
|
|
113
|
+
title?: React.ReactNode;
|
|
114
|
+
/** After the title, in the left cluster: the page's name, a type chip, the save state, a mode switch. */
|
|
115
|
+
left?: React.ReactNode;
|
|
116
|
+
/** The centre cluster: page picker, `DeviceToggle`. */
|
|
117
|
+
center?: React.ReactNode;
|
|
118
|
+
/** The right cluster: tools, the ⋯ menu, Publish. */
|
|
119
|
+
right?: React.ReactNode;
|
|
120
|
+
/** A transient line under the bar (publish progress), floating top-right; `state` is a class: done | error | running.
|
|
121
|
+
* Nothing is drawn while `message` is empty. */
|
|
122
|
+
status?: { state: string; message: React.ReactNode } | null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `header.gemboss-editor-topbar`: three clusters (left · center · right), 52px. Returns the header and, when there is
|
|
126
|
+
* one, the status line after it. */
|
|
127
|
+
export function EditorTopbar({ variant = "page", className, back, chatToggle, title, left, center, right, status }: EditorTopbarProps) {
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<header className={`gemboss-editor-topbar ${VARIANT[variant]}${className ? ` ${className}` : ""}`}>
|
|
131
|
+
<div className="gemboss-editor-tb-left">
|
|
132
|
+
{back}
|
|
133
|
+
{chatToggle}
|
|
134
|
+
{title != null && title !== false ? <span className="gemboss-editor-tb-name">{title}</span> : null}
|
|
135
|
+
{left}
|
|
136
|
+
</div>
|
|
137
|
+
<div className="gemboss-editor-tb-center">{center}</div>
|
|
138
|
+
<div className="gemboss-editor-tb-right">{right}</div>
|
|
139
|
+
</header>
|
|
140
|
+
{status && status.message != null && status.message !== false && status.message !== "" ? (
|
|
141
|
+
<div className={`gemboss-editor-statusbar ${status.state}`} role="status">{status.message}</div>
|
|
142
|
+
) : null}
|
|
143
|
+
</>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* ← Back: `as={Link}` (plus `href`) for a router link, a `<button type="button">` with `onClick` by default.
|
|
149
|
+
* The arrow is the label's picture; the name a screen reader hears is `label` ("Back").
|
|
150
|
+
*/
|
|
151
|
+
export function EditorBack({ as: Comp = "button", label = "Back", ...rest }:
|
|
152
|
+
{ as?: React.ElementType; label?: string } & Omit<React.AllHTMLAttributes<HTMLElement>, "as" | "label" | "className" | "children">) {
|
|
153
|
+
return <Comp {...(Comp === "button" ? { type: "button" } : null)} {...rest} className="gemboss-editor-back" aria-label={label}>←</Comp>;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The auto-save pill: "Saving…" with a spinner while an edit is in flight, "✓ Draft saved" once it lands.
|
|
158
|
+
* `idle` draws nothing unless `idleText` says what autosave means here (the section editor's "Autosaves", dimmed).
|
|
159
|
+
*/
|
|
160
|
+
export function EditorSaveState({
|
|
161
|
+
state, savedText = "✓ Draft saved", savingText = "Saving…", savingTitle = "Saving draft…",
|
|
162
|
+
savedTitle = "Every change is saved to the draft automatically", idleText,
|
|
163
|
+
}: {
|
|
164
|
+
state: "idle" | "saving" | "saved";
|
|
165
|
+
savedText?: React.ReactNode; savingText?: React.ReactNode; savingTitle?: string; savedTitle?: string; idleText?: React.ReactNode;
|
|
166
|
+
}) {
|
|
167
|
+
if (state === "idle") {
|
|
168
|
+
return idleText != null && idleText !== false ? <span className="gemboss-editor-savestate" title={savedTitle} style={{ opacity: 0.55 }}>{idleText}</span> : null;
|
|
169
|
+
}
|
|
170
|
+
return (
|
|
171
|
+
<span className={`gemboss-editor-savestate is-${state}`} role="status" aria-live="polite" title={state === "saving" ? savingTitle : savedTitle}>
|
|
172
|
+
{state === "saving" ? <><span className="gemboss-narr-spin" aria-hidden />{savingText}</> : savedText}
|
|
173
|
+
</span>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Device toggle: moved from the admin's components/shell/DeviceToggle.tsx (0.14.0), unchanged. The theme, page and
|
|
178
|
+
// section editors' device control, the plugin editor's, and the studio canvas's glyphs.
|
|
179
|
+
|
|
180
|
+
/** Desktop / tablet / mobile — the breakpoints the responsive style overrides target. */
|
|
181
|
+
export type PreviewBreakpoint = "desktop" | "tablet" | "mobile";
|
|
182
|
+
|
|
183
|
+
/** The monitor and the phone, ONE drawing each for every device control (the editors' toggle, a canvas's Desktop ·
|
|
184
|
+
* Mobile · Both). currentColor, so a pressed button's colour carries. */
|
|
185
|
+
export function MonitorGlyph({ size = 15 }: { size?: number }) {
|
|
186
|
+
return (
|
|
187
|
+
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
188
|
+
<rect x="2.5" y="4" width="19" height="12" rx="2" /><line x1="9" y1="20.5" x2="15" y2="20.5" /><line x1="12" y1="16" x2="12" y2="20.5" />
|
|
189
|
+
</svg>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
export function PhoneGlyph({ size = 15 }: { size?: number }) {
|
|
193
|
+
return (
|
|
194
|
+
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
195
|
+
<rect x="6.5" y="2.5" width="11" height="19" rx="2.5" /><line x1="10.5" y1="18.5" x2="13.5" y2="18.5" />
|
|
196
|
+
</svg>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Desktop / tablet / mobile preview toggle. The kit's segmented control (`.ui-seg`, 06/10/2026), the same look as every
|
|
201
|
+
* other switch in the editors; `.gemboss-pv-devices` / `.gemboss-pv-device` stay as the hooks editor-plain.js finds. */
|
|
202
|
+
export function DeviceToggle({ device, onChange }: { device: PreviewBreakpoint; onChange: (device: PreviewBreakpoint) => void }) {
|
|
203
|
+
return (
|
|
204
|
+
<div className="ui-seg gemboss-pv-devices" role="group" aria-label="Device">
|
|
205
|
+
<button
|
|
206
|
+
type="button"
|
|
207
|
+
className={`gemboss-pv-device${device === "desktop" ? " active" : ""}`}
|
|
208
|
+
aria-label="Desktop"
|
|
209
|
+
title="Desktop"
|
|
210
|
+
aria-pressed={device === "desktop"}
|
|
211
|
+
onClick={() => onChange("desktop")}
|
|
212
|
+
>
|
|
213
|
+
{/* Monitor SVG — the ▭/▯ box glyphs were indistinguishable and rendered as tofu on some fonts. */}
|
|
214
|
+
<MonitorGlyph />
|
|
215
|
+
</button>
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
className={`gemboss-pv-device${device === "tablet" ? " active" : ""}`}
|
|
219
|
+
aria-label="Tablet"
|
|
220
|
+
title="Tablet"
|
|
221
|
+
aria-pressed={device === "tablet"}
|
|
222
|
+
onClick={() => onChange("tablet")}
|
|
223
|
+
>
|
|
224
|
+
{/* Tablet SVG — a wider, landscape-ish slate to read distinctly from the phone. */}
|
|
225
|
+
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
226
|
+
<rect x="4" y="3" width="16" height="18" rx="2" /><line x1="10.5" y1="18" x2="13.5" y2="18" />
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
className={`gemboss-pv-device${device === "mobile" ? " active" : ""}`}
|
|
232
|
+
aria-label="Mobile"
|
|
233
|
+
title="Mobile"
|
|
234
|
+
aria-pressed={device === "mobile"}
|
|
235
|
+
onClick={() => onChange("mobile")}
|
|
236
|
+
>
|
|
237
|
+
{/* Phone SVG */}
|
|
238
|
+
<PhoneGlyph />
|
|
239
|
+
</button>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@gemboss/ui/editor` (0.14.0, step 2 of 5 of the editor kit; the panel and its controls since 0.17.0, step 3): the
|
|
3
|
+
* EDITOR FRAME and its TOP BAR — the full-screen shell
|
|
4
|
+
* the admin's theme, page and section editors are drawn in, as React components. The look is `@gemboss/ui/editor.css`
|
|
5
|
+
* (step 1); this is the markup that look is written for, so a second editor (GemFactory's Builder) gets the same frame
|
|
6
|
+
* instead of a copy.
|
|
7
|
+
*
|
|
8
|
+
* ┌ EditorTopbar ──────────────────────────────────────────────────────────────────────────────────────┐
|
|
9
|
+
* │ ← [toggle] Title · left… center (page picker, DeviceToggle) right (tools, ⋯, Publish) │
|
|
10
|
+
* ├ EditorFrame body ──────────────────────────────────────────────────────────────────────────────────┤
|
|
11
|
+
* │ left (.gemboss-ec) ║ seam ║ preview pane (preview + `right` docked over its right edge) │
|
|
12
|
+
* └────────────────────────────────────────────────────────────────── EditorPaneTabs (phones only) ─────┘
|
|
13
|
+
*
|
|
14
|
+
* FRAME ONLY. Every component is stateless and knows nothing of what is edited: the chat, the Configure dock, the
|
|
15
|
+
* canvas, the page picker, the menus and Publish are the editor's own and arrive as slots. State that belongs to the
|
|
16
|
+
* frame (the seam's width, which pane a phone shows, whether the left column is collapsed) is passed IN; the admin keeps
|
|
17
|
+
* it in `useResizableChatPanel`, a page without React gets it from `gembossUI.initEditorFrame()` (editor-plain.js).
|
|
18
|
+
*
|
|
19
|
+
* Its OWN entry, not part of `@gemboss/ui`: like editor.css, only a screen that shows an editor pays for it. Two modules
|
|
20
|
+
* behind it, grouped by who draws what (measured in the admin build, 06/10/2026): `frame` holds what every editor draws
|
|
21
|
+
* (and the device toggle the plugin editor shares), `panes` the chat toggle and the phone pane switch only the theme
|
|
22
|
+
* editor draws. One module for all of it cost the page and section editors ~0.3 KB of code they never run; one module
|
|
23
|
+
* per piece made the admin build grow a lazy chunk, and with it every screen's first load. No "use client": nothing
|
|
24
|
+
* here holds state.
|
|
25
|
+
*/
|
|
26
|
+
export {
|
|
27
|
+
DeviceToggle, EditorBack, EditorFrame, EditorSaveState, EditorTopbar, MonitorGlyph, PhoneGlyph,
|
|
28
|
+
type EditorFrameProps, type EditorPane, type EditorSplit, type EditorTopbarProps, type EditorTopbarVariant, type PreviewBreakpoint,
|
|
29
|
+
} from "./frame";
|
|
30
|
+
export { EditorChatToggle, EditorPaneTabs } from "./panes";
|
|
31
|
+
// 0.17.0 (step 3): the settings panel (tabs, header, collapsible group, field row) and its controls. Two more modules,
|
|
32
|
+
// by the same rule: `panel` is drawn by every editor's left column, `controls` only where values are edited. Both are
|
|
33
|
+
// "use client" (InspectorGroup may hold its own open state; the text-like controls hold a draft).
|
|
34
|
+
export {
|
|
35
|
+
Field, InspectorGroup, PanelAction, PanelHeader, PanelTabs,
|
|
36
|
+
type FieldProps, type InspectorGroupProps, type PanelHeaderProps, type PanelTab, type PanelTabsProps,
|
|
37
|
+
} from "./panel";
|
|
38
|
+
export {
|
|
39
|
+
ChipButton, ColorControl, EdgeBox, ImageControl, RangeControl, Seg,
|
|
40
|
+
type ColorControlProps, type EdgeBoxProps, type EdgeSide, type ImageControlProps, type RangeControlProps, type SegOption, type SegProps,
|
|
41
|
+
} from "./controls";
|
|
42
|
+
export { swatchState, type SwatchState } from "./swatch";
|
|
43
|
+
export { cssUrl } from "./css-url";
|