@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,146 @@
1
+ "use client";
2
+
3
+ /**
4
+ * `SearchDialog` — @gemboss/ui (04/10/2026; its own module since 0.23.0, out of dialog.tsx).
5
+ *
6
+ * The Featurebase / admin command search. A topbar button (or ⌘K / Ctrl+K / "/")
7
+ * opens it; what to show before typing comes from `initial`, results while typing from `search`
8
+ * (debounced, the latest answer wins); ↑ ↓ move, Enter opens, Esc closes. Results are links, so a
9
+ * page without JavaScript loses only the popup, never the search itself (`fallbackHref`).
10
+ * Its look (`gb-search-*`) is in portal.css, which it imports: a surface that never shows it never loads it.
11
+ */
12
+ import "./portal.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
13
+ import React, { useCallback, useEffect, useId, useRef, useState } from "react";
14
+ import { Icon } from "./icons";
15
+
16
+ const cx = (...p: Array<string | false | null | undefined>) => p.filter(Boolean).join(" ");
17
+
18
+ export type SearchItem = { href: string; title: string; meta?: React.ReactNode };
19
+ export type SearchSection = { title: string; items: SearchItem[] };
20
+
21
+ // One constant, not a default `= []`: a fresh array each render, read by an effect, re-ran it forever
22
+ // ("Maximum update depth exceeded", review of 0.7.0 in Chromium).
23
+ const NO_SECTIONS: SearchSection[] = [];
24
+
25
+ export function SearchDialog({ search, initial = NO_SECTIONS, placeholder = "Search…", label = "Search", fallbackHref, shortcut = true, renderItem }: {
26
+ /** Results for a query. Called at most every 150 ms; an answer to an older query is dropped. */
27
+ search: (q: string) => Promise<SearchSection[]>;
28
+ /** What the popup shows before anything is typed (Featurebase: "Trending requests", "Latest updates"). */
29
+ initial?: SearchSection[];
30
+ placeholder?: string;
31
+ label?: string;
32
+ /** Where the topbar button goes without JavaScript (a search page), used as given. */
33
+ fallbackHref?: string;
34
+ /** ⌘K / Ctrl+K and "/" open it. */
35
+ shortcut?: boolean;
36
+ renderItem?: (item: SearchItem) => React.ReactNode;
37
+ }) {
38
+ const [open, setOpen] = useState(false);
39
+ const [q, setQ] = useState("");
40
+ const [sections, setSections] = useState<SearchSection[]>(initial);
41
+ const [active, setActive] = useState(0);
42
+ const [busy, setBusy] = useState(false);
43
+ const ref = useRef<HTMLDialogElement>(null);
44
+ const input = useRef<HTMLInputElement>(null);
45
+ const seq = useRef(0);
46
+ const listId = useId();
47
+ const flat = sections.flatMap((s) => s.items);
48
+ // The latest `search` and `initial`, read when needed: a consumer's inline arrow or array (a new one
49
+ // each render) must not restart the debounce or the effect.
50
+ const searchRef = useRef(search);
51
+ const initialRef = useRef(initial);
52
+ searchRef.current = search;
53
+ initialRef.current = initial;
54
+
55
+ const show = useCallback(() => {
56
+ const d = ref.current;
57
+ if (d && !d.open) { d.showModal(); setOpen(true); requestAnimationFrame(() => input.current?.focus()); }
58
+ }, []);
59
+ useEffect(() => {
60
+ if (!shortcut) return undefined;
61
+ const onKey = (e: KeyboardEvent) => {
62
+ const t = e.target as HTMLElement | null;
63
+ const typing = !!t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName));
64
+ // A handler that already took the key (the admin's own ⌘K palette) keeps it.
65
+ if (e.defaultPrevented) return;
66
+ if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing)) { e.preventDefault(); show(); }
67
+ };
68
+ document.addEventListener("keydown", onKey);
69
+ return () => document.removeEventListener("keydown", onKey);
70
+ }, [shortcut, show]);
71
+ useEffect(() => {
72
+ if (!open) return undefined;
73
+ const query = q.trim();
74
+ // Every change takes a new number: an answer still on its way for an older query is dropped,
75
+ // including when the box was just cleared (it used to land under an empty box).
76
+ const mine = ++seq.current;
77
+ if (!query) { setSections(initialRef.current); setActive(0); setBusy(false); return undefined; }
78
+ setBusy(true);
79
+ const t = setTimeout(async () => {
80
+ try {
81
+ const res = await searchRef.current(query);
82
+ if (mine === seq.current) { setSections(res); setActive(0); }
83
+ } catch {
84
+ if (mine === seq.current) setSections(NO_SECTIONS);
85
+ } finally {
86
+ if (mine === seq.current) setBusy(false);
87
+ }
88
+ }, 150);
89
+ return () => { clearTimeout(t); if (mine === seq.current) setBusy(false); };
90
+ }, [q, open]);
91
+ // Before anything is typed, show the latest suggestions the page passed.
92
+ useEffect(() => { if (open && !q.trim()) setSections(initial); }, [initial, open, q]);
93
+ // The highlighted result stays in view as the arrows move it.
94
+ useEffect(() => {
95
+ if (!open) return;
96
+ document.getElementById(`${listId}-${active}`)?.scrollIntoView({ block: "nearest" });
97
+ }, [active, open, listId]);
98
+
99
+ const go = (item: SearchItem | undefined) => { if (item) window.location.assign(item.href); };
100
+ let n = -1;
101
+ return (
102
+ <>
103
+ <a className="gb-search-trigger" href={fallbackHref ?? "#"} aria-label={label} aria-haspopup="dialog"
104
+ onClick={(e) => { e.preventDefault(); show(); }}>
105
+ <Icon name="search" size={18} />
106
+ </a>
107
+ <dialog ref={ref} className="ui-modal gb-search" aria-label={label}
108
+ onClose={() => { setOpen(false); setQ(""); }}
109
+ onClick={(e) => { if (e.target === e.currentTarget) e.currentTarget.close(); }}>
110
+ <div className="gb-search-box">
111
+ <Icon name="search" size={18} className="gb-search-icon" />
112
+ <input ref={input} className="gb-search-input" type="search" placeholder={placeholder} aria-label={label}
113
+ value={q} onChange={(e) => setQ(e.target.value)} role="combobox" aria-expanded="true" aria-controls={listId}
114
+ aria-activedescendant={flat[active] ? `${listId}-${active}` : undefined} autoComplete="off"
115
+ onKeyDown={(e) => {
116
+ if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => Math.min(a + 1, flat.length - 1)); }
117
+ else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => Math.max(a - 1, 0)); }
118
+ // Enter that confirms an IME composition (Vietnamese, Chinese) is not "open".
119
+ else if (e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229) { e.preventDefault(); go(flat[active]); }
120
+ }} />
121
+ </div>
122
+ <p className="gb-search-empty" aria-live="polite">
123
+ {sections.every((s) => !s.items.length) && q.trim() ? (busy ? "Searching…" : "Nothing matches.") : ""}
124
+ </p>
125
+ <div className="gb-search-results" id={listId} role="listbox" aria-busy={busy}>
126
+ {sections.filter((s) => s.items.length).map((s) => (
127
+ <div key={s.title} className="gb-search-section" role="group" aria-label={s.title}>
128
+ <div className="gb-search-section-title">{s.title}</div>
129
+ {s.items.map((it) => {
130
+ n += 1;
131
+ const i = n;
132
+ return (
133
+ <a key={`${s.title}-${it.href}`} id={`${listId}-${i}`} href={it.href} role="option" aria-selected={i === active}
134
+ className={cx("gb-search-item", i === active && "is-active")} onMouseMove={() => setActive(i)}>
135
+ {renderItem ? renderItem(it) : (<><span className="gb-search-item-title">{it.title}</span>{it.meta ? <span className="gb-search-item-meta">{it.meta}</span> : null}</>)}
136
+ </a>
137
+ );
138
+ })}
139
+ </div>
140
+ ))}
141
+ </div>
142
+ <div className="gb-search-foot" aria-hidden="true"><span>↵ open</span><span>↑↓ move</span><span>esc</span></div>
143
+ </dialog>
144
+ </>
145
+ );
146
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What a click on one segment MEANS.
3
+ *
4
+ * A `.ts` beside the `.tsx` for the same reason `zoom-about.ts` sits beside `DesignCanvas.tsx`:
5
+ * `node:test` strips types but does NOT transform JSX, so anything importable from a component file
6
+ * is untestable here. A one-line decision left inline in a handler is a decision with no test.
7
+ *
8
+ * And it IS a decision: a click on the ALREADY-ACTIVE option clears when the caller offered
9
+ * `onClear`, and re-selects when it did not. Backwards, and you get a control that cannot be unset —
10
+ * which is exactly the behaviour the element inspector kept its own private copy of `Segmented` for.
11
+ */
12
+ export function segmentedClick<T extends string>(
13
+ active: boolean,
14
+ value: T,
15
+ onChange: (v: T) => void,
16
+ onClear?: () => void,
17
+ ): void {
18
+ if (active && onClear) { onClear(); return; }
19
+ onChange(value);
20
+ }
@@ -0,0 +1,98 @@
1
+ import type { CSSProperties, ReactNode } from "react";
2
+ import { segmentedClick } from "./segmented-click";
3
+
4
+ /**
5
+ * Shared tool-UI segmented control — token-driven (`.ui-seg`), cross-surface. Two forms:
6
+ * - <Segmented> text tabs (options + value)
7
+ * - <IconSegmented> icon squares (e.g. a grid/list view toggle)
8
+ * The selected segment uses the accent-soft state.
9
+ *
10
+ * In the package since 0.20.0 (was the admin's components/ui/Segmented.tsx): the editor kit's `Seg` (editor/controls)
11
+ * draws through it too, so the inspector's field switches and the Studio's mode switch are one component, not two with
12
+ * one look. Its own module and subpath (`@gemboss/ui/segmented`): a surface that draws a switch does not load the shell.
13
+ *
14
+ * THE ONLY ONE (06/10/2026). Every "pick one of these" switch in the admin draws through here: the Studio's
15
+ * Funnel · Canvas · Page, the funnel lens, the canvas's device, the inspector's field switches, the
16
+ * post-purchase step and device. Before, the same control had nine looks (a dark pill, a dark joined group,
17
+ * a grey track with a raised tab, …) and two of them sat side by side in one bar. `no-hand-rolled-switch.test.ts`
18
+ * (in the admin) fails on a new `role="group"` of `aria-pressed` buttons anywhere else.
19
+ */
20
+ export type SegmentedOption<T extends string> = {
21
+ value: T;
22
+ label: ReactNode;
23
+ /** Tooltip; also the reason when the option is off ("Pick a step with a page first"). */
24
+ title?: string;
25
+ /** The accessible name when the label alone is not the full sentence (a count badge, an icon). */
26
+ ariaLabel?: string;
27
+ disabled?: boolean;
28
+ };
29
+
30
+ export function Segmented<T extends string>({
31
+ options, value, onChange, ariaLabel, disabled, onClear, fill = false, className, style,
32
+ }: {
33
+ options: SegmentedOption<T>[];
34
+ value: T | "";
35
+ onChange: (v: T) => void;
36
+ ariaLabel: string;
37
+ /** Greys the whole group while a write is in flight. */
38
+ disabled?: boolean;
39
+ /**
40
+ * Present ⇒ clicking the ALREADY-ACTIVE option clears the setting instead of re-selecting it.
41
+ *
42
+ * A separate callback rather than widening `onChange` to `(v: T | "")`. Widening would break the
43
+ * four surfaces already passing a `(v: T) => void` handler — under `strictFunctionTypes` that is
44
+ * not assignable — and the alternative, calling `onChange("" as T)`, is a lie about the type in a
45
+ * component four other surfaces read. A clearing control is a different verb; it gets its own prop.
46
+ */
47
+ onClear?: () => void;
48
+ /** Stretch to the row and share it equally: a field switch in the inspector's narrow column. */
49
+ fill?: boolean;
50
+ /** A hook class beside `ui-seg` (the editor kit's `gemboss-ei-seg`, which editor-plain.js finds). Never a look. */
51
+ className?: string;
52
+ /** On the group: the inspector narrows a two-option row with a max-width. */
53
+ style?: CSSProperties;
54
+ }) {
55
+ return (
56
+ <div className={`ui-seg${fill ? " ui-seg--fill" : ""}${className ? ` ${className}` : ""}`} style={style} role="group" aria-label={ariaLabel || undefined}>
57
+ {options.map((o) => {
58
+ const active = value === o.value;
59
+ return (
60
+ <button
61
+ key={o.value}
62
+ type="button"
63
+ // What a page without React reads back (editor-plain.js): the label can be anything.
64
+ data-value={o.value}
65
+ disabled={disabled || o.disabled}
66
+ title={o.title}
67
+ aria-label={o.ariaLabel}
68
+ aria-pressed={active}
69
+ className={`is-text${active ? " active" : ""}`}
70
+ onClick={() => segmentedClick(active, o.value, onChange, onClear)}
71
+ >
72
+ {o.label}
73
+ </button>
74
+ );
75
+ })}
76
+ </div>
77
+ );
78
+ }
79
+
80
+ export function IconSegmented<T extends string>({
81
+ options, value, onChange, ariaLabel,
82
+ }: {
83
+ /** `ariaLabel` is also the tooltip: an icon says nothing to a mouse until it is named. */
84
+ options: { value: T; icon: ReactNode; ariaLabel: string }[];
85
+ value: T;
86
+ onChange: (v: T) => void;
87
+ ariaLabel: string;
88
+ }) {
89
+ return (
90
+ <div className="ui-seg" role="group" aria-label={ariaLabel}>
91
+ {options.map((o) => (
92
+ <button key={o.value} type="button" className={value === o.value ? "active" : ""} onClick={() => onChange(o.value)} aria-label={o.ariaLabel} title={o.ariaLabel} aria-pressed={value === o.value}>
93
+ {o.icon}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ );
98
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Selection for the collections (0.16.0), and the bulk bar that acts on it: the rules IndexTable and SelectableResourceList share, in one place (README
3
+ * "Collections"): own or controlled ids; Shift+click takes the range from the last box clicked (the anchor is an id, so
4
+ * a re-sort cannot move it; an anchor that left the rows starts over); select-all; Escape clears while focus is in the
5
+ * root, unless an open ⋯ menu or dialog inside it takes that Escape first. The ids handed out are in the rows' order and
6
+ * only the ones shown (a filtered-away id does not count).
7
+ *
8
+ * No "use client" of its own: only the two client modules import it (a server graph never reaches its hooks).
9
+ */
10
+ import "./collections.css"; // its own stylesheet travels with it (0.23.0): a surface that never renders it never loads it
11
+ import React, { useCallback, useEffect, useRef, useState } from "react";
12
+
13
+ export type BulkAction = { content: React.ReactNode; onAction: (ids: string[]) => void; tone?: "critical" };
14
+
15
+ /** Escape belongs to these first, when they are OPEN and shown (a closed `<dialog>` or a hidden menu in a row does not take it). */
16
+ export const ESCAPE_OWNERS = 'details[open], dialog[open], [role="dialog"], [role="menu"]';
17
+
18
+ /** Is this Escape for a menu or a dialog in `root` rather than for the selection? */
19
+ export function escapeTaken(root: Element, target: EventTarget | null): boolean {
20
+ const t = target as Element | null;
21
+ if (t && typeof t.closest === "function" && t.closest(ESCAPE_OWNERS)) return true;
22
+ return Array.from(root.querySelectorAll(ESCAPE_OWNERS)).some((e) => e.getClientRects().length > 0);
23
+ }
24
+
25
+ export function useSelection<R extends HTMLElement>(ids: string[], selectedProp: string[] | undefined, onSelectionChange: ((ids: string[]) => void) | undefined, enabled: boolean) {
26
+ const [own, setOwn] = useState<string[]>([]);
27
+ const selected = selectedProp ?? own;
28
+ const setSelected = useCallback((next: string[]) => { if (!selectedProp) setOwn(next); onSelectionChange?.(next); }, [selectedProp, onSelectionChange]);
29
+ const rootRef = useRef<R>(null);
30
+ const anchorRef = useRef<string | null>(null);
31
+ const selSet = new Set(selected);
32
+ const chosen = ids.filter((id) => selSet.has(id));
33
+ const n = chosen.length;
34
+ const all = n > 0 && n === ids.length, some = n > 0 && !all;
35
+ useEffect(() => {
36
+ const root = rootRef.current;
37
+ if (!root || !enabled) return;
38
+ const onKey = (e: KeyboardEvent) => {
39
+ if (e.key !== "Escape" || e.defaultPrevented || n === 0 || escapeTaken(root, e.target)) return;
40
+ setSelected([]);
41
+ };
42
+ root.addEventListener("keydown", onKey);
43
+ return () => root.removeEventListener("keydown", onKey);
44
+ }, [n, enabled, setSelected]);
45
+ const toggle = (i: number, on: boolean, shift: boolean) => {
46
+ const next = new Set(selected);
47
+ const from = anchorRef.current ? ids.indexOf(anchorRef.current) : -1;
48
+ if (shift && from > -1) {
49
+ for (let k = Math.min(from, i); k <= Math.max(from, i); k++) { if (on) next.add(ids[k]); else next.delete(ids[k]); }
50
+ } else if (on) next.add(ids[i]); else next.delete(ids[i]);
51
+ anchorRef.current = ids[i];
52
+ setSelected(ids.filter((x) => next.has(x)));
53
+ };
54
+ const toggleAll = (on: boolean) => { anchorRef.current = null; setSelected(on ? ids : []); };
55
+ return { rootRef, selSet, chosen, n, all, some, toggle, toggleAll };
56
+ }
57
+
58
+ /** The bulk bar: its own select-all box, "n selected", the actions (they get the chosen ids). `idle` says what it does
59
+ * with nothing selected: "hide" (a table: it covers the header only while rows are selected) or "disable" (a list: it
60
+ * is the list's header, there from the start, its actions off). */
61
+ export function BulkBar({ actions, chosen, all, some, onToggleAll, selectedLabel, selectAllLabel, idle, boxDisabled }: {
62
+ actions: BulkAction[]; chosen: string[]; all: boolean; some: boolean; onToggleAll: (on: boolean) => void;
63
+ selectedLabel: (n: number) => React.ReactNode; selectAllLabel: string; idle: "hide" | "disable"; boxDisabled?: boolean }) {
64
+ const box = useRef<HTMLInputElement>(null);
65
+ useEffect(() => { if (box.current) box.current.indeterminate = some; }, [some]);
66
+ const n = chosen.length;
67
+ return (
68
+ <div className="ui-bulkbar" hidden={idle === "hide" && n === 0}>
69
+ <label className="ui-table__checkbox"><input ref={box} type="checkbox" checked={all} aria-label={selectAllLabel} disabled={boxDisabled} onChange={(e) => onToggleAll(e.target.checked)} /></label>
70
+ <span className="ui-bulkbar__count" aria-live="polite">{selectedLabel(n)}</span>
71
+ <span className="ui-bulkbar__actions">
72
+ {actions.map((b, i) => (
73
+ <button key={i} type="button" className={b.tone === "critical" ? "ui-btn ui-btn--sm ui-btn--critical" : "ui-btn ui-btn--sm"}
74
+ disabled={idle === "disable" && n === 0} onClick={() => b.onAction(chosen)}>{b.content}</button>
75
+ ))}
76
+ </span>
77
+ </div>
78
+ );
79
+ }