@gemboss/ui 0.2.1 → 0.4.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);