@gemboss/ui 0.47.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 +315 -0
- package/README.md +162 -18
- package/catalog/editor.html +1 -1
- package/catalog/index.html +53 -49
- package/gemboss-ui.css +4 -4
- package/package.json +23 -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-plain.js +1 -1
- package/src/language-switcher.css +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/portal.tsx +19 -4
- 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/topnav-fold.css +1 -0
- package/src/translation-row.css +1 -0
- package/src/translation-row.tsx +113 -0
- package/src/ui.css +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
/**
|
|
3
|
+
* ListPage (23/10/2026, step 3 of the ⋯ / list standard, Gems/CLAUDE.md §13): the frame of a page that lists one kind of
|
|
4
|
+
* thing. Its header (a way back, the title, one line under it, the primary action, the rest), its notices, its toolbar, and
|
|
5
|
+
* ONE card holding the list, or, in the list's place, what the list is doing: loading, empty, nothing matching, or not read.
|
|
6
|
+
*
|
|
7
|
+
* Until now every admin list page drew this itself on Polaris `Page`, and each drew the states its own way: five kinds of
|
|
8
|
+
* empty state, four labels on a retry ("Try again", "Retry", "Reload", "Refresh"), a skeleton of 3 or 4 lines or a spinner or
|
|
9
|
+
* nothing. Here there is one of each: a page says WHICH state it is in, and the kit draws it.
|
|
10
|
+
*
|
|
11
|
+
* The header takes Polaris Page's numbers, measured in the admin (_local/gemboss/list-page-2026-10-23): title 20/24/700 on a
|
|
12
|
+
* 28px row, the line under it 12/16 muted 2px below that row, actions 28px tall 8px apart, 24px above the header and under it
|
|
13
|
+
* from 768px up, 16 below. What it does differently, on purpose:
|
|
14
|
+
* - secondary actions: in a page column of 560px or more the first two are buttons and the rest go behind ⋯; in a narrower
|
|
15
|
+
* column (the admin at 768, beside its rail) and below 768px all of them are behind ⋯. Polaris measures the room in
|
|
16
|
+
* script and folds a different number at every width; this folds the same way every time.
|
|
17
|
+
* - below 768 the controls are 44px tall (the kit's rule for a finger and a phone width, §9.1), not Polaris' 32.
|
|
18
|
+
*
|
|
19
|
+
* `primaryAction` is a node, so a page passes its own button (the admin's Polaris Button, its Publish button that counts
|
|
20
|
+
* changes). Secondary actions are data, so the kit can put them behind ⋯ when there is no room; an action is the same as a
|
|
21
|
+
* button and as a ⋯ row (its URL through `linkAs`, its `onAction` on both, its reason when disabled). `linkAs` draws the back
|
|
22
|
+
* link and every URL action with the app's link (Next's Link), as IndexTable does.
|
|
23
|
+
*
|
|
24
|
+
* The states are drawn here, not with collections.tsx' CollectionState / CollectionSkeleton: importing that module brings
|
|
25
|
+
* collections.css (15.6 KB gzip, tables, boards, grids) into every page that lists something. The markup is the same
|
|
26
|
+
* core classes (ui.css: `ui-empty`, `ui-notice--danger`, `ui-skeleton__line`); the no-match padding is this entry's own, since
|
|
27
|
+
* `ui-empty--no-match` lives in collections.css (the parts gate holds a component to the CSS it imports).
|
|
28
|
+
*/
|
|
29
|
+
import "./list-page.css";
|
|
30
|
+
import React, { useId } from "react";
|
|
31
|
+
import { Icon } from "./icons";
|
|
32
|
+
import { Menu, MenuGroup, MenuItem, MenuSeparator } from "./menu";
|
|
33
|
+
|
|
34
|
+
export interface ListPageAction {
|
|
35
|
+
content: string;
|
|
36
|
+
onAction?: () => void;
|
|
37
|
+
url?: string;
|
|
38
|
+
/** A new tab. */
|
|
39
|
+
external?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
destructive?: boolean;
|
|
42
|
+
/** Why it is disabled, or what it will do: the button's title, the menu row's hint. */
|
|
43
|
+
helpText?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A choice in an action group: the one that is on is `active` (a check before it; the others none). */
|
|
47
|
+
export interface ListPageChoice extends ListPageAction {
|
|
48
|
+
active?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type ListPageState = "ready" | "loading" | "empty" | "no-match" | "error";
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/** How many secondary actions stand as buttons from 768px up; the rest are behind ⋯. */
|
|
55
|
+
const WIDE_SECONDARY = 2;
|
|
56
|
+
|
|
57
|
+
export function ListPage({
|
|
58
|
+
title, subtitle, back, titleMeta, primaryAction, secondaryActions = [], actionGroups = [], notices, toolbar,
|
|
59
|
+
state = "ready", empty, noMatch, error, loadingRows = 3, loadingText = "Loading…", cardTitle, cardMeta, cardLead, card = true, cardProps, after, children, linkAs = "a",
|
|
60
|
+
}: {
|
|
61
|
+
title: React.ReactNode;
|
|
62
|
+
/** One line under the title: what the page holds, with its count first when it has one ("12 rules · 9 on."). */
|
|
63
|
+
subtitle?: React.ReactNode;
|
|
64
|
+
/** The page this one sits in ("Types" from a type's entries). */
|
|
65
|
+
back?: { href: string; label: string };
|
|
66
|
+
/** Beside the title: a badge (a count, a status). */
|
|
67
|
+
titleMeta?: React.ReactNode;
|
|
68
|
+
/** The ONE thing to do here, as the page's own button. */
|
|
69
|
+
primaryAction?: React.ReactNode;
|
|
70
|
+
secondaryActions?: ListPageAction[];
|
|
71
|
+
/** Choices of one kind, always behind ⋯ (Polaris Page's actionGroups): a title, and the one that is on marked `active`
|
|
72
|
+
* (Products' Auto-refresh: Daily, Off). Never header buttons, at any width. */
|
|
73
|
+
actionGroups?: { title: string; actions: ListPageChoice[] }[];
|
|
74
|
+
/** What sits between the header and the toolbar: banners about the page (a change failed, a note), a band, the one item
|
|
75
|
+
* shown apart (Themes' current theme). Not the list's read error: that is `state`. */
|
|
76
|
+
notices?: React.ReactNode;
|
|
77
|
+
/** Search, filters, sort, a view switch: above the card. */
|
|
78
|
+
toolbar?: React.ReactNode;
|
|
79
|
+
/** What the card shows: the list (`children`), or the state the list is in. */
|
|
80
|
+
state?: ListPageState;
|
|
81
|
+
/** The empty state, the page's own (an EmptyStage). Default: one line. */
|
|
82
|
+
empty?: React.ReactNode;
|
|
83
|
+
/** Filters found nothing: the header and the toolbar stay, the card says so and offers to clear them. */
|
|
84
|
+
noMatch?: { title?: React.ReactNode; body?: React.ReactNode; onClear?: () => void };
|
|
85
|
+
/** The list could not be read: what failed, and "Try again" (`retrying` while the new read is on its way). A failed action
|
|
86
|
+
* over a list that IS there is a notice. */
|
|
87
|
+
error?: { title?: React.ReactNode; message?: React.ReactNode; onRetry?: () => void; retrying?: boolean };
|
|
88
|
+
/** Rows the loading placeholder draws (at most 10). */
|
|
89
|
+
loadingRows?: number;
|
|
90
|
+
/** What a screen reader hears while the list loads ("Loading your rules…"). */
|
|
91
|
+
loadingText?: string;
|
|
92
|
+
/** A heading at the top of the card ("Your rules"). */
|
|
93
|
+
cardTitle?: React.ReactNode;
|
|
94
|
+
/** Beside and under that heading, not in it: a hint about the card ("Drop images here to upload"), so a screen reader's heading
|
|
95
|
+
* list reads the heading alone. */
|
|
96
|
+
cardMeta?: React.ReactNode;
|
|
97
|
+
/** false: no card around a list that draws its own (a grid of cards), and its `empty` is drawn as given: a page's own
|
|
98
|
+
* starting point (StartingPoint) stands on the page. Loading, no match and a failed read still draw in a card. */
|
|
99
|
+
card?: boolean;
|
|
100
|
+
/** Attributes on the card itself (a drop zone's class and drag handlers), so a state drawn in it keeps them: an empty
|
|
101
|
+
* library is still where files are dropped. */
|
|
102
|
+
cardProps?: React.HTMLAttributes<HTMLElement>;
|
|
103
|
+
/** Rows at the top of the card in EVERY state, the state's loading, empty or error after them: a row that does not wait for the
|
|
104
|
+
* list's read (Funnels' Website, the funnel every store has). A page with a lead always has its card, so the lead stays the
|
|
105
|
+
* same element (an open row stays open) from one state to the next. */
|
|
106
|
+
cardLead?: React.ReactNode;
|
|
107
|
+
/** Sections after the list (a library to add from, ready-made templates). */
|
|
108
|
+
after?: React.ReactNode;
|
|
109
|
+
children?: React.ReactNode;
|
|
110
|
+
/** The app's link (Next's Link), as IndexTable takes it. */
|
|
111
|
+
linkAs?: React.ElementType;
|
|
112
|
+
}) {
|
|
113
|
+
const Link = linkAs;
|
|
114
|
+
const uid = useId();
|
|
115
|
+
const wide = secondaryActions.slice(0, WIDE_SECONDARY);
|
|
116
|
+
const overflow = secondaryActions.slice(WIDE_SECONDARY);
|
|
117
|
+
const hasActions = Boolean(primaryAction) || secondaryActions.length > 0 || actionGroups.length > 0;
|
|
118
|
+
// Each group is a titled set of choices, every one of them a menuitemradio (its `active` true or false), after a line when
|
|
119
|
+
// rows come before it.
|
|
120
|
+
const groups = (after: boolean) => actionGroups.map((g, gi) => (
|
|
121
|
+
<React.Fragment key={`${gi}:${g.title}`}>
|
|
122
|
+
{after || gi > 0 ? <MenuSeparator /> : null}
|
|
123
|
+
<MenuGroup title={g.title}>{g.actions.map((a, i) => item(a, i, !!a.active))}</MenuGroup>
|
|
124
|
+
</React.Fragment>
|
|
125
|
+
));
|
|
126
|
+
// A ⋯ that holds only one group of choices is named by it ("Auto-refresh options"), as the caret beside Refresh now was;
|
|
127
|
+
// any other is "More actions" (review of 3f).
|
|
128
|
+
const wideMore = overflow.length === 0 && actionGroups.length === 1 ? `${actionGroups[0].title} options` : "More actions";
|
|
129
|
+
// `checked` only for a group's choices: an action is never a radio row in the ⋯ and a plain button beside it.
|
|
130
|
+
const item = (a: ListPageAction, i: number, checked?: boolean) => (
|
|
131
|
+
<MenuItem key={`${i}:${a.content}`} checked={checked} href={a.url} linkAs={a.url && !a.external ? Link : undefined} danger={a.destructive} disabled={a.disabled} hint={a.helpText}
|
|
132
|
+
onClick={a.onAction ? () => a.onAction!() : undefined}
|
|
133
|
+
{...(a.url && a.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>{a.content}</MenuItem>
|
|
134
|
+
);
|
|
135
|
+
// A button says the same as its ⋯ row: a disabled one stays focusable (aria-disabled) with its reason as its description,
|
|
136
|
+
// which a keyboard and a screen reader reach; a `title` alone, on a `disabled` button, reaches neither.
|
|
137
|
+
const button = (a: ListPageAction, i: number) => {
|
|
138
|
+
const cls = `ui-btn ui-list-page__action${a.destructive ? " ui-list-page__action--danger" : ""} ui-list-page__wide`;
|
|
139
|
+
const why = a.helpText ? `${uid}-why-${i}` : undefined;
|
|
140
|
+
const run = a.onAction ? () => a.onAction!() : undefined;
|
|
141
|
+
// An external action (a new tab, a download) is a plain link: through the app's Link, Next would prefetch it, and a
|
|
142
|
+
// download route would build its file on every visit (review of 3e, Tests' Export CSV).
|
|
143
|
+
const To: React.ElementType = a.external ? "a" : Link;
|
|
144
|
+
return (
|
|
145
|
+
<React.Fragment key={`${i}:${a.content}`}>
|
|
146
|
+
{a.url && !a.disabled ? (
|
|
147
|
+
<To href={a.url} className={cls} title={a.helpText} aria-describedby={why} onClick={run}
|
|
148
|
+
{...(a.external ? { target: "_blank", rel: "noopener noreferrer" } : {})}>{a.content}</To>
|
|
149
|
+
) : (
|
|
150
|
+
<button type="button" className={cls} aria-disabled={a.disabled || undefined} title={a.helpText} aria-describedby={why}
|
|
151
|
+
onClick={a.disabled ? undefined : run}>{a.content}</button>
|
|
152
|
+
)}
|
|
153
|
+
{why ? <span id={why} hidden>{a.helpText}</span> : null}
|
|
154
|
+
</React.Fragment>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
// The words are said by the page's one status region (below), not by the busy rows: a screen reader may hold back a live
|
|
159
|
+
// region inside an aria-busy one.
|
|
160
|
+
const placeholder = (
|
|
161
|
+
<div className="ui-list-page__loading">
|
|
162
|
+
<div aria-busy="true" aria-hidden="true">
|
|
163
|
+
{Array.from({ length: Math.max(1, Math.min(10, loadingRows)) }, (_, i) => (
|
|
164
|
+
<div key={i} className="ui-list-page__skeleton"><span className="ui-skeleton__line" /><span className="ui-skeleton__line" /></div>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
);
|
|
169
|
+
const body = state === "loading" ? placeholder
|
|
170
|
+
: state === "empty" ? (empty ?? <div className="ui-empty"><p className="ui-empty__title">Nothing here yet</p></div>)
|
|
171
|
+
: state === "no-match" ? (
|
|
172
|
+
<div className="ui-empty ui-list-page__no-match">
|
|
173
|
+
<p className="ui-empty__title">{noMatch?.title ?? "Nothing matches"}</p>
|
|
174
|
+
{noMatch?.body ? <p className="ui-empty__body">{noMatch.body}</p> : null}
|
|
175
|
+
{noMatch?.onClear ? <button type="button" className="ui-btn" onClick={noMatch.onClear}>Clear filters</button> : null}
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
: state === "error" ? (
|
|
179
|
+
<div className="ui-list-page__error">
|
|
180
|
+
<div className="ui-notice ui-notice--danger" role="alert">
|
|
181
|
+
<div><b>{error?.title ?? "This list couldn’t be read."}</b>{error?.message ? <> {error.message}</> : null}</div>
|
|
182
|
+
{error?.onRetry ? (
|
|
183
|
+
<button type="button" className="ui-btn" aria-disabled={error.retrying || undefined}
|
|
184
|
+
onClick={error.retrying ? undefined : error.onRetry}>{error.retrying ? "Trying again…" : "Try again"}</button>
|
|
185
|
+
) : null}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
: children;
|
|
190
|
+
// A page with no card around its list draws its OWN empty as given (a starting point stands on the page); the kit's default
|
|
191
|
+
// line is still boxed, as every other state (review of 3c).
|
|
192
|
+
// A lead is card content, and a card that came and went would remount it (review of 3d).
|
|
193
|
+
const boxed = card || (cardLead != null && cardLead !== false) || (state !== "ready" && !(state === "empty" && empty != null));
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<div className="ui-list-page">
|
|
197
|
+
<header className={`ui-list-page__head${back ? " ui-list-page__head--back" : ""}`}>
|
|
198
|
+
{back ? (
|
|
199
|
+
<Link href={back.href} className="ui-btn ui-btn--icon ui-list-page__back" aria-label={`Back to ${back.label}`} title={back.label}>
|
|
200
|
+
<Icon name="arrowLeft" />
|
|
201
|
+
</Link>
|
|
202
|
+
) : null}
|
|
203
|
+
<div className="ui-list-page__title-row">
|
|
204
|
+
<h1 className="ui-list-page__title">{title}</h1>
|
|
205
|
+
{titleMeta}
|
|
206
|
+
</div>
|
|
207
|
+
{/* Its own cell: under the title from 768px up, across the whole header below (where the actions share the title's row). */}
|
|
208
|
+
{subtitle ? <p className="ui-list-page__subtitle">{subtitle}</p> : null}
|
|
209
|
+
{hasActions ? (
|
|
210
|
+
<div className="ui-list-page__actions">
|
|
211
|
+
{wide.map(button)}
|
|
212
|
+
{overflow.length || actionGroups.length ? <span className="ui-list-page__wide"><Menu label={wideMore} end className="ui-list-page__more">{overflow.map((a, i) => item(a, i))}{groups(overflow.length > 0)}</Menu></span> : null}
|
|
213
|
+
{secondaryActions.length || actionGroups.length ? <span className="ui-list-page__narrow"><Menu label="More actions" end className="ui-list-page__more">{secondaryActions.map((a, i) => item(a, i))}{groups(secondaryActions.length > 0)}</Menu></span> : null}
|
|
214
|
+
{primaryAction}
|
|
215
|
+
</div>
|
|
216
|
+
) : null}
|
|
217
|
+
</header>
|
|
218
|
+
{/* ONE status region for the page, there in every state: a live region added with its words already in it is often not
|
|
219
|
+
read out, so the region stays and only its words change (review of 3d). */}
|
|
220
|
+
<span className="ui-sr-only" role="status">{state === "loading" ? loadingText : ""}</span>
|
|
221
|
+
{notices ? <div className="ui-list-page__notices">{notices}</div> : null}
|
|
222
|
+
{toolbar ? <div className="ui-list-page__toolbar">{toolbar}</div> : null}
|
|
223
|
+
{boxed ? (
|
|
224
|
+
<section {...cardProps} className={["ui-card ui-list-page__card", cardProps?.className].filter(Boolean).join(" ")}>
|
|
225
|
+
{cardTitle ? <div className="ui-list-page__card-head"><h2 className="ui-list-page__card-title">{cardTitle}</h2>{cardMeta}</div> : null}
|
|
226
|
+
{cardLead}
|
|
227
|
+
{body}
|
|
228
|
+
</section>
|
|
229
|
+
) : (
|
|
230
|
+
// No card around a list that draws its own (a grid of cards): the heading stays, so it does not come and go with the state.
|
|
231
|
+
<>
|
|
232
|
+
{cardTitle ? <h2 className="ui-list-page__card-title ui-list-page__section-title">{cardTitle}</h2> : null}
|
|
233
|
+
{body}
|
|
234
|
+
</>
|
|
235
|
+
)}
|
|
236
|
+
{after}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.ui-menu__busy{width:16px;height:16px;box-sizing:border-box;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ui-menu-busy .8s linear infinite}@keyframes ui-menu-busy{to{transform:rotate(360deg)}}@media (prefers-reduced-motion: reduce){.ui-menu__busy{animation-duration:2s}}.ui-menu__list{max-width:calc(100vw - 16px)}.ui-menu__item{flex-wrap:wrap}.ui-menu__hint{max-width:32ch;white-space:normal;text-align:right}.ui-menu:not([open])>.ui-menu__list{display:none}.ui-menu__check{display:inline-flex;flex:none;width:16px;height:16px;visibility:hidden}.ui-menu__item[aria-checked=true]>.ui-menu__check{visibility:visible}.ui-menu__item[aria-checked=true]{font-weight:600}.ui-menu__group-title{padding:6px 6px 2px;color:var(--ui-text-muted);font-size:var(--ui-fs-caption);line-height:16px}@media (pointer: coarse),(max-width: 47.9975em){.ui-menu__group-title{padding:8px 16px 4px}}
|
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}
|