@gemboss/ui 0.13.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.
@@ -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,78 @@
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 React, { useCallback, useEffect, useRef, useState } from "react";
11
+
12
+ export type BulkAction = { content: React.ReactNode; onAction: (ids: string[]) => void; tone?: "critical" };
13
+
14
+ /** 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). */
15
+ export const ESCAPE_OWNERS = 'details[open], dialog[open], [role="dialog"], [role="menu"]';
16
+
17
+ /** Is this Escape for a menu or a dialog in `root` rather than for the selection? */
18
+ export function escapeTaken(root: Element, target: EventTarget | null): boolean {
19
+ const t = target as Element | null;
20
+ if (t && typeof t.closest === "function" && t.closest(ESCAPE_OWNERS)) return true;
21
+ return Array.from(root.querySelectorAll(ESCAPE_OWNERS)).some((e) => e.getClientRects().length > 0);
22
+ }
23
+
24
+ export function useSelection<R extends HTMLElement>(ids: string[], selectedProp: string[] | undefined, onSelectionChange: ((ids: string[]) => void) | undefined, enabled: boolean) {
25
+ const [own, setOwn] = useState<string[]>([]);
26
+ const selected = selectedProp ?? own;
27
+ const setSelected = useCallback((next: string[]) => { if (!selectedProp) setOwn(next); onSelectionChange?.(next); }, [selectedProp, onSelectionChange]);
28
+ const rootRef = useRef<R>(null);
29
+ const anchorRef = useRef<string | null>(null);
30
+ const selSet = new Set(selected);
31
+ const chosen = ids.filter((id) => selSet.has(id));
32
+ const n = chosen.length;
33
+ const all = n > 0 && n === ids.length, some = n > 0 && !all;
34
+ useEffect(() => {
35
+ const root = rootRef.current;
36
+ if (!root || !enabled) return;
37
+ const onKey = (e: KeyboardEvent) => {
38
+ if (e.key !== "Escape" || e.defaultPrevented || n === 0 || escapeTaken(root, e.target)) return;
39
+ setSelected([]);
40
+ };
41
+ root.addEventListener("keydown", onKey);
42
+ return () => root.removeEventListener("keydown", onKey);
43
+ }, [n, enabled, setSelected]);
44
+ const toggle = (i: number, on: boolean, shift: boolean) => {
45
+ const next = new Set(selected);
46
+ const from = anchorRef.current ? ids.indexOf(anchorRef.current) : -1;
47
+ if (shift && from > -1) {
48
+ for (let k = Math.min(from, i); k <= Math.max(from, i); k++) { if (on) next.add(ids[k]); else next.delete(ids[k]); }
49
+ } else if (on) next.add(ids[i]); else next.delete(ids[i]);
50
+ anchorRef.current = ids[i];
51
+ setSelected(ids.filter((x) => next.has(x)));
52
+ };
53
+ const toggleAll = (on: boolean) => { anchorRef.current = null; setSelected(on ? ids : []); };
54
+ return { rootRef, selSet, chosen, n, all, some, toggle, toggleAll };
55
+ }
56
+
57
+ /** The bulk bar: its own select-all box, "n selected", the actions (they get the chosen ids). `idle` says what it does
58
+ * with nothing selected: "hide" (a table: it covers the header only while rows are selected) or "disable" (a list: it
59
+ * is the list's header, there from the start, its actions off). */
60
+ export function BulkBar({ actions, chosen, all, some, onToggleAll, selectedLabel, selectAllLabel, idle, boxDisabled }: {
61
+ actions: BulkAction[]; chosen: string[]; all: boolean; some: boolean; onToggleAll: (on: boolean) => void;
62
+ selectedLabel: (n: number) => React.ReactNode; selectAllLabel: string; idle: "hide" | "disable"; boxDisabled?: boolean }) {
63
+ const box = useRef<HTMLInputElement>(null);
64
+ useEffect(() => { if (box.current) box.current.indeterminate = some; }, [some]);
65
+ const n = chosen.length;
66
+ return (
67
+ <div className="ui-bulkbar" hidden={idle === "hide" && n === 0}>
68
+ <label className="ui-table__checkbox"><input ref={box} type="checkbox" checked={all} aria-label={selectAllLabel} disabled={boxDisabled} onChange={(e) => onToggleAll(e.target.checked)} /></label>
69
+ <span className="ui-bulkbar__count" aria-live="polite">{selectedLabel(n)}</span>
70
+ <span className="ui-bulkbar__actions">
71
+ {actions.map((b, i) => (
72
+ <button key={i} type="button" className={b.tone === "critical" ? "ui-btn ui-btn--sm ui-btn--critical" : "ui-btn ui-btn--sm"}
73
+ disabled={idle === "disable" && n === 0} onClick={() => b.onAction(chosen)}>{b.content}</button>
74
+ ))}
75
+ </span>
76
+ </div>
77
+ );
78
+ }