@gemboss/ui 0.48.0 → 0.50.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 +260 -0
- package/README.md +127 -11
- package/catalog/editor.html +1 -1
- package/catalog/index.html +10 -10
- package/gemboss-ui.css +3 -3
- package/package.json +22 -2
- package/parts/components.css +3 -3
- package/parts/core.css +1 -1
- package/parts/shell.css +2 -2
- package/src/app-shell.css +1 -1
- package/src/assistant/chat.tsx +50 -6
- package/src/assistant/host.tsx +19 -7
- package/src/assistant/index.ts +1 -1
- package/src/assistant/sessions.tsx +2 -1
- package/src/assistant-chat.css +1 -1
- package/src/assistant.css +1 -1
- package/src/auth-signin-plain.js +1 -0
- package/src/auth-sso-plain.js +1 -1
- package/src/auth.tsx +105 -8
- package/src/briefing-card.css +1 -1
- package/src/collections.css +1 -1
- package/src/create-shell.css +1 -0
- package/src/create-shell.tsx +221 -0
- package/src/editor-insert.css +1 -0
- package/src/editor.css +1 -1
- package/src/empty-stage.css +1 -0
- package/src/empty-stage.tsx +31 -0
- package/src/icons.tsx +2 -0
- package/src/index-table.tsx +7 -4
- package/src/index.ts +16 -2
- package/src/kit-plain.css +1 -1
- package/src/kit-plain.js +1 -1
- package/src/language-switcher.tsx +10 -41
- package/src/list-page.css +1 -0
- package/src/list-page.tsx +239 -0
- package/src/menu-extras.css +1 -0
- package/src/menu-keys.ts +199 -0
- package/src/menu-layer.ts +2 -1
- package/src/menu-plain.js +1 -0
- package/src/menu.css +1 -0
- package/src/menu.tsx +83 -35
- package/src/pager-extras.css +1 -0
- package/src/pager.css +1 -0
- package/src/pager.tsx +88 -0
- package/src/portal.css +1 -1
- package/src/segmented.tsx +12 -2
- package/src/selection.tsx +3 -1
- package/src/session-rail-plain.js +1 -1
- package/src/settings-dialog.tsx +29 -2
- package/src/shell.css +1 -1
- package/src/stage-path.css +1 -0
- package/src/stage-path.tsx +89 -0
- package/src/stage.css +1 -0
- package/src/stage.tsx +52 -0
- package/src/starting-point.css +1 -0
- package/src/starting-point.tsx +93 -0
- package/src/status-list.css +1 -0
- package/src/status-list.tsx +65 -0
- package/src/status-matrix.css +1 -0
- package/src/status-matrix.tsx +131 -0
- package/src/team-chat.css +1 -1
- package/src/translation-row.css +1 -0
- package/src/translation-row.tsx +113 -0
- package/src/ui.css +1 -1
package/src/menu-keys.ts
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* menu-keys.ts (23/10/2026): what a kit menu (`details.ui-menu`) does from the keyboard, and the page-wide rules every
|
|
3
|
+
* React menu shares. Used by `Menu` (menu.tsx) and `LanguageSwitcher`; menu-plain.js is the plain-HTML copy, and
|
|
4
|
+
* menu-browser.test.ts runs one script against both, so they cannot drift apart.
|
|
5
|
+
*
|
|
6
|
+
* - The list is `role="menu"` named by the trigger's label, its rows `role="menuitem"` out of the Tab order (tabindex -1):
|
|
7
|
+
* the arrows move between them, not Tab. `armMenu` adds whatever hand-written markup leaves out.
|
|
8
|
+
* - An opening by a click, Enter or Space on the trigger, or ↓, puts focus on the first row that can act; ↑ on the last.
|
|
9
|
+
* - On a ROW (a field or a checkbox in the list keeps its own keys): ↓ ↑ move to the next / previous row that can act and
|
|
10
|
+
* wrap; a disabled row (`disabled`, `aria-disabled="true"`) is skipped. Home / End: the first / last. Enter and Space
|
|
11
|
+
* act on the row (a button does both natively, a link only Enter, so Space clicks a link row here).
|
|
12
|
+
* - Escape closes the menu and puts focus back on the trigger; it is taken (default prevented, propagation stopped), so a
|
|
13
|
+
* popup holding the menu stays open. Tab closes it and lets focus go on from the trigger, forward or back.
|
|
14
|
+
* - One menu open at a time: opening one closes every other open `details.ui-menu` on the page, however it was opened
|
|
15
|
+
* (a click, a key, a script), not only by a click outside, which a key never is. A menu that holds it stays open.
|
|
16
|
+
* - ONE set of page-wide listeners for every mounted React menu (`registerMenu`): a click outside closes it, Escape closes
|
|
17
|
+
* it with focus back on its trigger, its toggle floats or sinks it. A 100-row table has 100 Menus, not 300 listeners.
|
|
18
|
+
*/
|
|
19
|
+
import { canFloatMenus, floatMenu, partsOf, sinkMenu } from "./menu-layer";
|
|
20
|
+
|
|
21
|
+
/** A row of a kit menu. */
|
|
22
|
+
export const MENU_ROW = ".ui-menu__item";
|
|
23
|
+
const MENU = "details.ui-menu";
|
|
24
|
+
|
|
25
|
+
/** The rows the keys land on: in the list, not disabled. */
|
|
26
|
+
export function menuRows(d: HTMLDetailsElement, selector = MENU_ROW): HTMLElement[] {
|
|
27
|
+
const { list } = partsOf(d);
|
|
28
|
+
if (!list) return [];
|
|
29
|
+
return Array.from(list.querySelectorAll<HTMLElement>(selector)).filter((r) => !r.matches(':disabled, [aria-disabled="true"]'));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Gives a menu what `Menu` renders, where the markup has not said it already: `aria-haspopup` on the trigger, the list a
|
|
33
|
+
* `menu` named by the trigger's label, each row a `menuitem` out of the Tab order. Never overwrites (LanguageSwitcher's
|
|
34
|
+
* rows are `menuitemradio`). */
|
|
35
|
+
export function armMenu(d: HTMLDetailsElement): void {
|
|
36
|
+
const { button, list } = partsOf(d);
|
|
37
|
+
if (button && !button.hasAttribute("aria-haspopup")) button.setAttribute("aria-haspopup", "menu");
|
|
38
|
+
if (!list) return;
|
|
39
|
+
if (!list.hasAttribute("role")) list.setAttribute("role", "menu");
|
|
40
|
+
if (!list.hasAttribute("aria-label") && !list.hasAttribute("aria-labelledby")) {
|
|
41
|
+
const name = button ? button.getAttribute("aria-label") || (button.textContent ?? "").trim() : "";
|
|
42
|
+
if (name) list.setAttribute("aria-label", name);
|
|
43
|
+
}
|
|
44
|
+
list.querySelectorAll<HTMLElement>(MENU_ROW).forEach((r) => {
|
|
45
|
+
if (!r.hasAttribute("role")) r.setAttribute("role", "menuitem");
|
|
46
|
+
if (!r.hasAttribute("tabindex")) r.setAttribute("tabindex", "-1");
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Closes every other open kit menu (not one that holds `d`). */
|
|
51
|
+
export function closeOtherMenus(d: HTMLDetailsElement): void {
|
|
52
|
+
document.querySelectorAll<HTMLDetailsElement>(`${MENU}[open]`).forEach((o) => {
|
|
53
|
+
if (o !== d && !o.contains(d)) o.open = false;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Menus an opening has already closed the others for: the page-wide toggle listener skips them once, so the others are
|
|
58
|
+
// closed once per opening, in the task of the click or key that opened it.
|
|
59
|
+
const closedFor = new WeakSet<HTMLDetailsElement>();
|
|
60
|
+
|
|
61
|
+
/** Opens a menu the way a click on its trigger does: the others close, it floats in the same task where it can. */
|
|
62
|
+
export function openMenu(d: HTMLDetailsElement): void {
|
|
63
|
+
// (Already open: no toggle will come to use up the mark.)
|
|
64
|
+
if (!d.open) {
|
|
65
|
+
closeOtherMenus(d);
|
|
66
|
+
closedFor.add(d);
|
|
67
|
+
d.open = true;
|
|
68
|
+
}
|
|
69
|
+
armMenu(d);
|
|
70
|
+
if (canFloatMenus()) floatMenu(d);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Closes it; `focusTrigger` puts focus back on its button. */
|
|
74
|
+
export function closeMenu(d: HTMLDetailsElement, focusTrigger = true): void {
|
|
75
|
+
d.open = false;
|
|
76
|
+
if (focusTrigger) partsOf(d).button?.focus();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Focus where an opening lands: the first row that can act, or the last. */
|
|
80
|
+
export function focusRow(d: HTMLDetailsElement, which: "first" | "last", selector = MENU_ROW): void {
|
|
81
|
+
const rows = menuRows(d, selector);
|
|
82
|
+
(which === "first" ? rows[0] : rows[rows.length - 1])?.focus();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const mounted = new Set<HTMLDetailsElement>();
|
|
86
|
+
let wired = false;
|
|
87
|
+
/** The page-wide listeners, once: the one-open rule for every `details.ui-menu` (React or hand-written), and for the
|
|
88
|
+
* menus `registerMenu` holds, the outside click, Escape and the float. */
|
|
89
|
+
export function listenForMenus(): void {
|
|
90
|
+
if (wired || typeof document === "undefined") return;
|
|
91
|
+
wired = true;
|
|
92
|
+
const openMounted = () => Array.from(document.querySelectorAll<HTMLDetailsElement>(`${MENU}[open]`)).filter((d) => mounted.has(d));
|
|
93
|
+
document.addEventListener("click", (e) => {
|
|
94
|
+
for (const d of openMounted()) if (!d.contains(e.target as Node)) d.open = false;
|
|
95
|
+
});
|
|
96
|
+
document.addEventListener("keydown", (e) => {
|
|
97
|
+
// An Escape something else already took (a dialog over the page, a combobox) is not the menus' (review of #5179).
|
|
98
|
+
if (e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
|
|
99
|
+
const open = openMounted().reverse();
|
|
100
|
+
if (!open.length) return;
|
|
101
|
+
// Taken (default prevented), as kit-plain.js takes it: a dialog listening after this one, on the document or the window,
|
|
102
|
+
// must not close on the Escape that closed the menu. Focus on the ⋯ of a menu whose rows are all disabled closed the
|
|
103
|
+
// menu AND the admin's Modal or funnel popup around it (review of the admin's step 2, measured in Chromium): the menu
|
|
104
|
+
// was already shut when the dialog looked.
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
// Innermost first, so focus ends on the outermost trigger that stays on screen.
|
|
107
|
+
for (const d of open) closeMenu(d);
|
|
108
|
+
});
|
|
109
|
+
document.addEventListener("toggle", (e) => {
|
|
110
|
+
const d = e.target as HTMLDetailsElement | null;
|
|
111
|
+
if (!d || typeof d.matches !== "function" || !d.matches(MENU)) return;
|
|
112
|
+
// A close uses up the mark too: an opening closed again before its toggle came left it, and the next opening skipped
|
|
113
|
+
// the one-open rule (review of #5179).
|
|
114
|
+
if (!d.open) { closedFor.delete(d); if (mounted.has(d)) sinkMenu(d); return; }
|
|
115
|
+
if (!closedFor.delete(d)) closeOtherMenus(d);
|
|
116
|
+
if (mounted.has(d)) {
|
|
117
|
+
if (canFloatMenus()) floatMenu(d);
|
|
118
|
+
armMenu(d);
|
|
119
|
+
}
|
|
120
|
+
}, true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A mounted React menu joins the page-wide listeners; the return value takes it out (the effect's cleanup). */
|
|
124
|
+
export function registerMenu(d: HTMLDetailsElement): () => void {
|
|
125
|
+
listenForMenus();
|
|
126
|
+
mounted.add(d);
|
|
127
|
+
return () => { mounted.delete(d); };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A keydown, native or React's. */
|
|
131
|
+
export interface MenuKeyEvent {
|
|
132
|
+
key: string;
|
|
133
|
+
target: EventTarget | null;
|
|
134
|
+
defaultPrevented: boolean;
|
|
135
|
+
preventDefault(): void;
|
|
136
|
+
stopPropagation(): void;
|
|
137
|
+
isComposing?: boolean;
|
|
138
|
+
nativeEvent?: { isComposing?: boolean };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface MenuKeyOptions {
|
|
142
|
+
/** What a row is (default `.ui-menu__item`). Only a row's keys are the menu's. */
|
|
143
|
+
row?: string;
|
|
144
|
+
/** Where ↓ / ↑ on the trigger put focus (default: the first / the last row). LanguageSwitcher: the checked row. */
|
|
145
|
+
entry?: (d: HTMLDetailsElement, which: "first" | "last") => void;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The menu's keys, for a keydown inside `d` (its trigger or one of its rows). True when it took the key. */
|
|
149
|
+
export function menuKeyDown(d: HTMLDetailsElement, e: MenuKeyEvent, o: MenuKeyOptions = {}): boolean {
|
|
150
|
+
if (e.defaultPrevented || e.isComposing || e.nativeEvent?.isComposing) return false;
|
|
151
|
+
const { button, list } = partsOf(d);
|
|
152
|
+
const t = e.target as HTMLElement | null;
|
|
153
|
+
// A key in a menu inside this one is that menu's.
|
|
154
|
+
if (!t || !button || !list || t.closest(MENU) !== d) return false;
|
|
155
|
+
const row = o.row ?? MENU_ROW;
|
|
156
|
+
const entry = o.entry ?? ((m, which) => focusRow(m, which, row));
|
|
157
|
+
if (t === button) {
|
|
158
|
+
// Tab from the button of an open menu: its rows are out of the Tab order, so focus would go on with the list still over
|
|
159
|
+
// the page. It closes, and focus goes on (not prevented), forward or back (review of #5179).
|
|
160
|
+
if (e.key === "Tab" && d.open) { closeMenu(d, false); return true; }
|
|
161
|
+
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return false;
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
if (!d.open) openMenu(d);
|
|
164
|
+
entry(d, e.key === "ArrowUp" ? "last" : "first");
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
if (!d.open || !t.matches(row) || !list.contains(t)) return false;
|
|
168
|
+
if (e.key === "Escape") {
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
e.stopPropagation();
|
|
171
|
+
closeMenu(d);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
// Tab leaves: the menu closes and focus goes on from its button (not prevented), forward or back.
|
|
175
|
+
if (e.key === "Tab") { closeMenu(d); return true; }
|
|
176
|
+
const rows = menuRows(d, row);
|
|
177
|
+
const i = rows.indexOf(t);
|
|
178
|
+
if (e.key === " " && t.tagName === "A" && i > -1) { e.preventDefault(); t.click(); return true; }
|
|
179
|
+
const n = rows.length;
|
|
180
|
+
if (!n) return false;
|
|
181
|
+
const next = e.key === "ArrowDown" ? (i + 1) % n
|
|
182
|
+
: e.key === "ArrowUp" ? (i < 1 ? n - 1 : i - 1)
|
|
183
|
+
: e.key === "Home" ? 0 : e.key === "End" ? n - 1 : -1;
|
|
184
|
+
if (next < 0) return false;
|
|
185
|
+
e.preventDefault();
|
|
186
|
+
rows[next].focus();
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** A row was chosen (its click): focus goes back on the trigger BEFORE the row's own handler, so a popup it opens returns
|
|
191
|
+
* focus there and not to a row about to be hidden; then the menu closes. Only when focus is in the menu: a row's handler
|
|
192
|
+
* that moved focus on purpose keeps it. Returns the close to run after the handler. */
|
|
193
|
+
export function chooseRow(row: Element): () => void {
|
|
194
|
+
const d = row.closest<HTMLDetailsElement>(MENU);
|
|
195
|
+
if (!d) return () => {};
|
|
196
|
+
const { button } = partsOf(d);
|
|
197
|
+
if (button && d.contains(document.activeElement)) button.focus();
|
|
198
|
+
return () => { d.open = false; };
|
|
199
|
+
}
|
package/src/menu-layer.ts
CHANGED
|
@@ -24,7 +24,8 @@ export function canFloatMenus(): boolean {
|
|
|
24
24
|
return typeof HTMLElement !== "undefined" && Object.prototype.hasOwnProperty.call(HTMLElement.prototype, "popover");
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/** A kit menu's two parts: its list and its button (the summary). menu-keys.ts reads them through this too. */
|
|
28
|
+
export const partsOf = (d: HTMLDetailsElement) => ({
|
|
28
29
|
list: d.querySelector<HTMLElement>(":scope > .ui-menu__list"),
|
|
29
30
|
button: d.querySelector<HTMLElement>(":scope > summary"),
|
|
30
31
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(m){"use strict";if(typeof document>"u")return;var l="details.ui-menu",d=".ui-menu__item",b=!1,a=null,s=function(e){return{list:e.querySelector(":scope > .ui-menu__list"),button:e.querySelector(":scope > summary")}},g=function(e){var t=m.gembossUI;t&&t.floatMenu&&t.floatMenu(e)},p=function(e,t){var n=e&&e.closest&&e.closest(t);return n&&n.closest(l)};function h(e){var t=s(e).list;return t?[].filter.call(t.querySelectorAll(d),function(n){return!n.matches(':disabled,[aria-disabled="true"]')}):[]}function c(e){var t=s(e),n=t.button,r=t.list,u=function(i,o,f){i&&f&&!i.hasAttribute(o)&&i.setAttribute(o,f)};u(n,"aria-haspopup","menu"),r&&(u(r,"role","menu"),r.hasAttribute("aria-labelledby")||u(r,"aria-label",n&&(n.getAttribute("aria-label")||n.textContent.trim())),r.querySelectorAll(d).forEach(function(i){u(i,"role","menuitem"),u(i,"tabindex","-1")}))}function y(e){document.querySelectorAll(l+"[open]").forEach(function(t){t!==e&&!t.contains(e)&&(t.open=!1)})}function A(e,t){var n=h(e);n.length&&n[t?n.length-1:0].focus()}function w(e){e.open=!1,s(e).button.focus()}var v=function(e){return e.matches(".gb-lang")};function E(){b||(b=!0,addEventListener("focusin",function(e){var t=p(e.target,l+" > summary");t&&c(t)},!0),addEventListener("click",function(e){var t=p(e.target,l+" > summary");t&&v(t)||(a=t&&!t.open?t:null,t&&c(t))},!0),addEventListener("click",function(e){a&&e.defaultPrevented&&!a.open&&(a=null)}),document.addEventListener("toggle",function(e){var t=e.target,n;if(!(!t.matches||!t.matches(l))){if(!t.open){a===t&&(a=null);return}y(t),!v(t)&&(c(t),n=s(t).list,a===t&&(a=null,g(t),n&&!n.contains(document.activeElement)&&A(t)))}},!0),document.addEventListener("keydown",function(e){var t=e.target,n=e.key,r=!e.defaultPrevented&&!e.isComposing&&p(t,l),u=r&&s(r);if(!(!u||!u.list||!u.button||v(r))){if(t===u.button){if(n==="Tab"&&r.open){r.open=!1;return}return n!=="ArrowDown"&&n!=="ArrowUp"?void 0:(e.preventDefault(),r.open||(y(r),r.open=!0,c(r),g(r)),A(r,n==="ArrowUp"))}if(!(!r.open||!t.matches(d)||!u.list.contains(t))){if(n==="Escape")return e.preventDefault(),e.stopPropagation(),w(r);if(n==="Tab")return w(r);var i=h(r),o=i.indexOf(t),f=i.length;if(n===" "&&t.tagName==="A"&&o>-1)return e.preventDefault(),t.click();o=n==="ArrowDown"?(o+1)%f:n==="ArrowUp"?(o<1?f:o)-1:n==="Home"?0:n==="End"?f-1:-1,!(o<0||!f)&&(e.preventDefault(),i[o].focus())}}}))}E(),m.gembossUI&&(m.gembossUI.initMenuKeys=E)})(globalThis);
|
package/src/menu.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ui-btn--icon{flex:none;width:var(--ui-btn-h);padding:0}.ui-btn--icon svg{width:18px;height:18px}.ui-menu{position:relative;display:inline-block}.ui-menu>summary{list-style:none;cursor:pointer}.ui-menu>summary::-webkit-details-marker{display:none}.ui-menu__list{position:absolute;z-index:20;top:calc(100% + 4px);left:0;display:grid;min-width:220px;padding:6px;box-sizing:border-box}.ui-menu--end .ui-menu__list{left:auto;right:0}.ui-menu__list[popover]{position:fixed;inset:auto;margin:0;overflow:auto}.ui-menu__list[popover]:not(:popover-open){display:none}:where(.ui-menu__list[popover]){border:0;background:none;color:inherit}:root[data-gb-menus=float] .ui-menu[open]>.ui-menu__list:not([popover]){visibility:hidden}.ui-menu__item:is([disabled],[aria-disabled=true]){opacity:.5;cursor:not-allowed;pointer-events:none}.ui-menu__hint{margin-left:auto;padding-left:var(--ui-space-4);color:var(--ui-text-muted);font-size:var(--ui-fs-caption)}.ui-menu__sep{height:1px;margin:var(--ui-space-1) 0;border:0;background:var(--ui-border)}@media (pointer: coarse){.ui-btn--icon:not(.ui-btn--sm){min-width:44px}}@media (max-width: 47.9975em){.ui-btn--icon:not(.ui-btn--sm){min-width:44px;min-height:44px}}
|
package/src/menu.tsx
CHANGED
|
@@ -8,64 +8,96 @@
|
|
|
8
8
|
// had no clean way to close it). The catalog renders its example from these components.
|
|
9
9
|
// 0.18.0: the open list floats in the top layer (./menu-layer), so a table's scrolling frame no longer cuts it; it
|
|
10
10
|
// closes when the page scrolls or the window resizes.
|
|
11
|
+
// 23/10/2026: a menu's semantics and keys (./menu-keys, shared with LanguageSwitcher; menu-plain.js is the plain twin): the
|
|
12
|
+
// list is `role="menu"` named by `label`, rows are `menuitem`s out of the Tab order, an opening puts focus on the first row
|
|
13
|
+
// that can act, ↑ ↓ (wrapping, skipping disabled rows) Home End move, Escape closes with focus back on ⋯, Tab closes and
|
|
14
|
+
// goes on; one menu open at a time. Its CSS is its own file (./menu.css), so a surface without the core (the admin) draws it.
|
|
15
|
+
import "./menu.css";
|
|
16
|
+
import "./menu-extras.css";
|
|
11
17
|
import React, { useEffect, useRef } from "react";
|
|
12
18
|
import { Icon } from "./icons";
|
|
13
19
|
import { canFloatMenus, floatMenu, listenForDetachedMenus, sinkMenu } from "./menu-layer";
|
|
20
|
+
import { chooseRow, closeMenu, focusRow, menuKeyDown, menuRows, openMenu, registerMenu } from "./menu-keys";
|
|
14
21
|
|
|
15
|
-
/** The trigger is an icon button (`label` is its accessible name). `end` aligns the list to the right edge.
|
|
16
|
-
|
|
22
|
+
/** The trigger is an icon button (`label` is its accessible name, and the menu's). `end` aligns the list to the right edge.
|
|
23
|
+
* `disabled`: the ⋯ takes no click and no key and leaves the Tab order (a row's work runs). `loading`: a ring in place of the
|
|
24
|
+
* dots, `aria-busy`, and disabled the same way (what it started runs: a rename, a duplicate). */
|
|
25
|
+
export function Menu({ label, icon, end, children, className, disabled, loading }: {
|
|
17
26
|
label: string;
|
|
18
27
|
icon?: React.ReactNode;
|
|
19
28
|
end?: boolean;
|
|
20
29
|
children?: React.ReactNode;
|
|
21
30
|
className?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
loading?: boolean;
|
|
22
33
|
}) {
|
|
23
34
|
const ref = useRef<HTMLDetailsElement>(null);
|
|
35
|
+
const off = Boolean(disabled || loading);
|
|
36
|
+
// Turned off while open (its work started from a row): it closes, and focus that was in it goes back to ⋯, not to the page
|
|
37
|
+
// (review of the admin's step 2: on a row the close hid, the next Tab started from the top).
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const el = ref.current;
|
|
40
|
+
if (off && el?.open) { closeMenu(el, el.contains(document.activeElement)); sinkMenu(el); }
|
|
41
|
+
}, [off]);
|
|
24
42
|
useEffect(() => {
|
|
25
43
|
const el = ref.current;
|
|
26
44
|
if (!el) return undefined;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
document.addEventListener("click", onDoc);
|
|
32
|
-
document.addEventListener("keydown", onKey);
|
|
33
|
-
if (float) {
|
|
34
|
-
el.addEventListener("toggle", onToggle);
|
|
35
|
-
// One page-wide pair of listeners for all menus (a 100-row table has 100 Menus), not one per menu.
|
|
45
|
+
// The outside click, Escape (focus back on ⋯), the float on its toggle and one menu open at a time: one page-wide set
|
|
46
|
+
// of listeners for every Menu (./menu-keys registerMenu), not three per menu.
|
|
47
|
+
const off = registerMenu(el);
|
|
48
|
+
if (canFloatMenus()) {
|
|
36
49
|
listenForDetachedMenus();
|
|
37
50
|
// Opened before this effect ran (a click during hydration): float it now.
|
|
38
51
|
if (el.open) floatMenu(el);
|
|
39
52
|
}
|
|
40
|
-
return
|
|
41
|
-
document.removeEventListener("click", onDoc);
|
|
42
|
-
document.removeEventListener("keydown", onKey);
|
|
43
|
-
el.removeEventListener("toggle", onToggle);
|
|
44
|
-
};
|
|
53
|
+
return off;
|
|
45
54
|
}, []);
|
|
46
|
-
// The summary
|
|
47
|
-
//
|
|
48
|
-
// would hide a hand-written `details.ui-menu` on a React page that
|
|
55
|
+
// The summary opens the menu itself, floats it in the same task (so the list is never painted inside the frame first:
|
|
56
|
+
// the details' own `toggle` event comes a task later) and puts focus on its first row, with or without the popover API.
|
|
57
|
+
// Nothing page-wide: kit-plain.js's `data-gb-menus` rule would hide a hand-written `details.ui-menu` on a React page that
|
|
58
|
+
// nobody floats.
|
|
49
59
|
const onSummary = (e: React.MouseEvent<HTMLElement>) => {
|
|
50
60
|
const el = ref.current;
|
|
51
|
-
if (!el
|
|
61
|
+
if (!el) return;
|
|
52
62
|
e.preventDefault();
|
|
53
|
-
|
|
54
|
-
if (el.open)
|
|
63
|
+
if (off) return;
|
|
64
|
+
if (el.open) { el.open = false; sinkMenu(el); return; }
|
|
65
|
+
openMenu(el);
|
|
66
|
+
enter(el, "first");
|
|
55
67
|
};
|
|
56
68
|
return (
|
|
57
|
-
<details ref={ref} className={["ui-menu", end ? "ui-menu--end" : "", className ?? ""].filter(Boolean).join(" ")}
|
|
58
|
-
|
|
59
|
-
|
|
69
|
+
<details ref={ref} className={["ui-menu", end ? "ui-menu--end" : "", className ?? ""].filter(Boolean).join(" ")}
|
|
70
|
+
onKeyDown={(e) => { if (ref.current && !off) menuKeyDown(ref.current, e, { entry: enter }); }}
|
|
71
|
+
// Opened while off by anything but its button (find-in-page opens a closed details that holds a match, a script):
|
|
72
|
+
// closed again.
|
|
73
|
+
onToggle={(e) => { if (off && e.currentTarget.open) e.currentTarget.open = false; }}>
|
|
74
|
+
<summary className="ui-btn ui-btn--icon" aria-label={label} title={label} aria-haspopup="menu" onClick={onSummary}
|
|
75
|
+
aria-disabled={off || undefined} aria-busy={loading || undefined} tabIndex={off ? -1 : undefined}>
|
|
76
|
+
{loading ? <span className="ui-menu__busy" aria-hidden="true" /> : (icon ?? <Icon name="more" />)}
|
|
77
|
+
</summary>
|
|
78
|
+
<div className="ui-menu__list gemboss-popover-surface" role="menu" aria-label={label}>{children}</div>
|
|
60
79
|
</details>
|
|
61
80
|
);
|
|
62
81
|
}
|
|
63
82
|
|
|
64
83
|
/** One row: a link (`href`) or a button. `hint` is the quiet text on the right; `danger` for destructive rows.
|
|
65
84
|
* A disabled link is not a link: no href, aria-disabled, out of the tab order (an `<a disabled>` still follows
|
|
66
|
-
* on Enter). A caller's `className` is added to the row's class, not put in its place.
|
|
67
|
-
|
|
85
|
+
* on Enter). A caller's `className` is added to the row's class, not put in its place. `linkAs` draws a link row with
|
|
86
|
+
* the app's link (Next's Link), so a row and a button with the same URL navigate the same way (ListPage). */
|
|
87
|
+
/** Where an opening puts focus: a menu whose rows are all choices (MenuItem `checked`) opens on the one that is on, as
|
|
88
|
+
* LanguageSwitcher does; any other menu on its first (or, from ↑, its last) row. */
|
|
89
|
+
function enter(el: HTMLDetailsElement, which: "first" | "last"): void {
|
|
90
|
+
const rows = menuRows(el);
|
|
91
|
+
const on = rows.length > 0 && rows.every((r) => r.getAttribute("role") === "menuitemradio")
|
|
92
|
+
? rows.find((r) => r.getAttribute("aria-checked") === "true") : undefined;
|
|
93
|
+
if (on) on.focus(); else focusRow(el, which);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function MenuItem({ href, danger, hint, children, className, disabled, onClick, linkAs, checked, ...rest }: {
|
|
68
97
|
href?: string;
|
|
98
|
+
/** A choice among others (in a MenuGroup): `menuitemradio`, a check before the one that is on. Left out: a plain row. */
|
|
99
|
+
checked?: boolean;
|
|
100
|
+
linkAs?: React.ElementType;
|
|
69
101
|
danger?: boolean;
|
|
70
102
|
hint?: React.ReactNode;
|
|
71
103
|
children?: React.ReactNode;
|
|
@@ -73,16 +105,32 @@ export function MenuItem({ href, danger, hint, children, className, disabled, on
|
|
|
73
105
|
onClick?: React.MouseEventHandler<HTMLElement>;
|
|
74
106
|
} & Omit<React.HTMLAttributes<HTMLElement>, "onClick"> & Pick<React.AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel">) {
|
|
75
107
|
const cls = ["ui-menu__item", danger ? "ui-menu__item--danger" : "", className ?? ""].filter(Boolean).join(" ");
|
|
76
|
-
const
|
|
108
|
+
const choice = checked === undefined ? { role: "menuitem" } : { role: "menuitemradio", "aria-checked": checked };
|
|
109
|
+
const body = <>{checked === undefined ? null : <span className="ui-menu__check" aria-hidden="true"><Icon name="check" size={16} /></span>}
|
|
110
|
+
{children}{hint ? <span className="ui-menu__hint">{hint}</span> : null}</>;
|
|
111
|
+
// Every row closes its menu once chosen, a link too (a new tab, an anchor, a client router leave the page as it was).
|
|
112
|
+
// Focus is back on ⋯ before the row's handler runs (./menu-keys chooseRow).
|
|
113
|
+
const choose: React.MouseEventHandler<HTMLElement> = (e) => {
|
|
114
|
+
const close = chooseRow(e.currentTarget);
|
|
115
|
+
try { onClick?.(e); } finally { close(); }
|
|
116
|
+
};
|
|
77
117
|
if (href) {
|
|
78
|
-
if (disabled) return <a className={cls} aria-disabled="true" tabIndex={-1} {...rest}>{body}</a>;
|
|
79
|
-
|
|
118
|
+
if (disabled) return <a className={cls} {...choice} aria-disabled="true" tabIndex={-1} {...rest}>{body}</a>;
|
|
119
|
+
const A = linkAs ?? "a";
|
|
120
|
+
return <A className={cls} href={href} {...choice} tabIndex={-1} onClick={choose} {...rest}>{body}</A>;
|
|
80
121
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
122
|
+
return <button type="button" className={cls} {...choice} tabIndex={-1} disabled={disabled} onClick={choose} {...rest}>{body}</button>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Rows that are choices of one kind (Products' Auto-refresh: Daily, Off), named: `role="group"` with the title as its name,
|
|
126
|
+
* and the title shown above them. Its rows are MenuItems with `checked`. */
|
|
127
|
+
export function MenuGroup({ title, children }: { title: string; children?: React.ReactNode }) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="ui-menu__group" role="group" aria-label={title}>
|
|
130
|
+
<div className="ui-menu__group-title" aria-hidden="true">{title}</div>
|
|
131
|
+
{children}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
86
134
|
}
|
|
87
135
|
|
|
88
136
|
export function MenuSeparator() {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ui-pager>button{display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;gap:4px;min-width:32px;min-height:32px;padding:0 12px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-md);background:none;color:var(--ui-text);font:inherit;font-size:var(--ui-fs-body);cursor:pointer}.ui-pager>button:hover{background:var(--ui-surface-2)}.ui-pager>button:focus-visible{outline:2px solid var(--p-color-border-focus, var(--ui-focus));outline-offset:1px}.ui-pager>button[aria-current=page]{background:var(--ui-text);border-color:var(--ui-text);color:var(--ui-surface);font-size:var(--ui-fs-body);font-weight:600;cursor:default}.ui-pager>button[aria-disabled=true]{cursor:default}@media (pointer: coarse),(max-width: 47.9975em){.ui-pager>button{min-width:44px;min-height:44px}}@media (max-width: 35.9975em){.ui-pager>button:not([data-rel]):not([aria-current]){display:none}}@media (max-width: 35.9975em){.ui-pager>a[aria-disabled=true]:not([data-rel]):not([aria-current]){display:none}}.ui-pager.ui-pager--flush{margin-block:0}
|
package/src/pager.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ui-pager>a,.ui-pager>[aria-disabled=true]{min-width:32px;justify-content:center;box-sizing:border-box;gap:4px}.ui-pager>a[aria-current=page]{background:var(--ui-text);border-color:var(--ui-text);color:var(--ui-surface);font-size:var(--ui-fs-body);font-weight:600}.ui-pager>a[aria-current=page]:hover{background:var(--ui-text)}.ui-pager>[aria-disabled=true]{display:inline-flex;align-items:center;min-height:32px;padding:0 12px;border:1px solid var(--ui-border);border-radius:var(--ui-radius-md);color:var(--ui-text-muted);font-size:var(--ui-fs-body);opacity:.55;cursor:default}.ui-pager>[aria-disabled=true]:hover{background:none}.ui-pager__gap{min-width:16px;text-align:center;color:var(--ui-text-muted)}.ui-pager__range{margin-left:auto;color:var(--ui-text-muted);font-size:var(--ui-fs-body);font-variant-numeric:tabular-nums}@media (pointer: coarse),(max-width: 47.9975em){.ui-pager>a,.ui-pager>[aria-disabled=true]{min-width:44px;min-height:44px}}@media (max-width: 35.9975em){.ui-pager>a:not([rel]):not([aria-current]):not([aria-disabled]),.ui-pager__gap{display:none}}
|
package/src/pager.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import "./pager.css";
|
|
2
|
+
import "./pager-extras.css";
|
|
3
|
+
import React from "react";
|
|
4
|
+
|
|
5
|
+
// The numbered pager (README "Long lists", 24/10/2026): Previous · the pages around the current one · Next, and the range
|
|
6
|
+
// ("25–48 of 655") pushed right. Two ways to page:
|
|
7
|
+
// - `href(n)`: the position is in the URL (a work list people send each other). Links, drawn through `linkAs` (Next's Link);
|
|
8
|
+
// an edge that cannot move is a link with no href and aria-disabled, read as an unavailable link.
|
|
9
|
+
// - `onPage(n)`: the list pages in state (the admin's Products, whose search and filters are not in the URL either, so a
|
|
10
|
+
// page link would open page 3 of a different list). Buttons, the same look (pager-extras.css).
|
|
11
|
+
// Under 576px only Previous · the current page · Next stay and the range wraps under them.
|
|
12
|
+
|
|
13
|
+
/** The page numbers to show: the first, the last, the current one ± 1, and a gap only where it hides two pages or more (a gap
|
|
14
|
+
* hiding one page shows that page instead). */
|
|
15
|
+
export function pageWindow(page: number, count: number): (number | "gap")[] {
|
|
16
|
+
if (count < 1) return [];
|
|
17
|
+
const nums = [...new Set([1, count, page - 1, page, page + 1])].filter((n) => n >= 1 && n <= count).sort((a, b) => a - b);
|
|
18
|
+
const out: (number | "gap")[] = [];
|
|
19
|
+
nums.forEach((n, i) => {
|
|
20
|
+
const before = nums[i - 1];
|
|
21
|
+
if (before !== undefined && n - before === 2) out.push(before + 1);
|
|
22
|
+
else if (before !== undefined && n - before > 2) out.push("gap");
|
|
23
|
+
out.push(n);
|
|
24
|
+
});
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PagerCommon {
|
|
29
|
+
/** The current page, from 1. */
|
|
30
|
+
page: number;
|
|
31
|
+
pageCount: number;
|
|
32
|
+
/** "25–48 of 655": what is on screen, out of how many. */
|
|
33
|
+
range?: React.ReactNode;
|
|
34
|
+
/** The nav's name for a screen reader. */
|
|
35
|
+
label?: string;
|
|
36
|
+
/** A page is on its way: nothing moves until it lands (every page is unavailable, the current one stays marked). */
|
|
37
|
+
busy?: boolean;
|
|
38
|
+
previousLabel?: string;
|
|
39
|
+
nextLabel?: string;
|
|
40
|
+
/** In a stack that already spaces its children (a card's): no margin of its own. */
|
|
41
|
+
flush?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/** One way to page, never none (a pager with neither drew controls that did nothing; review of the Pager). */
|
|
44
|
+
export type PagerProps = PagerCommon & (
|
|
45
|
+
/** The position is in the URL: links, through the app's link. */
|
|
46
|
+
| { href: (page: number) => string; linkAs?: React.ElementType; onPage?: never }
|
|
47
|
+
/** The list pages in state: buttons. */
|
|
48
|
+
| { onPage: (page: number) => void; href?: never; linkAs?: never }
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export function Pager({ page, pageCount, range, href, onPage, linkAs, label = "Pages", busy = false, previousLabel = "Previous", nextLabel = "Next", flush = false }: PagerProps) {
|
|
52
|
+
const count = Math.max(1, pageCount);
|
|
53
|
+
const current = Math.min(Math.max(1, page), count);
|
|
54
|
+
const Link: React.ElementType = linkAs ?? "a";
|
|
55
|
+
const asLinks = Boolean(href);
|
|
56
|
+
const prev = <><span aria-hidden="true">‹</span> {previousLabel}</>;
|
|
57
|
+
const next = <>{nextLabel} <span aria-hidden="true">›</span></>;
|
|
58
|
+
|
|
59
|
+
// Previous / Next: an edge that cannot move (or a page on its way) says so, and does nothing.
|
|
60
|
+
const edge = (to: number, rel: "prev" | "next", body: React.ReactNode) => {
|
|
61
|
+
const off = busy || to < 1 || to > count;
|
|
62
|
+
// A closed edge keeps `data-rel` (a link with no href has no `rel`), so the phone rule keeps it beside the current page.
|
|
63
|
+
if (asLinks) return off
|
|
64
|
+
? <a key={rel} role="link" aria-disabled="true" data-rel={rel}>{body}</a>
|
|
65
|
+
: <Link key={rel} href={href!(to)} rel={rel}>{body}</Link>;
|
|
66
|
+
return <button key={rel} type="button" data-rel={rel} aria-disabled={off || undefined} onClick={off ? undefined : () => onPage?.(to)}>{body}</button>;
|
|
67
|
+
};
|
|
68
|
+
// A page number: the current one is marked and goes nowhere; while a page is on its way the others are unavailable (a link
|
|
69
|
+
// with no href, a button that says so), not live controls that do nothing.
|
|
70
|
+
const num = (n: number) => {
|
|
71
|
+
const here = n === current;
|
|
72
|
+
const off = busy && !here;
|
|
73
|
+
if (asLinks) return off
|
|
74
|
+
? <a key={n} role="link" aria-disabled="true" aria-label={`Page ${n}`}>{n}</a>
|
|
75
|
+
: <Link key={n} href={href!(n)} aria-label={`Page ${n}`} aria-current={here ? "page" : undefined}>{n}</Link>;
|
|
76
|
+
return <button key={n} type="button" aria-label={`Page ${n}`} aria-current={here ? "page" : undefined} aria-disabled={off || undefined}
|
|
77
|
+
onClick={here || off ? undefined : () => onPage?.(n)}>{n}</button>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<nav className={flush ? "ui-pager ui-pager--flush" : "ui-pager"} aria-label={label} aria-busy={busy || undefined}>
|
|
82
|
+
{edge(current - 1, "prev", prev)}
|
|
83
|
+
{pageWindow(current, count).map((n, i) => (n === "gap" ? <span key={`gap-${i}`} className="ui-pager__gap" aria-hidden="true">…</span> : num(n)))}
|
|
84
|
+
{edge(current + 1, "next", next)}
|
|
85
|
+
{range ? <span className="ui-pager__range">{range}</span> : null}
|
|
86
|
+
</nav>
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/portal.css
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
.gb-topnav{display:flex;align-items:stretch;gap:4px;min-width:0;height:100%;margin-left:12px;padding-left:12px;border-left:1px solid var(--gemboss-divider);overflow-x:auto;scrollbar-width:none}.gb-topnav::-webkit-scrollbar{display:none}.gb-topnav-link{position:relative;display:inline-flex;align-items:center;align-self:stretch;flex:0 0 auto;min-height:36px;padding:0 10px;border-radius:8px;color:var(--gemboss-text-muted);font-size:14px;font-weight:520;text-decoration:none;white-space:nowrap}.gb-topnav-link:hover{color:var(--gemboss-text-strong);background:var(--gemboss-hover)}.gb-topnav-link:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-topnav-link[aria-current=page]{color:var(--gemboss-text-strong);font-weight:640}.gb-topnav-link[aria-current=page]:after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;border-radius:2px;background:var(--gemboss-text-strong)}@media (pointer: coarse){.gb-topnav-link{min-height:44px}}.gemboss-platform-topbar:has(.gb-topnav):has(>.gemboss-topbar-center:empty){grid-template-columns:minmax(0,1fr) auto}.gemboss-platform-topbar:has(.gb-topnav)>.gemboss-topbar-center:empty{display:none}.gemboss-topbar-start:has(.gb-topnav){min-width:0;height:100%}.gemboss-topbar-start:has(.gb-topnav)>.gb-brand{flex:0 0 auto}@media (max-width: 760px){.gb-topnav{margin-left:8px;padding-left:8px}.gb-topnav-link{min-height:44px;padding:0 8px}}.gb-topnav-row{display:flex;align-items:stretch;gap:4px;min-width:0;height:100%}.gb-topnav-fold{display:none;position:relative;align-self:stretch}.gb-topnav-fold>summary{list-style:none;cursor:pointer;gap:4px;height:100%}.gb-topnav-fold>summary::-webkit-details-marker{display:none}.gb-topnav-fold-btn[aria-current=page]:after{display:none}.gb-topnav-chevron{flex:0 0 auto;transition:transform .15s ease}.gb-topnav-fold[open] .gb-topnav-chevron{transform:rotate(180deg)}.gb-topnav-pop{position:fixed;top:56px;left:8px;min-width:200px;padding:6px;display:grid;gap:2px;z-index:60}.gb-topnav-item{display:flex;align-items:center;min-height:44px;padding:0 12px;border-radius:8px;color:var(--gemboss-text);font-size:14px;font-weight:520;text-decoration:none}.gb-topnav-item:hover{background:var(--gemboss-hover)}.gb-topnav-item:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-topnav-item[aria-current=page]{color:var(--gemboss-active-fg);background:var(--gemboss-active-bg);font-weight:640}@media (max-width: 600px){.gb-topnav{overflow:visible}.gb-topnav-row{display:none}.gb-topnav-fold{display:flex}}@media (prefers-reduced-motion: reduce){.gb-topnav-chevron{transition:none}}.gb-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.gb-avatar{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;border-radius:50%;overflow:hidden;font-weight:700;line-height:1;letter-spacing:.01em;user-select:none}.gb-avatar--24{width:24px;height:24px;font-size:9.5px}.gb-avatar--32{width:32px;height:32px;font-size:11px}.gb-avatar--40{width:40px;height:40px;font-size:13px}.gb-avatar--56{width:56px;height:56px;font-size:18px}.gb-avatar-img{width:100%;height:100%;object-fit:cover;display:block}.gb-avatar[data-tone]{background:color-mix(in srgb,var(--gb-tone) 16%,#ffffff);color:color-mix(in srgb,var(--gb-tone) 70%,#000000)}.gb-avatar[data-tone="0"]{--gb-tone: #5661f5}.gb-avatar[data-tone="1"]{--gb-tone: #0a8f6a}.gb-avatar[data-tone="2"]{--gb-tone: #c26a04}.gb-avatar[data-tone="3"]{--gb-tone: #db2777}.gb-avatar[data-tone="4"]{--gb-tone: #0891b2}.gb-avatar[data-tone="5"]{--gb-tone: #7c3aed}.gb-avatar[data-tone="6"]{--gb-tone: #dc2626}.gb-avatar[data-tone="7"]{--gb-tone: #6b7280}.gb-leaderboard{display:grid;gap:6px;min-width:0}.gb-leaderboard-title{margin:0;display:flex;align-items:center;gap:6px;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--gemboss-text-muted)}.gb-leaderboard-caption{margin:-2px 0 2px;font-size:12px;color:var(--gemboss-text-muted)}.gb-leaderboard-list{list-style:none;margin:0;padding:0;display:grid;gap:0}.gb-leaderboard-link{display:flex;align-items:center;gap:10px;min-height:44px;padding:0 6px;border-radius:8px;color:var(--gemboss-text);text-decoration:none}a.gb-leaderboard-link:hover{background:var(--gemboss-hover)}a.gb-leaderboard-link:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-leaderboard-rank{width:20px;flex:0 0 auto;text-align:center;font-size:15px;line-height:1}.gb-leaderboard-rank.is-number{font-size:12px;font-weight:700;color:var(--gemboss-text-muted);font-variant-numeric:tabular-nums}.gb-leaderboard-name{flex:1 1 auto;min-width:0;font-size:13px;font-weight:560;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-leaderboard-value{flex:0 0 auto;font-size:13px;font-weight:650;color:var(--gemboss-text-muted);font-variant-numeric:tabular-nums}.gb-leaderboard-row.is-first .gb-leaderboard-value{color:#8a5a00;font-weight:750}.gb-leaderboard-row.is-current>.gb-leaderboard-link{background:var(--gemboss-active-bg)}.gb-leaderboard-badge{flex:0 1 auto;min-width:0;overflow:hidden}.gb-leaderboard-all{justify-self:center;display:inline-flex;align-items:center;min-height:24px;font-size:12px;font-weight:600;color:var(--gemboss-text-muted);text-decoration:none}.gb-leaderboard-all:hover{color:var(--gemboss-text-strong);text-decoration:underline}.gb-podium{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-items:end}.gb-podium-link{display:grid;justify-items:center;gap:6px;padding:14px 8px;border:1px solid var(--gemboss-divider);border-radius:12px;background:var(--gemboss-surface);color:var(--gemboss-text);text-decoration:none;text-align:center}a.gb-podium-link:hover{background:var(--gemboss-hover)}.gb-podium-slot{min-width:0}.gb-podium-slot.is-empty{visibility:hidden}.gb-podium-slot--1 .gb-podium-link{padding-top:22px;padding-bottom:20px;border-color:color-mix(in srgb,#d4a017 45%,var(--gemboss-divider))}.gb-podium-slot--2 .gb-podium-link{padding-bottom:12px}.gb-podium-slot--3 .gb-podium-link{padding-bottom:8px}.gb-podium-medal{font-size:22px;line-height:1}.gb-podium-name{max-width:100%;font-size:13px;font-weight:620;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-podium-value{font-size:15px;font-weight:750;font-variant-numeric:tabular-nums}.gb-podium-slot--1 .gb-podium-value{color:#8a5a00}.gb-podium-unit{font-size:11px;font-weight:500;color:var(--gemboss-text-muted)}.gb-podium-slot.is-current .gb-podium-link{border-color:var(--gemboss-text-strong);background:var(--gemboss-active-bg)}.gb-search-trigger,.gb-theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;flex:0 0 auto;border:0;border-radius:8px;background:transparent;color:var(--gemboss-text-muted);cursor:pointer}.gb-search-trigger:hover,.gb-theme-toggle:hover{background:var(--gemboss-hover);color:var(--gemboss-text-strong)}.gb-search-trigger:focus-visible,.gb-theme-toggle:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}@media (pointer: coarse),(max-width: 760px){.gb-search-trigger,.gb-theme-toggle{width:44px;height:44px}}.gb-search-field{text-decoration:none;cursor:text}.gb-search-field:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:1px}.gb-search-field-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--gemboss-fs-body);color:var(--gemboss-field-placeholder)}dialog.ui-modal.gb-search{width:min(640px,100%);margin-top:8vh;max-height:min(560px,calc(100dvh - 16vh))}.gb-search-box{position:relative;display:flex;align-items:center;border-bottom:1px solid var(--ui-border)}.gb-search-icon{position:absolute;left:18px;color:var(--ui-text-muted);pointer-events:none}.gb-search-input{flex:1 1 auto;min-width:0;height:52px;padding:0 18px 0 46px;border:0;background:transparent;color:var(--ui-text);font:inherit;font-size:15px;outline:none}.gb-search-input::-webkit-search-cancel-button{display:none}.gb-search-results{flex:1 1 auto;min-height:0;overflow:auto;padding:8px}.gb-search-section+.gb-search-section{margin-top:8px}.gb-search-section-title{padding:6px 10px;font-size:12px;font-weight:600;color:var(--ui-text-muted)}.gb-search-item{display:grid;gap:2px;min-height:44px;align-content:center;padding:6px 10px;border-radius:8px;color:var(--ui-text);text-decoration:none}.gb-search-item.is-active{background:var(--ui-surface-2)}.gb-search-item-title{font-size:14px;font-weight:520;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-search-item-meta{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--ui-text-muted)}.gb-search-empty{margin:0;padding:18px 18px 0;font-size:13px;color:var(--ui-text-muted)}.gb-search-empty:empty{padding:0}.gb-search-foot{display:flex;gap:16px;justify-content:flex-end;padding:8px 14px;border-top:1px solid var(--ui-border);font-size:12px;color:var(--ui-text-muted)}@media (max-width: 760px){dialog.ui-modal.gb-search{margin-top:12px}.gb-search-foot{display:none}}
|
|
1
|
+
.gb-topnav{display:flex;align-items:stretch;gap:4px;min-width:0;height:100%;margin-left:12px;padding-left:12px;border-left:1px solid var(--gemboss-divider);overflow-x:auto;scrollbar-width:none}.gb-topnav::-webkit-scrollbar{display:none}.gb-topnav-link{position:relative;display:inline-flex;align-items:center;align-self:stretch;flex:0 0 auto;min-height:36px;padding:0 10px;border-radius:8px;color:var(--gemboss-text-muted);font-size:14px;font-weight:520;text-decoration:none;white-space:nowrap}.gb-topnav-link:hover{color:var(--gemboss-text-strong);background:var(--gemboss-hover)}.gb-topnav-link:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-topnav-link[aria-current=page]{color:var(--gemboss-text-strong);font-weight:640}.gb-topnav-link[aria-current=page]:after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;border-radius:2px;background:var(--gemboss-text-strong)}@media (pointer: coarse){.gb-topnav-link{min-height:44px}}.gemboss-platform-topbar:has(.gb-topnav):has(>.gemboss-topbar-center:empty){grid-template-columns:minmax(0,1fr) auto}.gemboss-platform-topbar:has(.gb-topnav)>.gemboss-topbar-center:empty{display:none}.gemboss-topbar-start:has(.gb-topnav){min-width:0;height:100%}.gemboss-topbar-start:has(.gb-topnav)>.gb-brand{flex:0 0 auto}@media (max-width: 760px){.gb-topnav{margin-left:8px;padding-left:8px}.gb-topnav-link{min-height:44px;padding:0 8px}}.gb-topnav-row{display:flex;align-items:stretch;gap:4px;min-width:0;height:100%}.gb-topnav-fold{display:none;position:relative;align-self:stretch}.gb-topnav-fold>summary{list-style:none;cursor:pointer;gap:4px;height:100%}.gb-topnav-fold>summary::-webkit-details-marker{display:none}.gb-topnav-fold-btn[aria-current=page]:after{display:none}.gb-topnav-chevron{flex:0 0 auto;transition:transform .15s ease}.gb-topnav-fold[open] .gb-topnav-chevron{transform:rotate(180deg)}.gb-topnav-pop{position:fixed;top:56px;left:8px;min-width:200px;padding:6px;display:grid;gap:2px;z-index:60}.gb-topnav-item{display:flex;align-items:center;min-height:44px;padding:0 12px;border-radius:8px;color:var(--gemboss-text);font-size:14px;font-weight:520;text-decoration:none}.gb-topnav-item:hover{background:var(--gemboss-hover)}.gb-topnav-item:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-topnav-item[aria-current=page]{color:var(--gemboss-active-fg);background:var(--gemboss-active-bg);font-weight:640}@media (max-width: 600px){.gb-topnav{overflow:visible}.gb-topnav-row{display:none}.gb-topnav-fold{display:flex}}@media (prefers-reduced-motion: reduce){.gb-topnav-chevron{transition:none}}.gb-sr-only{position:absolute;left:0;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}.gb-avatar{display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;border-radius:50%;overflow:hidden;font-weight:700;line-height:1;letter-spacing:.01em;user-select:none}.gb-avatar--24{width:24px;height:24px;font-size:9.5px}.gb-avatar--32{width:32px;height:32px;font-size:11px}.gb-avatar--40{width:40px;height:40px;font-size:13px}.gb-avatar--56{width:56px;height:56px;font-size:18px}.gb-avatar-img{width:100%;height:100%;object-fit:cover;display:block}.gb-avatar[data-tone]{background:color-mix(in srgb,var(--gb-tone) 16%,#ffffff);color:color-mix(in srgb,var(--gb-tone) 70%,#000000)}.gb-avatar[data-tone="0"]{--gb-tone: #5661f5}.gb-avatar[data-tone="1"]{--gb-tone: #0a8f6a}.gb-avatar[data-tone="2"]{--gb-tone: #c26a04}.gb-avatar[data-tone="3"]{--gb-tone: #db2777}.gb-avatar[data-tone="4"]{--gb-tone: #0891b2}.gb-avatar[data-tone="5"]{--gb-tone: #7c3aed}.gb-avatar[data-tone="6"]{--gb-tone: #dc2626}.gb-avatar[data-tone="7"]{--gb-tone: #6b7280}.gb-leaderboard{display:grid;gap:6px;min-width:0}.gb-leaderboard-title{margin:0;display:flex;align-items:center;gap:6px;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--gemboss-text-muted)}.gb-leaderboard-caption{margin:-2px 0 2px;font-size:12px;color:var(--gemboss-text-muted)}.gb-leaderboard-list{list-style:none;margin:0;padding:0;display:grid;gap:0}.gb-leaderboard-link{display:flex;align-items:center;gap:10px;min-height:44px;padding:0 6px;border-radius:8px;color:var(--gemboss-text);text-decoration:none}a.gb-leaderboard-link:hover{background:var(--gemboss-hover)}a.gb-leaderboard-link:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}.gb-leaderboard-rank{width:20px;flex:0 0 auto;text-align:center;font-size:15px;line-height:1}.gb-leaderboard-rank.is-number{font-size:12px;font-weight:700;color:var(--gemboss-text-muted);font-variant-numeric:tabular-nums}.gb-leaderboard-name{flex:1 1 auto;min-width:0;font-size:13px;font-weight:560;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-leaderboard-value{flex:0 0 auto;font-size:13px;font-weight:650;color:var(--gemboss-text-muted);font-variant-numeric:tabular-nums}.gb-leaderboard-row.is-first .gb-leaderboard-value{color:#8a5a00;font-weight:750}.gb-leaderboard-row.is-current>.gb-leaderboard-link{background:var(--gemboss-active-bg)}.gb-leaderboard-badge{flex:0 1 auto;min-width:0;overflow:hidden}.gb-leaderboard-all{justify-self:center;display:inline-flex;align-items:center;min-height:24px;font-size:12px;font-weight:600;color:var(--gemboss-text-muted);text-decoration:none}.gb-leaderboard-all:hover{color:var(--gemboss-text-strong);text-decoration:underline}.gb-podium{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;align-items:end}.gb-podium-link{display:grid;justify-items:center;gap:6px;padding:14px 8px;border:1px solid var(--gemboss-divider);border-radius:12px;background:var(--gemboss-surface);color:var(--gemboss-text);text-decoration:none;text-align:center}a.gb-podium-link:hover{background:var(--gemboss-hover)}.gb-podium-slot{min-width:0}.gb-podium-slot.is-empty{visibility:hidden}.gb-podium-slot--1 .gb-podium-link{padding-top:22px;padding-bottom:20px;border-color:color-mix(in srgb,#d4a017 45%,var(--gemboss-divider))}.gb-podium-slot--2 .gb-podium-link{padding-bottom:12px}.gb-podium-slot--3 .gb-podium-link{padding-bottom:8px}.gb-podium-medal{font-size:22px;line-height:1}.gb-podium-name{max-width:100%;font-size:13px;font-weight:620;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-podium-value{font-size:15px;font-weight:750;font-variant-numeric:tabular-nums}.gb-podium-slot--1 .gb-podium-value{color:#8a5a00}.gb-podium-unit{font-size:11px;font-weight:500;color:var(--gemboss-text-muted)}.gb-podium-slot.is-current .gb-podium-link{border-color:var(--gemboss-text-strong);background:var(--gemboss-active-bg)}.gb-search-trigger,.gb-theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;flex:0 0 auto;border:0;border-radius:8px;background:transparent;color:var(--gemboss-text-muted);cursor:pointer}.gb-search-trigger:hover,.gb-theme-toggle:hover{background:var(--gemboss-hover);color:var(--gemboss-text-strong)}.gb-search-trigger:focus-visible,.gb-theme-toggle:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:-2px}@media (pointer: coarse),(max-width: 760px){.gb-search-trigger,.gb-theme-toggle{width:44px;height:44px}}.gb-search-field{text-decoration:none;cursor:text}.gb-search-field:focus-visible{outline:2px solid var(--gemboss-accent);outline-offset:1px}.gb-search-field-text{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:var(--gemboss-fs-body);color:var(--gemboss-field-placeholder)}dialog.ui-modal.gb-search{width:min(640px,100%);margin-top:8vh;max-height:min(560px,calc(100dvh - 16vh))}.gb-search-box{position:relative;display:flex;align-items:center;border-bottom:1px solid var(--ui-border)}.gb-search-icon{position:absolute;left:18px;color:var(--ui-text-muted);pointer-events:none}.gb-search-input{flex:1 1 auto;min-width:0;height:52px;padding:0 18px 0 46px;border:0;background:transparent;color:var(--ui-text);font:inherit;font-size:15px;outline:none}.gb-search-input::-webkit-search-cancel-button{display:none}.gb-search-results{flex:1 1 auto;min-height:0;overflow:auto;padding:8px}.gb-search-section+.gb-search-section{margin-top:8px}.gb-search-section-title{padding:6px 10px;font-size:12px;font-weight:600;color:var(--ui-text-muted)}.gb-search-item{display:grid;gap:2px;min-height:44px;align-content:center;padding:6px 10px;border-radius:8px;color:var(--ui-text);text-decoration:none}.gb-search-item.is-active{background:var(--ui-surface-2)}.gb-search-item-title{font-size:14px;font-weight:520;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gb-search-item-meta{display:flex;gap:8px;align-items:center;font-size:12px;color:var(--ui-text-muted)}.gb-search-empty{margin:0;padding:18px 18px 0;font-size:13px;color:var(--ui-text-muted)}.gb-search-empty:empty{padding:0}.gb-search-foot{display:flex;gap:16px;justify-content:flex-end;padding:8px 14px;border-top:1px solid var(--ui-border);font-size:12px;color:var(--ui-text-muted)}@media (max-width: 760px){dialog.ui-modal.gb-search{margin-top:12px}.gb-search-foot{display:none}}
|
package/src/segmented.tsx
CHANGED
|
@@ -27,8 +27,12 @@ export type SegmentedOption<T extends string> = {
|
|
|
27
27
|
disabled?: boolean;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/** `scroll`'s layout: the group scrolls inside itself, each tab keeps its words on one line (kit-plain.css's rule, as styles). */
|
|
31
|
+
const SCROLL_GROUP: CSSProperties = { maxWidth: "100%", overflowX: "auto" };
|
|
32
|
+
const SCROLL_TAB: CSSProperties = { flex: "none", whiteSpace: "nowrap" };
|
|
33
|
+
|
|
30
34
|
export function Segmented<T extends string>({
|
|
31
|
-
options, value, onChange, ariaLabel, disabled, onClear, fill = false, className, style,
|
|
35
|
+
options, value, onChange, ariaLabel, disabled, onClear, fill = false, scroll = false, className, style,
|
|
32
36
|
}: {
|
|
33
37
|
options: SegmentedOption<T>[];
|
|
34
38
|
value: T | "";
|
|
@@ -47,13 +51,18 @@ export function Segmented<T extends string>({
|
|
|
47
51
|
onClear?: () => void;
|
|
48
52
|
/** Stretch to the row and share it equally: a field switch in the inspector's narrow column. */
|
|
49
53
|
fill?: boolean;
|
|
54
|
+
/** Keep each label on one line and scroll sideways inside the group when it is wider than its room, for a filter of many
|
|
55
|
+
* options on a phone: what kit-plain.css does for every plain surface. Set as layout styles here, not as a rule in ui.css:
|
|
56
|
+
* gemboss-ui.css had 0 bytes of room (weight gate, 22/10/2026). Not in a short flex column: a scroll container there loses
|
|
57
|
+
* its height. */
|
|
58
|
+
scroll?: boolean;
|
|
50
59
|
/** A hook class beside `ui-seg` (the editor kit's `gemboss-ei-seg`, which editor-plain.js finds). Never a look. */
|
|
51
60
|
className?: string;
|
|
52
61
|
/** On the group: the inspector narrows a two-option row with a max-width. */
|
|
53
62
|
style?: CSSProperties;
|
|
54
63
|
}) {
|
|
55
64
|
return (
|
|
56
|
-
<div className={`ui-seg${fill ? " ui-seg--fill" : ""}${className ? ` ${className}` : ""}`} style={style} role="group" aria-label={ariaLabel || undefined}>
|
|
65
|
+
<div className={`ui-seg${fill ? " ui-seg--fill" : ""}${className ? ` ${className}` : ""}`} style={scroll ? { ...SCROLL_GROUP, ...style } : style} role="group" aria-label={ariaLabel || undefined}>
|
|
57
66
|
{options.map((o) => {
|
|
58
67
|
const active = value === o.value;
|
|
59
68
|
return (
|
|
@@ -67,6 +76,7 @@ export function Segmented<T extends string>({
|
|
|
67
76
|
aria-label={o.ariaLabel}
|
|
68
77
|
aria-pressed={active}
|
|
69
78
|
className={`is-text${active ? " active" : ""}`}
|
|
79
|
+
style={scroll ? SCROLL_TAB : undefined}
|
|
70
80
|
onClick={() => segmentedClick(active, o.value, onChange, onClear)}
|
|
71
81
|
>
|
|
72
82
|
{o.label}
|
package/src/selection.tsx
CHANGED
|
@@ -23,7 +23,9 @@ export const ESCAPE_OWNERS = 'details[open], dialog[open], [role="dialog"], [rol
|
|
|
23
23
|
export function escapeTaken(root: Element, target: EventTarget | null): boolean {
|
|
24
24
|
const t = target as Element | null;
|
|
25
25
|
if (t && typeof t.closest === "function" && t.closest(ESCAPE_OWNERS)) return true;
|
|
26
|
-
|
|
26
|
+
// Shown, not merely boxed: a closed <details>' content still has boxes (content-visibility: hidden), and every ⋯ menu's
|
|
27
|
+
// list is role="menu" (23/10/2026), so a box test would hand every Escape in a table to its closed menus.
|
|
28
|
+
return Array.from(root.querySelectorAll(ESCAPE_OWNERS)).some((e) => (typeof e.checkVisibility === "function" ? e.checkVisibility() : e.getClientRects().length > 0));
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
export function useSelection<R extends HTMLElement>(ids: string[], selectedProp: string[] | undefined, onSelectionChange: ((ids: string[]) => void) | undefined, enabled: boolean) {
|