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
|
@@ -26,15 +26,32 @@ export interface MenuItem {
|
|
|
26
26
|
* `attrs: "data-panel=push"` for a row that should stack instead.
|
|
27
27
|
*/
|
|
28
28
|
href?: string;
|
|
29
|
+
/**
|
|
30
|
+
* A keyboard shortcut that activates this item: `"mod+k"`, `"f2"`, a bare
|
|
31
|
+
* `"?"`. The spelling, and which keystrokes are yours to take, are
|
|
32
|
+
* documented on {@link bindKey}. The combination shows at the right end of
|
|
33
|
+
* the row (not on a touch device) and reaches screen readers as
|
|
34
|
+
* `aria-keyshortcuts`; the `?` overview ({@link showKeyHelp}) lists it
|
|
35
|
+
* under the item's label. Activating runs `click` with the `KeyboardEvent`
|
|
36
|
+
* and follows `href` as a fresh navigation to it — the target getting its
|
|
37
|
+
* own panel stack, as a nav item's does.
|
|
38
|
+
*
|
|
39
|
+
* The shortcut works with the menu shut — rather the point of one on a
|
|
40
|
+
* dropdown or context menu — for as long as whatever owns the items is
|
|
41
|
+
* drawn: the {@link menu}, {@link menuButton} or {@link addContextMenu}
|
|
42
|
+
* call, or `S.main`'s `nav`. (The bare {@link showFloatingMenu} binds
|
|
43
|
+
* nothing: its menu exists only while it is up.) A disabled item's key is
|
|
44
|
+
* not bound.
|
|
45
|
+
*/
|
|
46
|
+
key?: string;
|
|
29
47
|
/**
|
|
30
48
|
* Pages this item claims *beyond* its own `href`: a string claims that path
|
|
31
49
|
* and everything under it (`"/mail"` claims `/mail/…`, not `/mailbox`), a
|
|
32
50
|
* function is asked with the current path. While a claimed page is current,
|
|
33
51
|
* the item is highlighted and the branches above it stay unfolded — for the
|
|
34
52
|
* detail screens a menu has no row of their own: the `/thread/[id]` a
|
|
35
|
-
* notification lands on, an icon's page under the gallery's row. Claims
|
|
36
|
-
*
|
|
37
|
-
* which no amount of fold-state keeping can.
|
|
53
|
+
* notification lands on, an icon's page under the gallery's row. Claims work
|
|
54
|
+
* from the first paint, so cold deep links are covered too.
|
|
38
55
|
*/
|
|
39
56
|
match?: string | ((path: string) => boolean);
|
|
40
57
|
/** `target` for the link (`_blank`, etc.). Only meaningful with `href`. */
|
|
@@ -157,12 +174,22 @@ export type ContextMenuOptions = Omit<FloatingMenuOptions, "anchor" | "at" | "cl
|
|
|
157
174
|
*/
|
|
158
175
|
export declare function drawMenu(items: MenuEntry[], onLeafSelect?: () => void): void;
|
|
159
176
|
/**
|
|
160
|
-
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* so the two can never disagree with the highlighting.
|
|
177
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match` claims — is
|
|
178
|
+
* the current page. Exported because the shell's tagline rule (`taglineFits` in
|
|
179
|
+
* main.ts) must agree with the highlighting.
|
|
164
180
|
*/
|
|
165
181
|
export declare function anyCurrent(items: MenuEntry[]): boolean;
|
|
182
|
+
/**
|
|
183
|
+
* Bind the shortcuts of every item in a menu — see {@link MenuItem.key} — for as
|
|
184
|
+
* long as the calling scope lives. The `?` overview lists each binding under its
|
|
185
|
+
* item's label. No `aria-keyshortcuts` here — the rows announce their own.
|
|
186
|
+
*
|
|
187
|
+
* `getItems` is a function rather than the array itself, so that the read happens
|
|
188
|
+
* in this scope and not the caller's: a menu's items are often a reactive array,
|
|
189
|
+
* and subscribing the caller (`S.main()`'s whole shell, say) to it would redraw
|
|
190
|
+
* far more than the menu.
|
|
191
|
+
*/
|
|
192
|
+
export declare function registerMenuKeys(getItems: () => MenuEntry[], onSelect?: () => void): void;
|
|
166
193
|
/**
|
|
167
194
|
* Whether the navigation that just landed on `path` was a branch row expanding
|
|
168
195
|
* (consuming the note it left). Internal — used by the floating menu below and
|
|
@@ -210,8 +237,8 @@ export declare function menu(opts: MenuListOptions): void;
|
|
|
210
237
|
/**
|
|
211
238
|
* Open a floating dropdown menu anchored to an element. Portals to
|
|
212
239
|
* `document.body` (never clipped), positions itself (flipping up when there's
|
|
213
|
-
* no room below), and closes on Escape, Tab, item selection,
|
|
214
|
-
* outside the panel and anchor. Returns a `close()` function.
|
|
240
|
+
* no room below), and closes on Escape, Tab, item selection, a navigation, or
|
|
241
|
+
* any click outside the panel and anchor. Returns a `close()` function.
|
|
215
242
|
*
|
|
216
243
|
* Menus are usually opened through {@link menuButton} or
|
|
217
244
|
* {@link addContextMenu}; reach for this primitive when you need to trigger a
|
package/dist/components/menu.js
CHANGED
|
@@ -4,68 +4,73 @@ import { drawSlot, mountPortal, focusFirst } from "../core.js";
|
|
|
4
4
|
import { menu as menuIcon, chevronRight, externalLink as newTabIcon, link as linkIcon } from "../icons.js";
|
|
5
5
|
import { button } from "./button.js";
|
|
6
6
|
import { toast } from "./toast.js";
|
|
7
|
-
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
import { bindKey, formatKey } from "../keys.js";
|
|
8
|
+
// Styles shared by the floating dropdown and the sidebar nav. The item styles
|
|
9
|
+
// aren't scoped to a container, so `drawMenu` can render into either one.
|
|
10
10
|
A.insertGlobalCss({
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// invisible yet still hittable by tests and read by assistive tech.
|
|
11
|
+
// On dismissal (the `.hidden` rule below), `visibility` rides the fade,
|
|
12
|
+
// flipping only at its end: a dismissed menu lingers in the DOM (`destroy=`
|
|
13
|
+
// removes it on a timer), and without this it would spend that time invisible
|
|
14
|
+
// yet still hittable and read by assistive tech. On entry it must flip
|
|
15
|
+
// instantly (`0s` here) instead: a hidden element refuses focus, so the
|
|
16
|
+
// menu's own opening focus() would silently fail mid-fade-in.
|
|
18
17
|
".s-menu-list": "position:fixed z-index:350 min-width:10rem display:flex flex-direction:column p:$1 " +
|
|
19
18
|
"r:$s-radius-lg " +
|
|
20
|
-
// The 16px
|
|
21
|
-
//
|
|
22
|
-
// than hanging off the screen. (A `min-width` from the caller still wins,
|
|
23
|
-
// as CSS says it must — that ask is the app's to keep sensible.)
|
|
19
|
+
// The 16px matches the 8px gap `positionMenu` leaves at either window edge, so
|
|
20
|
+
// a menu too wide for the window narrows instead of hanging off the screen.
|
|
24
21
|
"max-width: calc(100vw - 16px); overflow-y:auto max-height:min(80vh,28rem) " +
|
|
22
|
+
"transition: opacity 0.15s, transform 0.15s, visibility 0s;",
|
|
23
|
+
".s-menu-list.hidden": "opacity:0 pointer-events:none transform:translateY(-6px) visibility:hidden " +
|
|
25
24
|
"transition: opacity 0.15s, transform 0.15s, visibility 0.15s;",
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
// The scroll-margin keeps a revealed row (see the scrollIntoView in
|
|
30
|
-
// `drawMenu`) a little clear of the scrollport edge, instead of flush to it.
|
|
31
|
-
".s-menu-item": "display:flex align-items:center gap:$2 w:100% outline:0 scroll-margin:$2 " +
|
|
25
|
+
// One class for both the `<a>` and `<button>` forms. The scroll-margin keeps a
|
|
26
|
+
// revealed row (the scrollIntoView in `drawLeaf`) clear of the scrollport edge.
|
|
27
|
+
".s-menu-item": "display:flex align-items:center gap:$2 w:100% scroll-margin:$2 " +
|
|
32
28
|
"padding: $m2 0; line-height:1.1 r:$s-radius cursor:pointer text-align:left font-weight:450 " +
|
|
33
29
|
"font-size:0.9em border:0 background:transparent fg:$s-text text-decoration:none " +
|
|
34
30
|
"transition: color 0.12s, transform 0.12s, text-shadow 0.12s;",
|
|
35
31
|
".s-menu-item:focus-visible:not([aria-current=page]), .s-menu-item:hover:not([aria-disabled=true]):not([aria-current=page])": "filter:none color: color-mix(in lab, $s-primary 33%, $s-text);",
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
32
|
+
// Arrowing through a list is aiming blind unless the row you are on says so, and
|
|
33
|
+
// the hover colour alone doesn't — least of all on the current-page row, which
|
|
34
|
+
// already wears the accent. So: a tinted band, plus the theme's focus ring, inset
|
|
35
|
+
// because a row runs the full width of its list and an outset ring would clip.
|
|
36
|
+
".s-menu-item:focus-visible": "outline-offset:-2px background: color-mix(in srgb, $s-accent 14%, transparent);",
|
|
37
|
+
// The active row is simply drawn in the surface's accent — no glow, no brightening.
|
|
38
|
+
// `filter:none` also keeps the global `a:hover` brighten off it.
|
|
41
39
|
".s-menu-item[aria-current=page]": "color:$s-accent filter:none",
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// (Sidebar rows stay flush — their panel brings the breathing room.)
|
|
40
|
+
// Dropdown rows bring their own horizontal padding; the panel's thin `$1` inset
|
|
41
|
+
// alone leaves labels nearly touching its edge. Sidebar rows stay flush.
|
|
45
42
|
".s-menu-list .s-menu-item": "padding-inline:$2",
|
|
46
43
|
".s-menu-item[aria-disabled=true]": "opacity:0.45 cursor:not-allowed pointer-events:none",
|
|
47
44
|
".s-menu-icon": "flex-shrink:0",
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// Scoped to `.s-menu-list` deliberately: `S.main`'s nav is a roomier thing
|
|
52
|
-
// than a dropdown — its rows are built around the icon at the size it was
|
|
53
|
-
// drawn, and shrinking it there tightened the whole sidebar.
|
|
45
|
+
// Icons come out of the set at 24px, towering over a 0.9em dropdown row; riding
|
|
46
|
+
// the font size keeps them in step with the label. Scoped to `.s-menu-list`:
|
|
47
|
+
// `S.main`'s nav rows are built around the icon at the size it was drawn.
|
|
54
48
|
".s-menu-list .s-menu-icon": "display:flex",
|
|
55
49
|
".s-menu-list .s-menu-icon > svg": "width:1.25em height:1.25em",
|
|
56
|
-
// A
|
|
57
|
-
//
|
|
58
|
-
// `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
50
|
+
// A hairline that fades out at both ends, reading as a grouping cue rather than a
|
|
51
|
+
// divider bar. `hr.` (not just `.`) so this wins over the global hr flow-margin rule.
|
|
59
52
|
"hr.s-menu-sep": "border:0 height:1px margin: $1 0.6rem; " +
|
|
60
53
|
"background: linear-gradient(to right, transparent, $s-faint 18%, $s-faint 82%, transparent);",
|
|
61
|
-
//
|
|
62
|
-
//
|
|
54
|
+
// The shortcut hint (see `MenuItem.key`): pushed to the right end of the row, and
|
|
55
|
+
// kept quiet — it is there to be found, not read. In the row's own colour at a low
|
|
56
|
+
// opacity, so it follows the row through hover and the current-page accent.
|
|
57
|
+
".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",
|
|
58
|
+
// A touch device gets no hint: a row advertising a key there is mostly noise.
|
|
59
|
+
// These describe the *primary* input, so a laptop with a touchscreen keeps its
|
|
60
|
+
// hints. The shortcuts stay bound whatever the device says — a keyboard clipped
|
|
61
|
+
// onto a tablet works, it just isn't advertised.
|
|
62
|
+
"@media (hover: none) and (pointer: coarse)": {
|
|
63
|
+
".s-menu-key": "display:none",
|
|
64
|
+
},
|
|
65
|
+
// A branch row's fold indicator: a › that turns downward while the branch is open.
|
|
63
66
|
".s-menu-chevron": "margin-left:auto flex-shrink:0 display:flex transition: transform 0.15s ease;",
|
|
67
|
+
// Two `margin-left:auto`s in one row would split the free space between them,
|
|
68
|
+
// stranding the hint in the middle; the hint's is the one that should win.
|
|
69
|
+
".s-menu-key + .s-menu-chevron": "margin-left:0",
|
|
64
70
|
".s-menu-chevron > svg": "width:1em height:1em",
|
|
65
|
-
// A branch is a native <details>: closed content is
|
|
66
|
-
//
|
|
67
|
-
// the
|
|
68
|
-
// `interpolate-size`; engines without it simply snap, which is fine).
|
|
71
|
+
// A branch is a native <details>: closed content is hidden, not unmounted, so a fold
|
|
72
|
+
// is one attribute flip — no teardown, no sibling redraws — and the browser animates
|
|
73
|
+
// the height via `::details-content` (engines without `interpolate-size` just snap).
|
|
69
74
|
".s-menu-details": {
|
|
70
75
|
"> summary": "list-style:none",
|
|
71
76
|
"> summary::-webkit-details-marker": "display:none",
|
|
@@ -76,8 +81,7 @@ A.insertGlobalCss({
|
|
|
76
81
|
},
|
|
77
82
|
// A branch's children: indented one step.
|
|
78
83
|
".s-menu-sub": "display:flex flex-direction:column gap:$1 padding-left:$3",
|
|
79
|
-
// The standalone `menu()` component's list
|
|
80
|
-
// are `.s-menu-item`s like everywhere else); this only stacks them.
|
|
84
|
+
// The standalone `menu()` component's list; the rows style themselves.
|
|
81
85
|
".s-menu-inline": "display:flex flex-direction:column gap:$1",
|
|
82
86
|
});
|
|
83
87
|
/**
|
|
@@ -100,11 +104,10 @@ A.insertGlobalCss({
|
|
|
100
104
|
export function drawMenu(items, onLeafSelect) {
|
|
101
105
|
// Roving focus via the DOM: query the live item elements on each keypress.
|
|
102
106
|
A("keydown=", (e) => {
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
if (e.key === "Enter" && e.target.tagName === "A") {
|
|
107
|
+
// interceptLinks' own Enter handler preventDefault()s the activation, so no
|
|
108
|
+
// synthetic `click` fires and the click-bound `onLeafSelect` never runs. Close
|
|
109
|
+
// it ourselves, deferred so this keydown finishes navigating first.
|
|
110
|
+
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.shiftKey && !e.altKey && e.target.tagName === "A") {
|
|
108
111
|
queueMicrotask(() => onLeafSelect?.());
|
|
109
112
|
return;
|
|
110
113
|
}
|
|
@@ -124,11 +127,9 @@ export function drawMenu(items, onLeafSelect) {
|
|
|
124
127
|
(cur + dir + els.length) % els.length;
|
|
125
128
|
els[next].focus();
|
|
126
129
|
});
|
|
127
|
-
// Whether the current page is in this menu *at all
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
// no branch can tell on its own. Derived, so the branches re-run only when
|
|
131
|
-
// the answer flips — not on every navigation between two held pages.
|
|
130
|
+
// Whether the current page is in this menu *at all* — a fact no single branch can
|
|
131
|
+
// tell, and one the fold logic needs (see `drawBranch`). Derived, so branches
|
|
132
|
+
// re-run only when the answer flips.
|
|
132
133
|
const $menuHasCurrent = A.derive(() => anyCurrent(items));
|
|
133
134
|
drawEntries(items, onLeafSelect, $menuHasCurrent);
|
|
134
135
|
}
|
|
@@ -149,17 +150,12 @@ function drawEntries(items, onLeafSelect, $menuHasCurrent) {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
function drawLeaf(entry, onLeafSelect) {
|
|
152
|
-
// Whether the aria-current scope below has run before:
|
|
153
|
-
//
|
|
153
|
+
// Whether the aria-current scope below has run before: only a *later* run
|
|
154
|
+
// should animate the reveal.
|
|
154
155
|
let drawn = false;
|
|
155
|
-
// `data-panel=open
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
// body) and `S.main()`'s sidebar are outside every panel and behave this way
|
|
159
|
-
// already; saying it outright makes an inline `menu()` — which may well sit
|
|
160
|
-
// *inside* a panel — behave the same wherever it's drawn. `attrs` comes
|
|
161
|
-
// after, so an item that really does want to stack can say
|
|
162
|
-
// `attrs: "data-panel=push"`.
|
|
156
|
+
// `data-panel=open`: a menu row is navigation, not a link in the content, so the
|
|
157
|
+
// panel it was clicked from isn't context to keep — including for an inline `menu()`
|
|
158
|
+
// drawn inside one. `attrs` comes after, so a row can opt into `data-panel=push`.
|
|
163
159
|
const itemEl = A(entry.href ? "a.s-menu-item data-panel=open" : "button.s-menu-item type=button", entry.attrs, () => {
|
|
164
160
|
if (entry.href) {
|
|
165
161
|
A("href=", entry.href);
|
|
@@ -171,19 +167,16 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
171
167
|
if (!isCurrent(entry))
|
|
172
168
|
return;
|
|
173
169
|
A("aria-current=page");
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
// at all when it's already visible. A row that *starts out*
|
|
178
|
-
// current arrives at the right place (a cold deep link lands
|
|
179
|
-
// with the sidebar already there); when a navigation moves the
|
|
180
|
-
// highlight later, the scroll follows it smoothly. rAF, so a
|
|
181
|
-
// fresh row is laid out before it's measured.
|
|
170
|
+
// In a list taller than its scrollport (a long sidebar nav), the current
|
|
171
|
+
// row can be scrolled out of sight; bring it back — jumping on the first
|
|
172
|
+
// run, gliding on later ones. rAF, so a fresh row is laid out first.
|
|
182
173
|
requestAnimationFrame(() => itemEl.scrollIntoView({ block: "nearest", behavior: first ? "instant" : "smooth" }));
|
|
183
174
|
});
|
|
184
175
|
}
|
|
185
176
|
if (entry.disabled)
|
|
186
177
|
A("aria-disabled=true");
|
|
178
|
+
if (entry.key)
|
|
179
|
+
A("aria-keyshortcuts=", formatKey(entry.key, true));
|
|
187
180
|
A("click=", (e) => {
|
|
188
181
|
if (entry.disabled) {
|
|
189
182
|
e.preventDefault();
|
|
@@ -195,14 +188,13 @@ function drawLeaf(entry, onLeafSelect) {
|
|
|
195
188
|
if (entry.icon)
|
|
196
189
|
A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
197
190
|
drawSlot(entry.label);
|
|
191
|
+
drawKeyHint(entry);
|
|
198
192
|
});
|
|
199
193
|
}
|
|
200
194
|
/**
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
* state would hand it three folded sections in the middle of the user's work.
|
|
205
|
-
* Bounded by the number of distinct branch hrefs an app ever shows.
|
|
195
|
+
* Last route-derived fold state per linked branch, keyed by its selection href.
|
|
196
|
+
* Module-level on purpose: menus remount (the phone's full-page nav exists only
|
|
197
|
+
* while it is open), and per-mount state would refold every section mid-task.
|
|
206
198
|
*/
|
|
207
199
|
const foldMemory = new Map();
|
|
208
200
|
function setFold(href, open) {
|
|
@@ -211,30 +203,21 @@ function setFold(href, open) {
|
|
|
211
203
|
}
|
|
212
204
|
/**
|
|
213
205
|
* A branch: a native `<details>` folding a sub-list of entries in and out. The
|
|
214
|
-
* children stay mounted whether folded or not
|
|
215
|
-
*
|
|
216
|
-
* itself, and nothing around it redraws.
|
|
206
|
+
* children stay mounted whether folded or not, so a fold is a single `open` flip
|
|
207
|
+
* and nothing around it redraws.
|
|
217
208
|
*
|
|
218
|
-
* Clicking the summary row *selects* rather than toggles when there is a page
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
209
|
+
* Clicking the summary row *selects* rather than toggles when there is a page to
|
|
210
|
+
* select (the branch's own `href`, or the first linked leaf below it): it navigates,
|
|
211
|
+
* and the navigation is what unfolds the branch, since a linked branch is open
|
|
212
|
+
* exactly while it holds the current page. Only a branch with no link anywhere below
|
|
213
|
+
* it keeps the native toggle.
|
|
223
214
|
*/
|
|
224
215
|
function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
|
|
225
216
|
const href = entry.href ?? firstLeafHref(entry.items);
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
// its last state — folding everything up would answer a question nobody
|
|
231
|
-
// asked with a menu that forgot where the user was.
|
|
232
|
-
//
|
|
233
|
-
// "Last state" lives in `foldMemory`, not in this closure: menus remount —
|
|
234
|
-
// the phone's full-page nav exists only while it is open — and a remount
|
|
235
|
-
// must find the state where the previous mount left it. It is keyed on the
|
|
236
|
-
// branch's selection href, so the sidebar and the phone nav (two renderings
|
|
237
|
-
// of the same items) share one truth, however often either is rebuilt.
|
|
217
|
+
// Derived, so the attribute scope below re-runs only when the fold answer flips.
|
|
218
|
+
// When the current page is nowhere in the menu, nothing has an opinion and the fold
|
|
219
|
+
// keeps its last state — kept in `foldMemory` rather than this closure, since menus
|
|
220
|
+
// remount, and keyed on the href so both renderings of the items share one truth.
|
|
238
221
|
const $open = href != null
|
|
239
222
|
? A.derive(() => {
|
|
240
223
|
if (containsCurrent(entry))
|
|
@@ -253,10 +236,11 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
|
|
|
253
236
|
A("summary.s-menu-item.s-menu-branch", entry.attrs, () => {
|
|
254
237
|
if (entry.disabled)
|
|
255
238
|
A("aria-disabled=true");
|
|
239
|
+
if (entry.key)
|
|
240
|
+
A("aria-keyshortcuts=", formatKey(entry.key, true));
|
|
256
241
|
A(() => {
|
|
257
|
-
// Current only on its *own* page
|
|
258
|
-
//
|
|
259
|
-
// row carries the highlight, and two highlights would read as two pages.
|
|
242
|
+
// Current only on its *own* page: when a descendant is current, that row
|
|
243
|
+
// carries the highlight, and two highlights would read as two pages.
|
|
260
244
|
if (isCurrent(entry))
|
|
261
245
|
A("aria-current=page");
|
|
262
246
|
});
|
|
@@ -277,15 +261,16 @@ function drawBranch(entry, onLeafSelect, $menuHasCurrent) {
|
|
|
277
261
|
if (entry.icon)
|
|
278
262
|
A("span.s-menu-icon", () => drawSlot(entry.icon));
|
|
279
263
|
drawSlot(entry.label);
|
|
264
|
+
drawKeyHint(entry);
|
|
280
265
|
A("span.s-menu-chevron aria-hidden=true", () => chevronRight());
|
|
281
266
|
});
|
|
282
267
|
A("div.s-menu-sub", () => drawEntries(entry.items, onLeafSelect, $menuHasCurrent));
|
|
283
268
|
});
|
|
284
269
|
}
|
|
285
270
|
/**
|
|
286
|
-
* Whether a row sits inside a closed branch. A closed `<details>` hides its
|
|
287
|
-
*
|
|
288
|
-
*
|
|
271
|
+
* Whether a row sits inside a closed branch. A closed `<details>` hides its content
|
|
272
|
+
* without unmounting it, so arrow-key navigation must skip it — but not the closed
|
|
273
|
+
* branch's own summary row.
|
|
289
274
|
*/
|
|
290
275
|
function foldedAway(el) {
|
|
291
276
|
for (let details = el.closest("details"); details; details = details.parentElement && details.parentElement.closest("details")) {
|
|
@@ -295,18 +280,17 @@ function foldedAway(el) {
|
|
|
295
280
|
return false;
|
|
296
281
|
}
|
|
297
282
|
/**
|
|
298
|
-
* Whether any item anywhere in `items` — branches, their leaves, `match`
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* so the two can never disagree with the highlighting.
|
|
283
|
+
* Whether any item anywhere in `items` — branches, their leaves, `match` claims — is
|
|
284
|
+
* the current page. Exported because the shell's tagline rule (`taglineFits` in
|
|
285
|
+
* main.ts) must agree with the highlighting.
|
|
302
286
|
*/
|
|
303
287
|
export function anyCurrent(items) {
|
|
304
288
|
return items.some((entry) => typeof entry !== "string" && typeof entry !== "function" && !("separator" in entry) && containsCurrent(entry));
|
|
305
289
|
}
|
|
306
290
|
/**
|
|
307
|
-
* Whether this item is the current page: its own `href` matches, or its
|
|
308
|
-
*
|
|
309
|
-
*
|
|
291
|
+
* Whether this item is the current page: its own `href` matches, or its `match`
|
|
292
|
+
* claims the current path. The single test behind `aria-current`, branch unfolding
|
|
293
|
+
* and {@link anyCurrent}.
|
|
310
294
|
*/
|
|
311
295
|
function isCurrent(entry) {
|
|
312
296
|
if (entry.href != null && matchCurrent(entry.href))
|
|
@@ -343,12 +327,76 @@ function firstLeafHref(items) {
|
|
|
343
327
|
}
|
|
344
328
|
return undefined;
|
|
345
329
|
}
|
|
330
|
+
// ─── Keyboard shortcuts ──────────────────────────────────────────────────────
|
|
331
|
+
/** The quiet hint at the right end of a row. See {@link MenuItem.key}. */
|
|
332
|
+
function drawKeyHint(entry) {
|
|
333
|
+
// `aria-hidden`: the row already carries the shortcut as `aria-keyshortcuts`,
|
|
334
|
+
// which is what a screen reader reads out — this is the sighted half.
|
|
335
|
+
if (entry.key)
|
|
336
|
+
A("kbd.s-menu-key aria-hidden=true text=", formatKey(entry.key));
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Bind the shortcuts of every item in a menu — see {@link MenuItem.key} — for as
|
|
340
|
+
* long as the calling scope lives. The `?` overview lists each binding under its
|
|
341
|
+
* item's label. No `aria-keyshortcuts` here — the rows announce their own.
|
|
342
|
+
*
|
|
343
|
+
* `getItems` is a function rather than the array itself, so that the read happens
|
|
344
|
+
* in this scope and not the caller's: a menu's items are often a reactive array,
|
|
345
|
+
* and subscribing the caller (`S.main()`'s whole shell, say) to it would redraw
|
|
346
|
+
* far more than the menu.
|
|
347
|
+
*/
|
|
348
|
+
export function registerMenuKeys(getItems, onSelect) {
|
|
349
|
+
A(() => {
|
|
350
|
+
for (const entry of withKeys(getItems(), [])) {
|
|
351
|
+
bindKey(entry.key, entry.label, (e) => {
|
|
352
|
+
// Picking a row from the keyboard is still picking a row: any menu that
|
|
353
|
+
// happens to be up steps out of the way, as it would on a click.
|
|
354
|
+
closeFloatingMenu();
|
|
355
|
+
onSelect?.();
|
|
356
|
+
entry.click?.(e);
|
|
357
|
+
if (entry.href != null)
|
|
358
|
+
followHref(entry.href, entry.target);
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Every item with a key worth binding, branches and their children included. A
|
|
365
|
+
* disabled one is left out rather than bound and ignored, so its combination
|
|
366
|
+
* stays free — and since `disabled` is read here, flipping it rebinds.
|
|
367
|
+
*/
|
|
368
|
+
function withKeys(items, out) {
|
|
369
|
+
for (const entry of items) {
|
|
370
|
+
if (typeof entry === "string" || typeof entry === "function" || "separator" in entry)
|
|
371
|
+
continue;
|
|
372
|
+
if (entry.key && !entry.disabled)
|
|
373
|
+
out.push(entry);
|
|
374
|
+
if (entry.items)
|
|
375
|
+
withKeys(entry.items, out);
|
|
376
|
+
}
|
|
377
|
+
return out;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Follow a row's `href` from the keyboard. The row may not even be drawn, so this
|
|
381
|
+
* can't just click it — it does what clicking it would: routing an ordinary in-app
|
|
382
|
+
* path, which lands where the row's own `data-panel=open` does (the target getting
|
|
383
|
+
* its own stack of columns, as a nav item's target does), and leaving the links
|
|
384
|
+
* Aberdeen doesn't intercept either — another target, another origin — to the
|
|
385
|
+
* browser.
|
|
386
|
+
*/
|
|
387
|
+
function followHref(href, target) {
|
|
388
|
+
const url = new URL(href, location.href);
|
|
389
|
+
if (target)
|
|
390
|
+
window.open(url.href, target, target === "_blank" ? "noopener" : "");
|
|
391
|
+
else if (url.origin !== location.origin)
|
|
392
|
+
location.href = url.href;
|
|
393
|
+
else
|
|
394
|
+
void go(href);
|
|
395
|
+
}
|
|
346
396
|
// ─── Branch navigations ──────────────────────────────────────────────────────
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
// opening up. So a branch click leaves a note of where it is headed, and the
|
|
351
|
-
// dismiss-on-navigation checks consume it.
|
|
397
|
+
// Menus dismiss themselves when the page navigates — but a branch row navigates in
|
|
398
|
+
// order to expand, and dismissing over that would close the menu mid-unfold. So a
|
|
399
|
+
// branch click leaves a note of where it is headed; the dismiss checks consume it.
|
|
352
400
|
let branchNavPath = null;
|
|
353
401
|
function noteBranchNav(href) {
|
|
354
402
|
try {
|
|
@@ -414,8 +462,8 @@ function positionMenu(menuEl, rect) {
|
|
|
414
462
|
}
|
|
415
463
|
/**
|
|
416
464
|
* The standard entries for the link a menu stands on (see
|
|
417
|
-
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so
|
|
418
|
-
*
|
|
465
|
+
* {@link FloatingMenuOptions.link}). "Open in new tab" is a real new tab, so the
|
|
466
|
+
* target arrives cold, exactly as the link middle-clicked would.
|
|
419
467
|
*/
|
|
420
468
|
function linkItems(href) {
|
|
421
469
|
return [
|
|
@@ -424,10 +472,9 @@ function linkItems(href) {
|
|
|
424
472
|
];
|
|
425
473
|
}
|
|
426
474
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
* needs a secure context, so a failure says so rather than lying.
|
|
475
|
+
* Copy the link's absolute URL to the clipboard, confirmed with a toast since a
|
|
476
|
+
* silent copy leaves you wondering. `writeText` needs a secure context, so a failure
|
|
477
|
+
* says so rather than lying.
|
|
431
478
|
*/
|
|
432
479
|
async function copyLink(href) {
|
|
433
480
|
const url = new URL(href, location.href).href;
|
|
@@ -461,11 +508,9 @@ mountPortal(() => {
|
|
|
461
508
|
closeFloating();
|
|
462
509
|
}
|
|
463
510
|
};
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
// about — doesn't, and neither does a navigation from anywhere else. A branch
|
|
468
|
-
// row expanding is the one navigation that *isn't* a hand-over.
|
|
511
|
+
// Close on any navigation the items didn't already close for: custom slot content,
|
|
512
|
+
// or a navigation from elsewhere. A branch row expanding is the one navigation that
|
|
513
|
+
// isn't a hand-over.
|
|
469
514
|
const openedAt = A.peek(currentRoute, "path");
|
|
470
515
|
A(() => { if (currentRoute.path !== openedAt && !consumeBranchNav(currentRoute.path))
|
|
471
516
|
closeFloating(); });
|
|
@@ -483,9 +528,8 @@ mountPortal(() => {
|
|
|
483
528
|
// pointer location for a context menu — otherwise below the anchor.
|
|
484
529
|
const rect = f.at ? { left: f.at.x, right: f.at.x, top: f.at.y, bottom: f.at.y } : f.anchor.getBoundingClientRect();
|
|
485
530
|
positionMenu(menuEl, rect);
|
|
486
|
-
// Focus the
|
|
487
|
-
//
|
|
488
|
-
// content, like a settings dropdown, not just `.s-menu-item`s).
|
|
531
|
+
// Focus the current-page item if there is one, else the first focusable
|
|
532
|
+
// element (covers custom slot content, not just `.s-menu-item`s).
|
|
489
533
|
focusFirst(menuEl, ".s-menu-item[aria-current=page]");
|
|
490
534
|
});
|
|
491
535
|
});
|
|
@@ -513,13 +557,16 @@ mountPortal(() => {
|
|
|
513
557
|
* ```
|
|
514
558
|
*/
|
|
515
559
|
export function menu(opts) {
|
|
516
|
-
A("nav.s-menu-inline", opts.attrs, () =>
|
|
560
|
+
A("nav.s-menu-inline", opts.attrs, () => {
|
|
561
|
+
registerMenuKeys(() => opts.items, () => opts.onLeafSelect?.());
|
|
562
|
+
drawMenu(opts.items, opts.onLeafSelect);
|
|
563
|
+
});
|
|
517
564
|
}
|
|
518
565
|
/**
|
|
519
566
|
* Open a floating dropdown menu anchored to an element. Portals to
|
|
520
567
|
* `document.body` (never clipped), positions itself (flipping up when there's
|
|
521
|
-
* no room below), and closes on Escape, Tab, item selection,
|
|
522
|
-
* outside the panel and anchor. Returns a `close()` function.
|
|
568
|
+
* no room below), and closes on Escape, Tab, item selection, a navigation, or
|
|
569
|
+
* any click outside the panel and anchor. Returns a `close()` function.
|
|
523
570
|
*
|
|
524
571
|
* Menus are usually opened through {@link menuButton} or
|
|
525
572
|
* {@link addContextMenu}; reach for this primitive when you need to trigger a
|
|
@@ -566,6 +613,9 @@ export function showFloatingMenu(opts) {
|
|
|
566
613
|
* ```
|
|
567
614
|
*/
|
|
568
615
|
export function addContextMenu(opts) {
|
|
616
|
+
// Bound here rather than where the menu is drawn: a shortcut on a context menu
|
|
617
|
+
// is meant to work without right-clicking first (see {@link MenuItem.key}).
|
|
618
|
+
registerMenuKeys(() => opts.items);
|
|
569
619
|
let myEl = null;
|
|
570
620
|
A.clean(() => { if ($floating.opts?.anchor === myEl)
|
|
571
621
|
closeFloating(); });
|
|
@@ -573,8 +623,7 @@ export function addContextMenu(opts) {
|
|
|
573
623
|
e.preventDefault();
|
|
574
624
|
myEl = e.currentTarget;
|
|
575
625
|
// Anchor at the exact click/tap point, and close on a plain click of the
|
|
576
|
-
// element (it has no toggle handler of its own).
|
|
577
|
-
// pass through whole, so a shared option can't be dropped on the way.
|
|
626
|
+
// element (it has no toggle handler of its own).
|
|
578
627
|
showFloatingMenu({
|
|
579
628
|
...opts,
|
|
580
629
|
anchor: myEl,
|
|
@@ -604,6 +653,7 @@ export function addContextMenu(opts) {
|
|
|
604
653
|
* ```
|
|
605
654
|
*/
|
|
606
655
|
export function menuButton(opts) {
|
|
656
|
+
registerMenuKeys(() => opts.items);
|
|
607
657
|
let myEl = null;
|
|
608
658
|
A.clean(() => { if ($floating.opts?.anchor === myEl)
|
|
609
659
|
closeFloating(); });
|