staffa 0.15.0 → 0.16.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/README.md +99 -271
- package/dist/components/autocomplete.js +4 -5
- package/dist/components/box.js +11 -21
- package/dist/components/button.d.ts +20 -5
- package/dist/components/button.js +55 -47
- package/dist/components/buttonChooser.js +1 -3
- package/dist/components/checkbox.js +1 -2
- package/dist/components/dialog.d.ts +9 -2
- package/dist/components/dialog.js +28 -32
- package/dist/components/field.d.ts +5 -8
- package/dist/components/field.js +4 -6
- package/dist/components/form.d.ts +5 -7
- package/dist/components/form.js +6 -9
- package/dist/components/keyhelp.d.ts +22 -0
- package/dist/components/keyhelp.js +91 -0
- package/dist/components/main.js +188 -271
- package/dist/components/menu.d.ts +36 -9
- package/dist/components/menu.js +185 -135
- package/dist/components/panels.d.ts +145 -236
- package/dist/components/panels.js +327 -557
- package/dist/components/select.js +1 -3
- package/dist/components/tabs.d.ts +10 -13
- package/dist/components/tabs.js +38 -58
- package/dist/components/textline.d.ts +3 -5
- package/dist/components/textline.js +3 -5
- package/dist/components/toast.d.ts +1 -3
- package/dist/components/toast.js +2 -3
- package/dist/components/tooltip.d.ts +4 -5
- package/dist/components/tooltip.js +7 -10
- package/dist/core.d.ts +17 -24
- package/dist/core.js +13 -18
- package/dist/icons-helpers.d.ts +3 -3
- package/dist/icons-helpers.js +6 -11
- package/dist/index.d.ts +3 -1
- package/dist/index.js +5 -4
- package/dist/keys.d.ts +92 -0
- package/dist/keys.js +279 -0
- package/dist/staffa.esm.js +1 -1
- package/dist/theme.d.ts +4 -10
- package/dist/theme.js +58 -123
- package/package.json +2 -2
- package/skill/ButtonOptions.md +12 -0
- package/skill/DialogOptions.md +11 -2
- package/skill/FieldOptions.md +3 -5
- package/skill/IconButtonOptions.md +8 -0
- package/skill/MenuItem.md +22 -3
- package/skill/Panel.md +8 -0
- package/skill/SKILL.md +161 -294
- package/skill/addTooltip.md +4 -5
- package/skill/bindKey.md +51 -0
- package/skill/box.md +1 -1
- package/skill/form.md +5 -7
- package/skill/formatKey.md +21 -0
- package/skill/iconButton.md +4 -5
- package/skill/scrollStrip.md +7 -9
- package/skill/showFloatingMenu.md +2 -2
- package/skill/showKeyHelp.md +17 -0
- package/skill/tabs.md +3 -4
- package/skill/textline.md +3 -5
- package/src/components/autocomplete.ts +4 -5
- package/src/components/box.ts +11 -21
- package/src/components/button.ts +70 -47
- package/src/components/buttonChooser.ts +1 -3
- package/src/components/checkbox.ts +1 -2
- package/src/components/dialog.ts +38 -34
- package/src/components/field.ts +7 -11
- package/src/components/form.ts +6 -9
- package/src/components/keyhelp.ts +96 -0
- package/src/components/main.ts +191 -270
- package/src/components/menu.ts +202 -138
- package/src/components/panels.ts +371 -619
- package/src/components/select.ts +1 -3
- package/src/components/tabs.ts +38 -58
- package/src/components/textline.ts +3 -5
- package/src/components/toast.ts +3 -6
- package/src/components/tooltip.ts +7 -10
- package/src/core.ts +17 -24
- package/src/icons-helpers.ts +6 -11
- package/src/index.ts +5 -4
- package/src/keys.ts +300 -0
- package/src/theme.ts +58 -123
- package/skill/Attributes.md +0 -10
package/src/components/menu.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { type Slot, type Attributes, drawSlot, mountPortal, focusFirst } from ".
|
|
|
4
4
|
import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
|
|
5
5
|
import { button, type ButtonOptions } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
7
|
+
import { bindKey, formatKey } from "../keys.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* A clickable item in a menu or sidebar nav.
|
|
@@ -31,15 +32,32 @@ export interface MenuItem {
|
|
|
31
32
|
* `attrs: "data-panel=push"` for a row that should stack instead.
|
|
32
33
|
*/
|
|
33
34
|
href?: string;
|
|
35
|
+
/**
|
|
36
|
+
* A keyboard shortcut that activates this item: `"mod+k"`, `"f2"`, a bare
|
|
37
|
+
* `"?"`. The spelling, and which keystrokes are yours to take, are
|
|
38
|
+
* documented on {@link bindKey}. The combination shows at the right end of
|
|
39
|
+
* the row (not on a touch device) and reaches screen readers as
|
|
40
|
+
* `aria-keyshortcuts`; the `?` overview ({@link showKeyHelp}) lists it
|
|
41
|
+
* under the item's label. Activating runs `click` with the `KeyboardEvent`
|
|
42
|
+
* and follows `href` as a fresh navigation to it — the target getting its
|
|
43
|
+
* own panel stack, as a nav item's does.
|
|
44
|
+
*
|
|
45
|
+
* The shortcut works with the menu shut — rather the point of one on a
|
|
46
|
+
* dropdown or context menu — for as long as whatever owns the items is
|
|
47
|
+
* drawn: the {@link menu}, {@link menuButton} or {@link addContextMenu}
|
|
48
|
+
* call, or `S.main`'s `nav`. (The bare {@link showFloatingMenu} binds
|
|
49
|
+
* nothing: its menu exists only while it is up.) A disabled item's key is
|
|
50
|
+
* not bound.
|
|
51
|
+
*/
|
|
52
|
+
key?: string;
|
|
34
53
|
/**
|
|
35
54
|
* Pages this item claims *beyond* its own `href`: a string claims that path
|
|
36
55
|
* and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
|
|
37
56
|
* function is asked with the current path. While a claimed page is current,
|
|
38
57
|
* the item is highlighted and the branches above it stay unfolded — for the
|
|
39
58
|
* detail screens a menu has no row of their own: the `/thread/[id]` a
|
|
40
|
-
* notification lands on, an icon's page under the gallery's row. Claims
|
|
41
|
-
*
|
|
42
|
-
* which no amount of fold-state keeping can.
|
|
59
|
+
* notification lands on, an icon's page under the gallery's row. Claims work
|
|
60
|
+
* from the first paint, so cold deep links are covered too.
|
|
43
61
|
*/
|
|
44
62
|
match?: string | ((path: string) => boolean);
|
|
45
63
|
/** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
|
|
@@ -147,73 +165,79 @@ export interface FloatingMenuOptions {
|
|
|
147
165
|
* the anchor is the element the handler is attached to. */
|
|
148
166
|
export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "closeOnAnchorClick">;
|
|
149
167
|
|
|
150
|
-
// Styles shared by the floating dropdown and the sidebar nav
|
|
151
|
-
//
|
|
152
|
-
// render its items into either one.
|
|
168
|
+
// Styles shared by the floating dropdown and the sidebar nav. The item styles
|
|
169
|
+
// aren't scoped to a container, so `drawMenu` can render into either one.
|
|
153
170
|
A.insertGlobalCss({
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// invisible yet still hittable by tests and read by assistive tech.
|
|
171
|
+
// On dismissal (the `.hidden` rule below), `visibility` rides the fade,
|
|
172
|
+
// flipping only at its end: a dismissed menu lingers in the DOM (`destroy=`
|
|
173
|
+
// removes it on a timer), and without this it would spend that time invisible
|
|
174
|
+
// yet still hittable and read by assistive tech. On entry it must flip
|
|
175
|
+
// instantly (`0s` here) instead: a hidden element refuses focus, so the
|
|
176
|
+
// menu's own opening focus() would silently fail mid-fade-in.
|
|
161
177
|
".s-menu-list":
|
|
162
178
|
"position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
163
179
|
"r:$s-radius-lg " +
|
|
164
|
-
// The 16px
|
|
165
|
-
//
|
|
166
|
-
// than hanging off the screen. (A `min-width` from the caller still wins,
|
|
167
|
-
// as CSS says it must — that ask is the app's to keep sensible.)
|
|
180
|
+
// The 16px matches the 8px gap `positionMenu` leaves at either window edge, so
|
|
181
|
+
// a menu too wide for the window narrows instead of hanging off the screen.
|
|
168
182
|
"max-width: calc(100vw - 16px); overflow-y:auto max-height:min(80vh,28rem) " +
|
|
183
|
+
"transition: opacity 0.15s, transform 0.15s, visibility 0s;",
|
|
184
|
+
".s-menu-list.hidden":
|
|
185
|
+
"opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden " +
|
|
169
186
|
"transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
|
|
170
|
-
|
|
171
|
-
//
|
|
172
|
-
// identical; the element only differs where link semantics matter (see below).
|
|
173
|
-
// The scroll-margin keeps a revealed row (see the scrollIntoView in
|
|
174
|
-
// `drawMenu`) a little clear of the scrollport edge, instead of flush to it.
|
|
187
|
+
// One class for both the `<a>` and `<button>` forms. The scroll-margin keeps a
|
|
188
|
+
// revealed row (the scrollIntoView in `drawLeaf`) clear of the scrollport edge.
|
|
175
189
|
".s-menu-item":
|
|
176
|
-
"display:flex align-items:center gap:$2 w:100%
|
|
190
|
+
"display:flex align-items:center gap:$2 w:100% scroll-margin:$2 " +
|
|
177
191
|
"padding: $m2 0; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
|
|
178
192
|
"font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
|
|
179
193
|
"transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
|
|
180
194
|
".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])":
|
|
181
195
|
"filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
196
|
+
// Arrowing through a list is aiming blind unless the row you are on says so, and
|
|
197
|
+
// the hover colour alone doesn't — least of all on the current-page row, which
|
|
198
|
+
// already wears the accent. So: a tinted band, plus the theme's focus ring, inset
|
|
199
|
+
// because a row runs the full width of its list and an outset ring would clip.
|
|
200
|
+
".s-menu-item:focus-visible":
|
|
201
|
+
"outline-offset:-2px background: color-mix(in srgb, $s-accent 14%, transparent);",
|
|
202
|
+
// The active row is simply drawn in the surface's accent — no glow, no brightening.
|
|
203
|
+
// `filter:none` also keeps the global `a:hover` brighten off it.
|
|
187
204
|
".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// (Sidebar rows stay flush — their panel brings the breathing room.)
|
|
205
|
+
// Dropdown rows bring their own horizontal padding; the panel's thin `$1` inset
|
|
206
|
+
// alone leaves labels nearly touching its edge. Sidebar rows stay flush.
|
|
191
207
|
".s-menu-list .s-menu-item": "padding-inline:$2",
|
|
192
208
|
".s-menu-item[aria-disabled=true]":
|
|
193
209
|
"opacity:0.45 cursor:not-allowed pointer-events:none",
|
|
194
210
|
".s-menu-icon": "flex-shrink:0",
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// Scoped to `.s-menu-list` deliberately: `S.main`'s nav is a roomier thing
|
|
199
|
-
// than a dropdown — its rows are built around the icon at the size it was
|
|
200
|
-
// drawn, and shrinking it there tightened the whole sidebar.
|
|
211
|
+
// Icons come out of the set at 24px, towering over a 0.9em dropdown row; riding
|
|
212
|
+
// the font size keeps them in step with the label. Scoped to `.s-menu-list`:
|
|
213
|
+
// `S.main`'s nav rows are built around the icon at the size it was drawn.
|
|
201
214
|
".s-menu-list .s-menu-icon": "display:flex",
|
|
202
215
|
".s-menu-list .s-menu-icon > svg": "width:1.25em height:1.25em",
|
|
203
|
-
// A
|
|
204
|
-
//
|
|
205
|
-
// `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
216
|
+
// A hairline that fades out at both ends, reading as a grouping cue rather than a
|
|
217
|
+
// divider bar. `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
206
218
|
"hr.s-menu-sep":
|
|
207
219
|
"border:0 height:1px margin: $1 0.6rem; " +
|
|
208
220
|
"background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
209
|
-
//
|
|
210
|
-
//
|
|
221
|
+
// The shortcut hint (see `MenuItem.key`): pushed to the right end of the row, and
|
|
222
|
+
// kept quiet — it is there to be found, not read. In the row's own colour at a low
|
|
223
|
+
// opacity, so it follows the row through hover and the current-page accent.
|
|
224
|
+
".s-menu-key": "margin-left:auto padding-left:$2 font-family:inherit font-size:0.8em opacity:0.55 white-space:nowrap flex-shrink:0",
|
|
225
|
+
// A touch device gets no hint: a row advertising a key there is mostly noise.
|
|
226
|
+
// These describe the *primary* input, so a laptop with a touchscreen keeps its
|
|
227
|
+
// hints. The shortcuts stay bound whatever the device says — a keyboard clipped
|
|
228
|
+
// onto a tablet works, it just isn't advertised.
|
|
229
|
+
"@media (hover: none) and (pointer: coarse)": {
|
|
230
|
+
".s-menu-key": "display:none",
|
|
231
|
+
},
|
|
232
|
+
// A branch row's fold indicator: a › that turns downward while the branch is open.
|
|
211
233
|
".s-menu-chevron": "margin-left:auto flex-shrink:0 display:flex transition: transform 0.15s ease;",
|
|
234
|
+
// Two `margin-left:auto`s in one row would split the free space between them,
|
|
235
|
+
// stranding the hint in the middle; the hint's is the one that should win.
|
|
236
|
+
".s-menu-key + .s-menu-chevron": "margin-left:0",
|
|
212
237
|
".s-menu-chevron > svg": "width:1em height:1em",
|
|
213
|
-
// A branch is a native <details>: closed content is
|
|
214
|
-
//
|
|
215
|
-
// the
|
|
216
|
-
// `interpolate-size`; engines without it simply snap, which is fine).
|
|
238
|
+
// A branch is a native <details>: closed content is hidden, not unmounted, so a fold
|
|
239
|
+
// is one attribute flip — no teardown, no sibling redraws — and the browser animates
|
|
240
|
+
// the height via `::details-content` (engines without `interpolate-size` just snap).
|
|
217
241
|
".s-menu-details": {
|
|
218
242
|
"> summary": "list-style:none",
|
|
219
243
|
"> summary::-webkit-details-marker": "display:none",
|
|
@@ -225,8 +249,7 @@ A.insertGlobalCss({
|
|
|
225
249
|
},
|
|
226
250
|
// A branch's children: indented one step.
|
|
227
251
|
".s-menu-sub": "display:flex flex-direction:column gap:$1 padding-left:$3",
|
|
228
|
-
// The standalone `menu()` component's list
|
|
229
|
-
// are `.s-menu-item`s like everywhere else); this only stacks them.
|
|
252
|
+
// The standalone `menu()` component's list; the rows style themselves.
|
|
230
253
|
".s-menu-inline": "display:flex flex-direction:column gap:$1",
|
|
231
254
|
});
|
|
232
255
|
|
|
@@ -250,11 +273,10 @@ A.insertGlobalCss({
|
|
|
250
273
|
export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
|
|
251
274
|
// Roving focus via the DOM: query the live item elements on each keypress.
|
|
252
275
|
A("keydown=", (e: KeyboardEvent) => {
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
if (e.key === "Enter" && (e.target as HTMLElement).tagName === "A") {
|
|
276
|
+
// interceptLinks' own Enter handler preventDefault()s the activation, so no
|
|
277
|
+
// synthetic `click` fires and the click-bound `onLeafSelect` never runs. Close
|
|
278
|
+
// it ourselves, deferred so this keydown finishes navigating first.
|
|
279
|
+
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && (e.target as HTMLElement).tagName === "A") {
|
|
258
280
|
queueMicrotask(() => onLeafSelect?.());
|
|
259
281
|
return;
|
|
260
282
|
}
|
|
@@ -274,11 +296,9 @@ export function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void {
|
|
|
274
296
|
els[next].focus();
|
|
275
297
|
});
|
|
276
298
|
|
|
277
|
-
// Whether the current page is in this menu *at all
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// no branch can tell on its own. Derived, so the branches re-run only when
|
|
281
|
-
// the answer flips — not on every navigation between two held pages.
|
|
299
|
+
// Whether the current page is in this menu *at all* — a fact no single branch can
|
|
300
|
+
// tell, and one the fold logic needs (see `drawBranch`). Derived, so branches
|
|
301
|
+
// re-run only when the answer flips.
|
|
282
302
|
const $menuHasCurrent = A.derive(() => anyCurrent(items));
|
|
283
303
|
drawEntries(items, onLeafSelect, $menuHasCurrent);
|
|
284
304
|
}
|
|
@@ -293,17 +313,12 @@ function drawEntries(items: MenuEntry[], onLeafSelect?: () => void, $menuHasCurr
|
|
|
293
313
|
}
|
|
294
314
|
|
|
295
315
|
function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
296
|
-
// Whether the aria-current scope below has run before:
|
|
297
|
-
//
|
|
316
|
+
// Whether the aria-current scope below has run before: only a *later* run
|
|
317
|
+
// should animate the reveal.
|
|
298
318
|
let drawn = false;
|
|
299
|
-
// `data-panel=open
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// body) and `S.main()`'s sidebar are outside every panel and behave this way
|
|
303
|
-
// already; saying it outright makes an inline `menu()` — which may well sit
|
|
304
|
-
// *inside* a panel — behave the same wherever it's drawn. `attrs` comes
|
|
305
|
-
// after, so an item that really does want to stack can say
|
|
306
|
-
// `attrs: "data-panel=push"`.
|
|
319
|
+
// `data-panel=open`: a menu row is navigation, not a link in the content, so the
|
|
320
|
+
// panel it was clicked from isn't context to keep — including for an inline `menu()`
|
|
321
|
+
// drawn inside one. `attrs` comes after, so a row can opt into `data-panel=push`.
|
|
307
322
|
const itemEl = A(entry.href ? "a.s-menu-item data-panel=open" : "button.s-menu-item type=button", entry.attrs, () => {
|
|
308
323
|
if (entry.href) {
|
|
309
324
|
A("href=", entry.href);
|
|
@@ -313,19 +328,15 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
313
328
|
drawn = true;
|
|
314
329
|
if (!isCurrent(entry)) return;
|
|
315
330
|
A("aria-current=page");
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
// at all when it's already visible. A row that *starts out*
|
|
320
|
-
// current arrives at the right place (a cold deep link lands
|
|
321
|
-
// with the sidebar already there); when a navigation moves the
|
|
322
|
-
// highlight later, the scroll follows it smoothly. rAF, so a
|
|
323
|
-
// fresh row is laid out before it's measured.
|
|
331
|
+
// In a list taller than its scrollport (a long sidebar nav), the current
|
|
332
|
+
// row can be scrolled out of sight; bring it back — jumping on the first
|
|
333
|
+
// run, gliding on later ones. rAF, so a fresh row is laid out first.
|
|
324
334
|
requestAnimationFrame(() =>
|
|
325
335
|
(itemEl as HTMLElement).scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
|
|
326
336
|
});
|
|
327
337
|
}
|
|
328
338
|
if (entry.disabled) A("aria-disabled=true");
|
|
339
|
+
if (entry.key) A("aria-keyshortcuts=", formatKey(entry.key, true));
|
|
329
340
|
A("click=", (e: Event) => {
|
|
330
341
|
if (entry.disabled) { e.preventDefault(); return; }
|
|
331
342
|
onLeafSelect?.();
|
|
@@ -333,15 +344,14 @@ function drawLeaf(entry: MenuItem, onLeafSelect?: () => void): void {
|
|
|
333
344
|
});
|
|
334
345
|
if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
335
346
|
drawSlot(entry.label);
|
|
347
|
+
drawKeyHint(entry);
|
|
336
348
|
});
|
|
337
349
|
}
|
|
338
350
|
|
|
339
351
|
/**
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
* state would hand it three folded sections in the middle of the user's work.
|
|
344
|
-
* Bounded by the number of distinct branch hrefs an app ever shows.
|
|
352
|
+
* Last route-derived fold state per linked branch, keyed by its selection href.
|
|
353
|
+
* Module-level on purpose: menus remount (the phone's full-page nav exists only
|
|
354
|
+
* while it is open), and per-mount state would refold every section mid-task.
|
|
345
355
|
*/
|
|
346
356
|
const foldMemory = new Map<string, boolean>();
|
|
347
357
|
|
|
@@ -352,30 +362,21 @@ function setFold(href: string, open: boolean): boolean {
|
|
|
352
362
|
|
|
353
363
|
/**
|
|
354
364
|
* A branch: a native `<details>` folding a sub-list of entries in and out. The
|
|
355
|
-
* children stay mounted whether folded or not
|
|
356
|
-
*
|
|
357
|
-
* itself, and nothing around it redraws.
|
|
365
|
+
* children stay mounted whether folded or not, so a fold is a single `open` flip
|
|
366
|
+
* and nothing around it redraws.
|
|
358
367
|
*
|
|
359
|
-
* Clicking the summary row *selects* rather than toggles when there is a page
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
368
|
+
* Clicking the summary row *selects* rather than toggles when there is a page to
|
|
369
|
+
* select (the branch's own `href`, or the first linked leaf below it): it navigates,
|
|
370
|
+
* and the navigation is what unfolds the branch, since a linked branch is open
|
|
371
|
+
* exactly while it holds the current page. Only a branch with no link anywhere below
|
|
372
|
+
* it keeps the native toggle.
|
|
364
373
|
*/
|
|
365
374
|
function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?: { value: boolean }): void {
|
|
366
375
|
const href = entry.href ?? firstLeafHref(entry.items!);
|
|
367
|
-
//
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// its last state — folding everything up would answer a question nobody
|
|
372
|
-
// asked with a menu that forgot where the user was.
|
|
373
|
-
//
|
|
374
|
-
// "Last state" lives in `foldMemory`, not in this closure: menus remount —
|
|
375
|
-
// the phone's full-page nav exists only while it is open — and a remount
|
|
376
|
-
// must find the state where the previous mount left it. It is keyed on the
|
|
377
|
-
// branch's selection href, so the sidebar and the phone nav (two renderings
|
|
378
|
-
// of the same items) share one truth, however often either is rebuilt.
|
|
376
|
+
// Derived, so the attribute scope below re-runs only when the fold answer flips.
|
|
377
|
+
// When the current page is nowhere in the menu, nothing has an opinion and the fold
|
|
378
|
+
// keeps its last state — kept in `foldMemory` rather than this closure, since menus
|
|
379
|
+
// remount, and keyed on the href so both renderings of the items share one truth.
|
|
379
380
|
const $open = href != null
|
|
380
381
|
? A.derive(() => {
|
|
381
382
|
if (containsCurrent(entry)) return setFold(href, true);
|
|
@@ -391,10 +392,10 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
|
|
|
391
392
|
|
|
392
393
|
A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
|
|
393
394
|
if (entry.disabled) A("aria-disabled=true");
|
|
395
|
+
if (entry.key) A("aria-keyshortcuts=", formatKey(entry.key, true));
|
|
394
396
|
A(() => {
|
|
395
|
-
// Current only on its *own* page
|
|
396
|
-
//
|
|
397
|
-
// row carries the highlight, and two highlights would read as two pages.
|
|
397
|
+
// Current only on its *own* page: when a descendant is current, that row
|
|
398
|
+
// carries the highlight, and two highlights would read as two pages.
|
|
398
399
|
if (isCurrent(entry)) A("aria-current=page");
|
|
399
400
|
});
|
|
400
401
|
A("click=", (e: Event) => {
|
|
@@ -410,6 +411,7 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
|
|
|
410
411
|
});
|
|
411
412
|
if (entry.icon) A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
412
413
|
drawSlot(entry.label);
|
|
414
|
+
drawKeyHint(entry);
|
|
413
415
|
A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
|
|
414
416
|
});
|
|
415
417
|
|
|
@@ -418,9 +420,9 @@ function drawBranch(entry: MenuItem, onLeafSelect?: () => void, $menuHasCurrent?
|
|
|
418
420
|
}
|
|
419
421
|
|
|
420
422
|
/**
|
|
421
|
-
* Whether a row sits inside a closed branch. A closed `<details>` hides its
|
|
422
|
-
*
|
|
423
|
-
*
|
|
423
|
+
* Whether a row sits inside a closed branch. A closed `<details>` hides its content
|
|
424
|
+
* without unmounting it, so arrow-key navigation must skip it — but not the closed
|
|
425
|
+
* branch's own summary row.
|
|
424
426
|
*/
|
|
425
427
|
function foldedAway(el: HTMLElement): boolean {
|
|
426
428
|
for (
|
|
@@ -434,10 +436,9 @@ function foldedAway(el: HTMLElement): boolean {
|
|
|
434
436
|
}
|
|
435
437
|
|
|
436
438
|
/**
|
|
437
|
-
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
* so the two can never disagree with the highlighting.
|
|
439
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match` claims — is
|
|
440
|
+
* the current page. Exported because the shell's tagline rule (`taglineFits` in
|
|
441
|
+
* main.ts) must agree with the highlighting.
|
|
441
442
|
*/
|
|
442
443
|
export function anyCurrent(items: MenuEntry[]): boolean {
|
|
443
444
|
return items.some((entry) =>
|
|
@@ -445,9 +446,9 @@ export function anyCurrent(items: MenuEntry[]): boolean {
|
|
|
445
446
|
}
|
|
446
447
|
|
|
447
448
|
/**
|
|
448
|
-
* Whether this item is the current page: its own `href` matches, or its
|
|
449
|
-
*
|
|
450
|
-
*
|
|
449
|
+
* Whether this item is the current page: its own `href` matches, or its `match`
|
|
450
|
+
* claims the current path. The single test behind `aria-current`, branch unfolding
|
|
451
|
+
* and {@link anyCurrent}.
|
|
451
452
|
*/
|
|
452
453
|
function isCurrent(entry: MenuItem): boolean {
|
|
453
454
|
if (entry.href != null && matchCurrent(entry.href)) return true;
|
|
@@ -479,12 +480,73 @@ function firstLeafHref(items: MenuEntry[]): string | undefined {
|
|
|
479
480
|
return undefined;
|
|
480
481
|
}
|
|
481
482
|
|
|
483
|
+
// ─── Keyboard shortcuts ──────────────────────────────────────────────────────
|
|
484
|
+
|
|
485
|
+
/** The quiet hint at the right end of a row. See {@link MenuItem.key}. */
|
|
486
|
+
function drawKeyHint(entry: MenuItem): void {
|
|
487
|
+
// `aria-hidden`: the row already carries the shortcut as `aria-keyshortcuts`,
|
|
488
|
+
// which is what a screen reader reads out — this is the sighted half.
|
|
489
|
+
if (entry.key) A("kbd.s-menu-key aria-hidden=true text=", formatKey(entry.key));
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Bind the shortcuts of every item in a menu — see {@link MenuItem.key} — for as
|
|
494
|
+
* long as the calling scope lives. The `?` overview lists each binding under its
|
|
495
|
+
* item's label. No `aria-keyshortcuts` here — the rows announce their own.
|
|
496
|
+
*
|
|
497
|
+
* `getItems` is a function rather than the array itself, so that the read happens
|
|
498
|
+
* in this scope and not the caller's: a menu's items are often a reactive array,
|
|
499
|
+
* and subscribing the caller (`S.main()`'s whole shell, say) to it would redraw
|
|
500
|
+
* far more than the menu.
|
|
501
|
+
*/
|
|
502
|
+
export function registerMenuKeys(getItems: () => MenuEntry[], onSelect?: () => void): void {
|
|
503
|
+
A(() => {
|
|
504
|
+
for (const entry of withKeys(getItems(), [])) {
|
|
505
|
+
bindKey(entry.key!, entry.label, (e) => {
|
|
506
|
+
// Picking a row from the keyboard is still picking a row: any menu that
|
|
507
|
+
// happens to be up steps out of the way, as it would on a click.
|
|
508
|
+
closeFloatingMenu();
|
|
509
|
+
onSelect?.();
|
|
510
|
+
entry.click?.(e);
|
|
511
|
+
if (entry.href != null) followHref(entry.href, entry.target);
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Every item with a key worth binding, branches and their children included. A
|
|
519
|
+
* disabled one is left out rather than bound and ignored, so its combination
|
|
520
|
+
* stays free — and since `disabled` is read here, flipping it rebinds.
|
|
521
|
+
*/
|
|
522
|
+
function withKeys(items: MenuEntry[], out: MenuItem[]): MenuItem[] {
|
|
523
|
+
for (const entry of items) {
|
|
524
|
+
if (typeof entry === "string" || typeof entry === "function" || "separator" in entry) continue;
|
|
525
|
+
if (entry.key && !entry.disabled) out.push(entry);
|
|
526
|
+
if (entry.items) withKeys(entry.items, out);
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Follow a row's `href` from the keyboard. The row may not even be drawn, so this
|
|
533
|
+
* can't just click it — it does what clicking it would: routing an ordinary in-app
|
|
534
|
+
* path, which lands where the row's own `data-panel=open` does (the target getting
|
|
535
|
+
* its own stack of columns, as a nav item's target does), and leaving the links
|
|
536
|
+
* Aberdeen doesn't intercept either — another target, another origin — to the
|
|
537
|
+
* browser.
|
|
538
|
+
*/
|
|
539
|
+
function followHref(href: string, target?: string): void {
|
|
540
|
+
const url = new URL(href, location.href);
|
|
541
|
+
if (target) window.open(url.href, target, target === "_blank" ? "noopener" : "");
|
|
542
|
+
else if (url.origin !== location.origin) location.href = url.href;
|
|
543
|
+
else void go(href);
|
|
544
|
+
}
|
|
545
|
+
|
|
482
546
|
// ─── Branch navigations ──────────────────────────────────────────────────────
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
// opening up. So a branch click leaves a note of where it is headed, and the
|
|
487
|
-
// dismiss-on-navigation checks consume it.
|
|
547
|
+
// Menus dismiss themselves when the page navigates — but a branch row navigates in
|
|
548
|
+
// order to expand, and dismissing over that would close the menu mid-unfold. So a
|
|
549
|
+
// branch click leaves a note of where it is headed; the dismiss checks consume it.
|
|
488
550
|
|
|
489
551
|
let branchNavPath: string | null = null;
|
|
490
552
|
|
|
@@ -555,8 +617,8 @@ function positionMenu(menuEl: HTMLElement, rect: { left: number; right: number;
|
|
|
555
617
|
|
|
556
618
|
/**
|
|
557
619
|
* The standard entries for the link a menu stands on (see
|
|
558
|
-
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
|
|
559
|
-
*
|
|
620
|
+
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so the
|
|
621
|
+
* target arrives cold, exactly as the link middle-clicked would.
|
|
560
622
|
*/
|
|
561
623
|
function linkItems(href: string): MenuEntry[] {
|
|
562
624
|
return [
|
|
@@ -566,10 +628,9 @@ function linkItems(href: string): MenuEntry[] {
|
|
|
566
628
|
}
|
|
567
629
|
|
|
568
630
|
/**
|
|
569
|
-
*
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
* needs a secure context, so a failure says so rather than lying.
|
|
631
|
+
* Copy the link's absolute URL to the clipboard, confirmed with a toast since a
|
|
632
|
+
* silent copy leaves you wondering. `writeText` needs a secure context, so a failure
|
|
633
|
+
* says so rather than lying.
|
|
573
634
|
*/
|
|
574
635
|
async function copyLink(href: string): Promise<void> {
|
|
575
636
|
const url = new URL(href, location.href).href;
|
|
@@ -600,11 +661,9 @@ mountPortal(() => {
|
|
|
600
661
|
const onKey = (e: KeyboardEvent) => {
|
|
601
662
|
if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); closeFloating(); }
|
|
602
663
|
};
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
// about — doesn't, and neither does a navigation from anywhere else. A branch
|
|
607
|
-
// row expanding is the one navigation that *isn't* a hand-over.
|
|
664
|
+
// Close on any navigation the items didn't already close for: custom slot content,
|
|
665
|
+
// or a navigation from elsewhere. A branch row expanding is the one navigation that
|
|
666
|
+
// isn't a hand-over.
|
|
608
667
|
const openedAt = A.peek(currentRoute, "path");
|
|
609
668
|
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path)) closeFloating(); });
|
|
610
669
|
document.addEventListener("click", onClick, true);
|
|
@@ -621,9 +680,8 @@ mountPortal(() => {
|
|
|
621
680
|
// pointer location for a context menu — otherwise below the anchor.
|
|
622
681
|
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
623
682
|
positionMenu(menuEl, rect);
|
|
624
|
-
// Focus the
|
|
625
|
-
//
|
|
626
|
-
// content, like a settings dropdown, not just `.s-menu-item`s).
|
|
683
|
+
// Focus the current-page item if there is one, else the first focusable
|
|
684
|
+
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
627
685
|
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
628
686
|
});
|
|
629
687
|
});
|
|
@@ -653,14 +711,17 @@ mountPortal(() => {
|
|
|
653
711
|
* ```
|
|
654
712
|
*/
|
|
655
713
|
export function menu(opts: MenuListOptions): void {
|
|
656
|
-
A("nav.s-menu-inline", opts.attrs, () =>
|
|
714
|
+
A("nav.s-menu-inline", opts.attrs, () => {
|
|
715
|
+
registerMenuKeys(() => opts.items, () => opts.onLeafSelect?.());
|
|
716
|
+
drawMenu(opts.items, opts.onLeafSelect);
|
|
717
|
+
});
|
|
657
718
|
}
|
|
658
719
|
|
|
659
720
|
/**
|
|
660
721
|
* Open a floating dropdown menu anchored to an element. Portals to
|
|
661
722
|
* `document.body` (never clipped), positions itself (flipping up when there's
|
|
662
|
-
* no room below), and closes on Escape, Tab, item selection,
|
|
663
|
-
* outside the panel and anchor. Returns a `close()` function.
|
|
723
|
+
* no room below), and closes on Escape, Tab, item selection, a navigation, or
|
|
724
|
+
* any click outside the panel and anchor. Returns a `close()` function.
|
|
664
725
|
*
|
|
665
726
|
* Menus are usually opened through {@link menuButton} or
|
|
666
727
|
* {@link addContextMenu}; reach for this primitive when you need to trigger a
|
|
@@ -708,6 +769,9 @@ export function showFloatingMenu(opts: FloatingMenuOptions): () => void {
|
|
|
708
769
|
* ```
|
|
709
770
|
*/
|
|
710
771
|
export function addContextMenu(opts: ContextMenuOptions): void {
|
|
772
|
+
// Bound here rather than where the menu is drawn: a shortcut on a context menu
|
|
773
|
+
// is meant to work without right-clicking first (see {@link MenuItem.key}).
|
|
774
|
+
registerMenuKeys(() => opts.items);
|
|
711
775
|
let myEl: HTMLElement | null = null;
|
|
712
776
|
A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
|
|
713
777
|
|
|
@@ -715,8 +779,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
|
|
|
715
779
|
e.preventDefault();
|
|
716
780
|
myEl = e.currentTarget as HTMLElement;
|
|
717
781
|
// Anchor at the exact click/tap point, and close on a plain click of the
|
|
718
|
-
// element (it has no toggle handler of its own).
|
|
719
|
-
// pass through whole, so a shared option can't be dropped on the way.
|
|
782
|
+
// element (it has no toggle handler of its own).
|
|
720
783
|
showFloatingMenu({
|
|
721
784
|
...opts,
|
|
722
785
|
anchor: myEl,
|
|
@@ -747,6 +810,7 @@ export function addContextMenu(opts: ContextMenuOptions): void {
|
|
|
747
810
|
* ```
|
|
748
811
|
*/
|
|
749
812
|
export function menuButton(opts: MenuOptions): void {
|
|
813
|
+
registerMenuKeys(() => opts.items);
|
|
750
814
|
let myEl: HTMLElement | null = null;
|
|
751
815
|
A.clean(() => { if ($floating.opts?.anchor === myEl) closeFloating(); });
|
|
752
816
|
|