@gemboss/ui 0.2.0 → 0.3.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,179 @@
1
+ /* kit-plain.js: the BEHAVIOUR half of kit-plain.css, for surfaces that render HTML on the server and have no React
2
+ (GemFactory, gemcare, gempress, partner-radar). Each of them wrote its own popup, toast and menu-closing code; the
3
+ popup's Tab trap, stacking and "ask before closing" were each found missing by a review on some surface first.
4
+ GemFactory's copy (server/ui-kit.mjs, 02/10/2026) had all three, so it is the source.
5
+
6
+ A classic script, not a module: a server-rendered page can inline it (readFileSync(require.resolve(
7
+ "@gemboss/ui/kit-plain.js"))) or load it with <script src>, and use `globalThis.gembossUI` from its own inline code.
8
+ Strings it writes are the caller's: `title`, `body` and `foot` are HTML (escape your text), toast text is TEXT.
9
+
10
+ const { openModal, toast, initMenus } = gembossUI;
11
+ initMenus(); // details.ui-menu: outside click, item click, window blur and Escape close it
12
+ const m = openModal({ title: "Delete #12?", body: '<div class="ui-modal__body">…</div>', foot: "…" });
13
+ m.close(value); // onClose(value) runs once; Escape / backdrop / × close with null
14
+ toast("Saved"); toast("Could not save", { error: true });
15
+ */
16
+ (function (g) {
17
+ "use strict";
18
+ if (g.gembossUI) return;
19
+ var CLOSE_SVG = '<svg viewBox="0 0 20 20" aria-hidden="true" focusable="false"><path d="M5 5l10 10M15 5L5 15" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" fill="none"/></svg>';
20
+ // `summary` is a control (a kit ui-menu's ⋯ button); anything with no box (inside a closed <details>, display:none,
21
+ // [hidden]) is not, or Shift+Tab would "focus" a hidden menu item and stick (review #3633).
22
+ var FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), summary, iframe, [tabindex]:not([tabindex="-1"])';
23
+ var focusables = function (root) {
24
+ // checkVisibility knows a closed <details>' content (content-visibility: hidden still has boxes, so
25
+ // getClientRects alone counts its items); the box test is the fallback for browsers without it.
26
+ return Array.prototype.slice.call(root.querySelectorAll(FOCUSABLE)).filter(function (el) {
27
+ return el.checkVisibility ? el.checkVisibility({ visibilityProperty: true }) : el.getClientRects().length > 0;
28
+ });
29
+ };
30
+ var esc = function (s) { return String(s).replace(/[&<>"']/g, function (c) { return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]; }); };
31
+ var MODALS = [];
32
+ var seq = 0;
33
+ var savedOverflow = null; // the page's own body overflow, put back when the LAST popup closes
34
+
35
+ /* Popup: .ui-overlay + .ui-modal, the same markup as the admin's Modal.tsx. Escape, a click on the backdrop and the ×
36
+ close it; Tab stays inside; popups stack and only the TOP one takes keys. `title` empty = no header bar (then
37
+ `label` names the dialog for screen readers). `beforeClose(value)` may return a Promise<boolean>: false keeps it
38
+ open (unsaved changes); it gets the close value, so a Save that calls m.close(data) can skip the question, and a
39
+ guard that THROWS keeps the popup open (closing would throw away what it guards). Focus returns to whatever had it
40
+ when the popup opened, or to the popup below when that element is gone or sits under it. */
41
+ function openModal(o) {
42
+ var title = o.title || "", label = o.label || "", closeLabel = o.closeLabel || "Close";
43
+ var opener = document.activeElement;
44
+ var overlay = document.createElement("div");
45
+ overlay.className = "ui-overlay" + (o.overlayClass ? " " + o.overlayClass : "");
46
+ overlay.setAttribute("role", "presentation");
47
+ var tid = "gb-modal-title-" + ++seq;
48
+ overlay.innerHTML = '<div class="ui-modal' + (o.className ? " " + o.className : "") + '" role="dialog" aria-modal="true" ' +
49
+ (title ? 'aria-labelledby="' + tid + '"' : 'aria-label="' + esc(label) + '"') + ' tabindex="-1">' +
50
+ (title ? '<div class="ui-modal__head"><div class="ui-modal__title" id="' + tid + '">' + title + '</div><button type="button" class="ui-modal__close" aria-label="' + esc(closeLabel) + '">' + CLOSE_SVG + "</button></div>" : "") +
51
+ (o.body || "") + (o.foot ? '<div class="ui-modal__foot">' + o.foot + "</div>" : "") + "</div>";
52
+ var panel = overlay.firstChild;
53
+ var asking = false;
54
+ var m = {
55
+ panel: panel,
56
+ overlay: overlay,
57
+ close: function (value, opts) {
58
+ var force = opts && opts.force;
59
+ if (MODALS.indexOf(m) < 0) return Promise.resolve();
60
+ if (!force && o.beforeClose) {
61
+ if (asking) return Promise.resolve();
62
+ asking = true;
63
+ return Promise.resolve().then(function () { return o.beforeClose(value); }).catch(function (err) { console.error(err); return false; }).then(function (go) {
64
+ asking = false;
65
+ // Closed by force while asking (a navigation): closing again would splice the NEWEST popup instead.
66
+ if (go && MODALS.indexOf(m) >= 0) finish(value);
67
+ });
68
+ }
69
+ finish(value);
70
+ return Promise.resolve();
71
+ },
72
+ };
73
+ function finish(value) {
74
+ MODALS.splice(MODALS.indexOf(m), 1);
75
+ removeEventListener("keydown", onKey);
76
+ overlay.remove();
77
+ var top = MODALS[MODALS.length - 1];
78
+ if (!top) { document.body.style.overflow = savedOverflow || ""; savedOverflow = null; }
79
+ if (opener && opener.isConnected && opener.focus && (!top || top.panel.contains(opener))) opener.focus();
80
+ else if (top) top.panel.focus();
81
+ if (o.onClose) o.onClose(value);
82
+ }
83
+ function onKey(e) {
84
+ if (MODALS[MODALS.length - 1] !== m) return;
85
+ // Escape while an IME is composing (zh, ja, vi telex) cancels the composition, not the dialog.
86
+ if (e.key === "Escape") { if (e.isComposing || e.keyCode === 229) return; e.preventDefault(); m.close(null); return; }
87
+ if (e.key !== "Tab") return;
88
+ var f = focusables(panel);
89
+ if (!f.length) return;
90
+ var first = f[0], last = f[f.length - 1];
91
+ if (!panel.contains(document.activeElement)) { e.preventDefault(); first.focus(); }
92
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
93
+ else if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
94
+ }
95
+ // Close on the backdrop only when the press STARTED there: selecting text in a field and releasing past the panel
96
+ // edge fires click on the overlay (the common ancestor) and would throw the form away.
97
+ var downOnBackdrop = false;
98
+ overlay.addEventListener("mousedown", function (e) { downOnBackdrop = e.target === overlay; });
99
+ overlay.addEventListener("click", function (e) { if (e.target === overlay && downOnBackdrop) m.close(null); downOnBackdrop = false; });
100
+ var x = panel.querySelector(".ui-modal__head .ui-modal__close");
101
+ if (x) x.onclick = function () { m.close(null); };
102
+ addEventListener("keydown", onKey);
103
+ if (!MODALS.length) savedOverflow = document.body.style.overflow;
104
+ MODALS.push(m);
105
+ document.body.append(overlay);
106
+ document.body.style.overflow = "hidden";
107
+ setTimeout(function () { (panel.querySelector("[autofocus]") || panel).focus(); }, 0);
108
+ return m;
109
+ }
110
+
111
+ /* Toast: the admin's pill (.gemboss-toast), bottom centre, red when `error`. Text, not HTML. The viewport is a polite
112
+ live region; an ERROR toast is role="alert" so it is announced at once. `duration: 0` = stays until its × is
113
+ pressed (`dismissLabel` names it). When the region is created by this very toast, the toast goes in one frame
114
+ later: a live region that appears together with its first message is often not announced at all. */
115
+ function toast(text, opts) {
116
+ opts = opts || {};
117
+ var vp = document.querySelector(".gemboss-toast-viewport"), fresh = !vp;
118
+ if (!vp) {
119
+ vp = document.createElement("div");
120
+ vp.className = "gemboss-toast-viewport";
121
+ vp.setAttribute("role", "status");
122
+ vp.setAttribute("aria-live", "polite");
123
+ document.body.append(vp);
124
+ }
125
+ var t = document.createElement("div");
126
+ t.className = "gemboss-toast" + (opts.error ? " is-error" : "");
127
+ if (opts.error) t.setAttribute("role", "alert");
128
+ var span = document.createElement("span");
129
+ span.className = "gemboss-toast-text";
130
+ span.textContent = String(text);
131
+ t.append(span);
132
+ var sticky = opts.duration === 0;
133
+ if (sticky) {
134
+ var x = document.createElement("button");
135
+ x.type = "button";
136
+ x.className = "gemboss-toast-dismiss";
137
+ x.setAttribute("aria-label", opts.dismissLabel || "Dismiss");
138
+ x.innerHTML = CLOSE_SVG;
139
+ x.onclick = function () { t.remove(); };
140
+ t.append(x);
141
+ }
142
+ // Toasts sent while the new region is still waiting queue behind the first, so they keep their order.
143
+ if (fresh) vp.dataset.readyAt = String(Date.now() + 50);
144
+ var wait = Math.max(0, Number(vp.dataset.readyAt || 0) - Date.now());
145
+ var put = function () { vp.append(t); };
146
+ if (wait) setTimeout(put, wait); else put();
147
+ if (!sticky) setTimeout(function () { t.remove(); }, wait + (opts.duration || (opts.error ? 6000 : 3500)));
148
+ return t;
149
+ }
150
+
151
+ /* Menus (details.ui-menu, kit-plain.css) open with no script; closing is the page's job: an outside click, a click on
152
+ an item, the window losing focus (a click inside an iframe never reaches this document), and Escape. Escape closes
153
+ the MENU first and stops there, so a menu inside a popup does not close the popup with it. Idempotent. */
154
+ var menusOn = false;
155
+ function closeMenus(except) {
156
+ document.querySelectorAll("details.ui-menu[open]").forEach(function (d) { if (d !== except) d.open = false; });
157
+ }
158
+ function initMenus() {
159
+ if (menusOn) return;
160
+ menusOn = true;
161
+ document.addEventListener("click", function (e) {
162
+ var d = e.target.closest && e.target.closest("details.ui-menu");
163
+ closeMenus(d);
164
+ if (d && e.target.closest(".ui-menu__item")) d.open = false;
165
+ });
166
+ addEventListener("blur", function () { closeMenus(); });
167
+ addEventListener("keydown", function (e) {
168
+ var d = e.key === "Escape" && !e.isComposing && document.querySelector("details.ui-menu[open]");
169
+ if (!d) return;
170
+ e.preventDefault();
171
+ e.stopImmediatePropagation();
172
+ d.open = false;
173
+ var s = d.querySelector("summary");
174
+ if (s) s.focus();
175
+ }, true);
176
+ }
177
+
178
+ g.gembossUI = { openModal: openModal, toast: toast, initMenus: initMenus, closeMenus: closeMenus, openModals: function () { return MODALS.length; } };
179
+ })(globalThis);
package/src/menu.tsx ADDED
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ // menu.tsx: the kit's action menu ("⋯") — a `<details>` disclosure, so it opens with no JavaScript and a
4
+ // server-rendered surface (GemFactory, gemcare) can print it as plain markup. The surface is the shell's
5
+ // `gemboss-popover-surface` and the rows share the account menu's row (shell.css), so every menu in the
6
+ // fleet looks the same (§9.3). Where React hydrates, the menu closes itself like AccountMenu does: outside
7
+ // click, Escape (focus back on the trigger), and a button row after its handler (review #3591: a React page
8
+ // had no clean way to close it). The catalog renders its example from these components.
9
+ import React, { useEffect, useRef } from "react";
10
+ import { Icon } from "./icons";
11
+
12
+ /** The trigger is an icon button (`label` is its accessible name). `end` aligns the list to the right edge. */
13
+ export function Menu({ label, icon, end, children, className }: {
14
+ label: string;
15
+ icon?: React.ReactNode;
16
+ end?: boolean;
17
+ children?: React.ReactNode;
18
+ className?: string;
19
+ }) {
20
+ const ref = useRef<HTMLDetailsElement>(null);
21
+ useEffect(() => {
22
+ const el = ref.current;
23
+ if (!el) return undefined;
24
+ const onDoc = (e: MouseEvent) => { if (el.open && !el.contains(e.target as Node)) el.open = false; };
25
+ const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && el.open) { el.open = false; el.querySelector("summary")?.focus(); } };
26
+ document.addEventListener("click", onDoc);
27
+ document.addEventListener("keydown", onKey);
28
+ return () => { document.removeEventListener("click", onDoc); document.removeEventListener("keydown", onKey); };
29
+ }, []);
30
+ return (
31
+ <details ref={ref} className={["ui-menu", end ? "ui-menu--end" : "", className ?? ""].filter(Boolean).join(" ")}>
32
+ <summary className="ui-btn ui-btn--icon" aria-label={label} title={label}>{icon ?? <Icon name="more" />}</summary>
33
+ <div className="ui-menu__list gemboss-popover-surface">{children}</div>
34
+ </details>
35
+ );
36
+ }
37
+
38
+ /** One row: a link (`href`) or a button. `hint` is the quiet text on the right; `danger` for destructive rows.
39
+ * A disabled link is not a link: no href, aria-disabled, out of the tab order (an `<a disabled>` still follows
40
+ * on Enter). A caller's `className` is added to the row's class, not put in its place. */
41
+ export function MenuItem({ href, danger, hint, children, className, disabled, onClick, ...rest }: {
42
+ href?: string;
43
+ danger?: boolean;
44
+ hint?: React.ReactNode;
45
+ children?: React.ReactNode;
46
+ disabled?: boolean;
47
+ onClick?: React.MouseEventHandler<HTMLElement>;
48
+ } & Omit<React.HTMLAttributes<HTMLElement>, "onClick"> & Pick<React.AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel">) {
49
+ const cls = ["ui-menu__item", danger ? "ui-menu__item--danger" : "", className ?? ""].filter(Boolean).join(" ");
50
+ const body = <>{children}{hint ? <span className="ui-menu__hint">{hint}</span> : null}</>;
51
+ if (href) {
52
+ if (disabled) return <a className={cls} aria-disabled="true" tabIndex={-1} {...rest}>{body}</a>;
53
+ return <a className={cls} href={href} onClick={onClick} {...rest}>{body}</a>;
54
+ }
55
+ const close: React.MouseEventHandler<HTMLButtonElement> = (e) => {
56
+ onClick?.(e);
57
+ e.currentTarget.closest("details")?.removeAttribute("open");
58
+ };
59
+ return <button type="button" className={cls} disabled={disabled} onClick={close} {...rest}>{body}</button>;
60
+ }
61
+
62
+ export function MenuSeparator() {
63
+ return <hr className="ui-menu__sep" />;
64
+ }
package/src/shell.css CHANGED
@@ -100,20 +100,24 @@
100
100
  /* Account dropdown menu items (avatar chip) — CSS-driven hover/focus, replaces inline JS handlers. */
101
101
  /* min-height 44 (28/09/2026): 12px padding around a 13px line measured 42.84px — under the 44px control
102
102
  floor (CLAUDE.md §9.1), in the admin's own menu and in every surface wearing it through @gemboss/ui. */
103
- .gemboss-account-menu-item {
103
+ .gemboss-account-menu-item,
104
+ .ui-menu__item {
104
105
  display: block; width: 100%; min-height: 44px; padding: 12px 16px; border: 0; background: transparent;
105
106
  font: inherit; font-size: 13px; color: var(--gemboss-text); text-align: left; text-decoration: none;
106
107
  cursor: pointer; transition: background var(--gemboss-transition-fast) ease, color var(--gemboss-transition-fast) ease;
107
108
  }
108
109
 
109
- .gemboss-account-menu-item:hover { background: var(--gemboss-hover); }
110
+ .gemboss-account-menu-item:hover,
111
+ .ui-menu__item:hover { background: var(--gemboss-hover); }
110
112
 
111
- .gemboss-account-menu-item:focus-visible { outline: 2px solid var(--gemboss-primary); outline-offset: -2px; background: var(--gemboss-hover); }
113
+ .gemboss-account-menu-item:focus-visible,
114
+ .ui-menu__item:focus-visible { outline: 2px solid var(--gemboss-primary); outline-offset: -2px; background: var(--gemboss-hover); }
112
115
 
113
116
  /* `--gemboss-danger` var(--gemboss-danger) is 4.25:1 on `--gemboss-hover` #eceef2, and this row takes that hover
114
117
  background two lines above — so the one moment the item is under the cursor is the moment its label
115
118
  drops below AA. `--gemboss-danger-strong` #b42318 is 5.66:1 there and 7.30:1 on white. */
116
- .gemboss-account-menu-item.danger { color: var(--gemboss-danger-strong); }
119
+ .gemboss-account-menu-item.danger,
120
+ .ui-menu__item--danger { color: var(--gemboss-danger-strong); }
117
121
 
118
122
  .gemboss-account-chip {
119
123
  width: 30px;
package/src/ui.css CHANGED
@@ -154,7 +154,7 @@
154
154
  background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
155
155
  }
156
156
  .ui-select:focus { outline: none; }
157
- .ui-select:focus-visible {
157
+ .ui-select:focus-visible:not(:disabled) {
158
158
  outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
159
159
  border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
160
160
  }
@@ -300,12 +300,35 @@ a.ui-metric:focus-visible { outline: 2px solid var(--ui-focus); outline-offset:
300
300
  consumer belongs in that consumer's chunk (see SettingsDialog.module.css). */
301
301
  .ui-modal--lg { position: relative; }
302
302
 
303
+ /* The kit's multi-line field joins the ONE field family (01/10/2026, Chris: "form control đồng bộ"):
304
+ same input-border token, hover and focus ring as `gemboss-field` and `.ui-select`. Before, it drew a
305
+ lighter --ui-border box at 14px with no focus ring, so on GemFactory's compose form a textarea sat
306
+ beside selects of a darker border and text fields of a third look (measured on prod). */
303
307
  .ui-textarea {
304
308
  width: 100%; min-height: 96px; resize: vertical; box-sizing: border-box;
305
- background: var(--ui-surface); border: 1px solid var(--ui-border); border-radius: var(--ui-radius-md);
306
- padding: 12px; color: var(--ui-text); font-size: 14px; font-family: inherit; outline: none;
309
+ background: var(--p-color-input-bg-surface, var(--gemboss-field-bg));
310
+ border: 1px solid var(--p-color-input-border, var(--gemboss-field-line)); border-radius: var(--ui-radius-md);
311
+ padding: 12px; color: var(--p-color-text, var(--gemboss-field-ink)); font-size: var(--ui-fs-body); line-height: 1.5; font-family: inherit;
312
+ }
313
+ .ui-textarea::placeholder { color: var(--p-color-text-secondary, var(--gemboss-field-placeholder)); }
314
+ .ui-textarea:hover:not(:disabled) {
315
+ border-color: var(--p-color-input-border-hover, var(--gemboss-field-line-hover));
316
+ background-color: var(--p-color-input-bg-surface-hover, var(--gemboss-field-bg-hover));
317
+ }
318
+ .ui-textarea:focus { outline: none; }
319
+ .ui-textarea:focus-visible:not(:disabled) {
320
+ outline: 2px solid var(--p-color-border-focus, #005bd3); outline-offset: 1px;
321
+ border-color: var(--p-color-input-border-active, var(--gemboss-field-line-active));
322
+ }
323
+ .ui-textarea:disabled { opacity: .5; cursor: not-allowed; }
324
+ /* On a PHONE-WIDTH screen every field of the family reads 16px (iOS zooms the page on tap below that)
325
+ and a select is a 44px target — by WIDTH, as `gemboss-field` does, because a desktop browser at 320px
326
+ still reports pointer: fine. Measured on GemFactory at 390px before this: select 13px, textarea 14px. */
327
+ @media (max-width: 47.9975em) {
328
+ .ui-select.ui-select, .ui-textarea { font-size: 16px; }
329
+ /* Height only for the full select: `--slim` is a density decision (table rows), as the coarse block keeps it. */
330
+ .ui-select:not(.ui-select--slim) { min-height: 44px; }
307
331
  }
308
- .ui-textarea:focus { border-color: var(--ui-accent); }
309
332
 
310
333
  /* ── Segmented control ────────────────────────────────────────────────────────
311
334
  Grouped toggle: icon squares (.ui-seg button) or text tabs (.is-text). The