@3sln/nook 0.0.0-stage → 0.2.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/src/index.js ADDED
@@ -0,0 +1,2308 @@
1
+ import { keyboardLabels } from "./ui/keyboardLabels.js";
2
+ import { tutorialGraphic } from "./ui/tutorialGraphics.js";
3
+ import swipeDial, { directionalChoice } from "./ui/swipeDial.js";
4
+ import bounds, { hasAppContent } from "./ui/bounds.js";
5
+ import localization from "./ui/localization.js";
6
+ import feedbackUI from "./ui/feedback.js";
7
+ import enumPicker from "./ui/enumPicker.js";
8
+ import motion from "./ui/motion.js";
9
+ import { sprite, icon } from "./ui/icons.js";
10
+ import { onboardingResource } from "./providers/onboarding.js";
11
+ import commandPalette from "./ui/commandPalette.js";
12
+ import { validCommand } from "./bl/commands.js";
13
+ import { QueryStore } from "@3sln/ngin/queries";
14
+ import { Container, Provider } from "@3sln/ngin/providers";
15
+ import {
16
+ MenuState,
17
+ gesture,
18
+ closestAllowedPosition,
19
+ availablePositions,
20
+ positions,
21
+ responsivePositions,
22
+ corners,
23
+ distributeItems,
24
+ matchesKey,
25
+ presentation,
26
+ inheritedPresentation,
27
+ selectionNumber,
28
+ bindingSupported,
29
+ } from "./bl/menu.js";
30
+ import styles from "./ui/styles.js";
31
+
32
+ const mark = icon("nook");
33
+ const tags =
34
+ "nook-item,nook-action,nook-menu,nook-panel,nook-setting,nook-command";
35
+ const make = (tag, part, text) => {
36
+ const node = document.createElement(tag);
37
+ if (part) node.setAttribute("part", part);
38
+ if (text != null) node.textContent = text;
39
+ return node;
40
+ };
41
+ const emit = (node, name, detail) =>
42
+ node.dispatchEvent(
43
+ new CustomEvent(name, {
44
+ detail,
45
+ bubbles: true,
46
+ composed: true,
47
+ cancelable: true,
48
+ }),
49
+ );
50
+ const childSlot = (item, slot) =>
51
+ [...item.children].find((node) => node.getAttribute("slot") === slot);
52
+ const slotText = (item, slot) => {
53
+ const node = childSlot(item, slot);
54
+ return (
55
+ node instanceof HTMLTemplateElement
56
+ ? node.content.textContent
57
+ : node?.textContent
58
+ )?.trim();
59
+ };
60
+ const isLeaf = (item) => ["nook-item", "nook-action"].includes(item.localName);
61
+ const commandTarget = (item) => {
62
+ if (!isLeaf(item) || !item.hasAttribute("command")) return null;
63
+ const dock = item.closest("nook-dock"),
64
+ id = item.getAttribute("command")?.trim();
65
+ if (!dock || !id) return null;
66
+ return (
67
+ [...dock.querySelectorAll("nook-command")].find(
68
+ (command) => command.id === id && command.closest("nook-dock") === dock,
69
+ ) || null
70
+ );
71
+ };
72
+ const iconSlot = (item) =>
73
+ childSlot(item, "icon") ||
74
+ (commandTarget(item) && childSlot(commandTarget(item), "icon"));
75
+ const label = (item) =>
76
+ item.getAttribute("label") ||
77
+ slotText(item, "label") ||
78
+ commandTarget(item)?.getAttribute("name") ||
79
+ "Untitled";
80
+ const available = (item) => {
81
+ if (item.closest("[hidden],[disabled],template")) return false;
82
+ if (!isLeaf(item) || !item.hasAttribute("command")) return true;
83
+ const command = commandTarget(item);
84
+ return Boolean(
85
+ command &&
86
+ validCommand(command) &&
87
+ !command.closest("[hidden],[disabled],template"),
88
+ );
89
+ };
90
+
91
+ export class NookItem extends HTMLElement {
92
+ constructor() {
93
+ super();
94
+ this.attachShadow({ mode: "open" }).innerHTML =
95
+ "<style>:host{display:none!important}</style>";
96
+ }
97
+ get priority() {
98
+ if (!this.hasAttribute("priority")) return this.root ? 100 : 0;
99
+ const value = Number(this.getAttribute("priority"));
100
+ return Number.isFinite(value) ? value : 0;
101
+ }
102
+ set priority(value) {
103
+ this.setAttribute("priority", String(value));
104
+ }
105
+ get group() {
106
+ return this.getAttribute("group") || "";
107
+ }
108
+ set group(value) {
109
+ this.setAttribute("group", value || "");
110
+ }
111
+ get root() {
112
+ return this.hasAttribute("root");
113
+ }
114
+ set root(value) {
115
+ this.toggleAttribute("root", Boolean(value));
116
+ }
117
+ get command() {
118
+ return this.getAttribute("command") || "";
119
+ }
120
+ set command(value) {
121
+ if (value == null || value === "") this.removeAttribute("command");
122
+ else this.setAttribute("command", String(value));
123
+ }
124
+ get keybind() {
125
+ if (isLeaf(this) && this.hasAttribute("command"))
126
+ return commandTarget(this)?.getAttribute("keybind") || "";
127
+ // Legacy actions retain their old binding; new items put bindings on commands.
128
+ if (this.localName === "nook-item") return "";
129
+ return this.getAttribute("keybind") || "";
130
+ }
131
+ set keybind(value) {
132
+ this.setAttribute("keybind", value);
133
+ }
134
+ get value() {
135
+ return this._control
136
+ ? this._control.type === "checkbox"
137
+ ? this._control.checked
138
+ : this._control.value
139
+ : this.getAttribute("value");
140
+ }
141
+ set value(value) {
142
+ if (this._control) {
143
+ if (this._control.type === "checkbox")
144
+ this._control.checked = Boolean(value);
145
+ else this._control.value = value;
146
+ }
147
+ this.setAttribute("value", String(value));
148
+ }
149
+ }
150
+
151
+ export class NookDock extends HTMLElement {
152
+ static observedAttributes = [
153
+ "corner",
154
+ "position",
155
+ "position-policy",
156
+ "lang",
157
+ "boundary",
158
+ "disabled-corners",
159
+ "disabled-positions",
160
+ "root-keybind",
161
+ "settings-keybind",
162
+ "settings-href",
163
+ "quick-keybind",
164
+ "all-keybind",
165
+ "overflow-keybind",
166
+ "feedback-keybind",
167
+ "commands-keybind",
168
+ "feedback-disabled",
169
+ ];
170
+ constructor() {
171
+ super();
172
+ this.attachShadow({ mode: "open" });
173
+ this.i18n = localization(this, make);
174
+ this.make = this.i18n.make;
175
+ const style = this.make("style");
176
+ style.textContent = styles;
177
+ this.shadowRoot.append(style);
178
+ this.appSlot = this.make("slot", "app");
179
+ this.shadowRoot.append(this.appSlot);
180
+ const symbols = this.make("template");
181
+ symbols.innerHTML = sprite;
182
+ this.shadowRoot.append(symbols.content);
183
+ this.anchor = this.make("div", "anchor");
184
+ this.anchor.className = "anchor";
185
+ this.handle = this.make("button", "handle");
186
+ this.handle.className = "handle";
187
+ this.handle.type = "button";
188
+ this.i18n.attr(this.handle, "aria-label", "Open Nook menu");
189
+ this.handle.setAttribute("aria-haspopup", "dialog");
190
+ const icon = this.make("span", "mark");
191
+ icon.className = "mark";
192
+ icon.innerHTML = mark;
193
+ this.handleTip = this.make("span", "handle-tooltip");
194
+ this.handleTip.className = "handle-tooltip";
195
+ this.handleTip.setAttribute("role", "tooltip");
196
+ this.handleTipShortcut = this.make("span", "handle-shortcut");
197
+ const dragHint = this.make("span", "drag-hint", "Hold, then drag to move.");
198
+ this.handleTip.append(this.handleTipShortcut, dragHint);
199
+ this.updateHandleTip();
200
+ this.handle.append(icon, this.handleTip);
201
+ this.live = this.make("div", "announcement");
202
+ this.live.className = "live";
203
+ this.live.setAttribute("aria-live", "polite");
204
+ this.rootMeasure = this.make("span");
205
+ this.rootMeasure.className = "root-measure";
206
+ this.rootMeasure.hidden = true;
207
+ this.anchor.append(this.handle, this.live, this.rootMeasure);
208
+ this.overlayAnchor = this.make("div", "overlay-anchor");
209
+ this.overlayAnchor.className = "anchor overlay-anchor";
210
+ // Keep the handle above overlay material when their configured elevations match.
211
+ this.shadowRoot.append(this.overlayAnchor, this.anchor);
212
+ const mirrored = [
213
+ "data-corner",
214
+ "data-position",
215
+ "data-numbering",
216
+ "data-alt-held",
217
+ "data-layout-ready",
218
+ "data-draggable",
219
+ "lang",
220
+ ];
221
+ this.syncLayers = () => {
222
+ for (const name of mirrored) {
223
+ const value = this.anchor.getAttribute(name);
224
+ if (value === null) this.overlayAnchor.removeAttribute(name);
225
+ else this.overlayAnchor.setAttribute(name, value);
226
+ }
227
+ };
228
+ this.layerObserver = new MutationObserver(this.syncLayers);
229
+ this.mirroredAttributes = mirrored;
230
+ this.anchor.lang = this.locale;
231
+ this.preview = this.make("div", "drag-preview");
232
+ this.preview.className = "anchor preview";
233
+ this.preview.hidden = true;
234
+ this.preview.setAttribute("aria-hidden", "true");
235
+ const previewHandle = this.make("div", "preview-handle");
236
+ previewHandle.className = "handle";
237
+ const previewMark = this.make("span", "preview-mark");
238
+ previewMark.className = "mark";
239
+ previewMark.innerHTML = mark;
240
+ previewHandle.append(previewMark);
241
+ this.preview.append(previewHandle);
242
+ this.shadowRoot.append(this.preview);
243
+ this.landingSpots = new Map();
244
+ for (const corner of positions) {
245
+ const spot = this.make("div", "landing-zone");
246
+ spot.className = "anchor landing-zone";
247
+ spot.dataset.corner = corner;
248
+ spot.hidden = true;
249
+ spot.setAttribute("aria-hidden", "true");
250
+ const shape = this.make("div", "landing-outline");
251
+ shape.className = "handle";
252
+ spot.append(shape);
253
+ this.landingSpots.set(corner, spot);
254
+ this.shadowRoot.append(spot);
255
+ }
256
+
257
+ this.records = new Map();
258
+ this.views = new Map();
259
+ this.intents = new EventTarget();
260
+ this.handle.addEventListener("click", () => {
261
+ this.dismissHandleTip();
262
+ if (this.suppressClick) {
263
+ this.suppressClick = false;
264
+ return;
265
+ }
266
+ this.send({
267
+ type: this.state?.path.length ? "close" : "open",
268
+ view: "root",
269
+ });
270
+ });
271
+ this.handle.addEventListener("contextmenu", (e) => e.preventDefault());
272
+ this.handle.addEventListener("pointerleave", () =>
273
+ this.handle.removeAttribute("data-tooltip-dismissed"),
274
+ );
275
+ this.handle.addEventListener("focus", () => {
276
+ if (this.lastKey === "Tab")
277
+ this.handle.removeAttribute("data-tooltip-dismissed");
278
+ });
279
+ }
280
+ connectedCallback() {
281
+ this.queryReady = false;
282
+ this.syncLayers();
283
+ this.layerObserver.observe(this.anchor, {
284
+ attributes: true,
285
+ attributeFilter: this.mirroredAttributes,
286
+ });
287
+ this.abort = new AbortController();
288
+ const signal = this.abort.signal;
289
+ this.positionMedia = this.ownerDocument.defaultView.matchMedia(
290
+ "(pointer: coarse), (hover: none)",
291
+ );
292
+ this.motion = motion({
293
+ window: this.ownerDocument.defaultView,
294
+ signal,
295
+ host: this,
296
+ });
297
+ const keyboard = this.ownerDocument.defaultView.navigator.keyboard;
298
+ let layoutRequest = 0;
299
+ const readLayout = async () => {
300
+ const request = ++layoutRequest;
301
+ this.browserKeyboardLayout = undefined;
302
+ this.anchor.removeAttribute("data-layout-ready");
303
+ delete this.anchor.dataset.numbering;
304
+ if (this.palette) this.reconcile();
305
+ let layout;
306
+ try {
307
+ const reported = await keyboard?.getLayoutMap?.();
308
+ if (
309
+ reported &&
310
+ typeof reported.get === "function" &&
311
+ typeof reported.values === "function"
312
+ )
313
+ layout = reported;
314
+ } catch {}
315
+ if (signal.aborted || request !== layoutRequest) return;
316
+ this.browserKeyboardLayout = layout;
317
+ this.anchor.toggleAttribute("data-layout-ready", Boolean(layout));
318
+ this.reconcile();
319
+ if (this.altHeld) this.showNumbers();
320
+ };
321
+ this.refreshKeyboardLayout = readLayout;
322
+ void readLayout();
323
+ keyboard?.addEventListener?.("layoutchange", readLayout, { signal });
324
+ this.ownerDocument.defaultView.addEventListener("focus", readLayout, {
325
+ signal,
326
+ });
327
+ this.boundsController = bounds(this, {
328
+ signal,
329
+ onChange: (rect) => {
330
+ this.anchorRect = rect;
331
+ if (this.state) this.refreshPositions();
332
+ },
333
+ });
334
+ this.buildViews();
335
+ this.swipeDial = swipeDial({
336
+ dock: this,
337
+ make: this.make,
338
+ i18n: this.i18n,
339
+ signal,
340
+ });
341
+ this.feedbackUI?.dispose();
342
+ this.feedbackUI = feedbackUI({
343
+ view: this.views.get("feedback"),
344
+ make: this.make,
345
+ i18n: this.i18n,
346
+ dock: this,
347
+ getHooks: () => this.feedback,
348
+ signal,
349
+ });
350
+ let storage;
351
+ try {
352
+ storage = this.ownerDocument.defaultView.localStorage;
353
+ } catch {}
354
+ this.container = new Container({
355
+ providers: {
356
+ intents: Provider.fromSingleton(this.intents),
357
+ onboarding: Provider.fromSingleton(
358
+ onboardingResource({ storage, owner: this.ownerDocument }),
359
+ ),
360
+ },
361
+ });
362
+ this.queries = new QueryStore({ container: this.container });
363
+ this.subscription = this.queries
364
+ .query(
365
+ new MenuState(
366
+ this.getAttribute("position") || this.getAttribute("corner"),
367
+ this.blockedPositions(),
368
+ this.viewportPositions(true),
369
+ ),
370
+ )
371
+ .subscribe((state) => this.project(state));
372
+ this.observer = new MutationObserver((records) => {
373
+ if (
374
+ records.some(
375
+ (record) => record.target.getRootNode() !== this.shadowRoot,
376
+ )
377
+ )
378
+ this.reconcile();
379
+ });
380
+ this.observer.observe(this, {
381
+ subtree: true,
382
+ childList: true,
383
+ attributes: true,
384
+ characterData: true,
385
+ });
386
+ this.reconcile();
387
+ this.ownerDocument.addEventListener("keydown", (e) => this.keydown(e), {
388
+ signal,
389
+ });
390
+ const clearNumbers = () => {
391
+ this.altHeld = false;
392
+ this.anchor.removeAttribute("data-alt-held");
393
+ delete this.anchor.dataset.numbering;
394
+ };
395
+ this.ownerDocument.addEventListener(
396
+ "keyup",
397
+ (event) => {
398
+ if (event.key === "Alt" || !event.altKey) clearNumbers();
399
+ },
400
+ { signal },
401
+ );
402
+ this.ownerDocument.defaultView.addEventListener("blur", clearNumbers, {
403
+ signal,
404
+ });
405
+ this.ownerDocument.addEventListener("visibilitychange", clearNumbers, {
406
+ signal,
407
+ });
408
+ this.ownerDocument.addEventListener(
409
+ "pointerdown",
410
+ (e) => {
411
+ if (this.state?.path.length && !e.composedPath().includes(this))
412
+ this.close();
413
+ },
414
+ { signal },
415
+ );
416
+ this.handle.addEventListener("pointerdown", (e) => this.pointerdown(e), {
417
+ signal,
418
+ });
419
+ this.handle.addEventListener("pointermove", (e) => this.pointermove(e), {
420
+ signal,
421
+ });
422
+ this.handle.addEventListener("pointerup", (e) => this.pointerup(e), {
423
+ signal,
424
+ });
425
+ this.handle.addEventListener("pointercancel", () => this.cancelPointer(), {
426
+ signal,
427
+ });
428
+ this.handle.addEventListener(
429
+ "lostpointercapture",
430
+ () => this.cancelPointer(),
431
+ { signal },
432
+ );
433
+ this.ownerDocument.defaultView.addEventListener(
434
+ "blur",
435
+ () => this.cancelPointer(),
436
+ { signal },
437
+ );
438
+
439
+ this.ownerDocument.defaultView.addEventListener(
440
+ "resize",
441
+ () => this.refreshPositions(),
442
+ { signal },
443
+ );
444
+ this.positionMedia.addEventListener(
445
+ "change",
446
+ () => this.refreshPositions(true),
447
+ { signal },
448
+ );
449
+ this.ownerDocument.defaultView.addEventListener(
450
+ "orientationchange",
451
+ () => this.refreshPositions(true),
452
+ { signal },
453
+ );
454
+ this.ownerDocument.defaultView.screen?.orientation?.addEventListener(
455
+ "change",
456
+ () => this.refreshPositions(true),
457
+ { signal },
458
+ );
459
+ }
460
+ disconnectedCallback() {
461
+ this.queryReady = false;
462
+ this.pendingIntents = [];
463
+ this.layerObserver.disconnect();
464
+ this.altHeld = false;
465
+ this.anchor.removeAttribute("data-alt-held");
466
+ delete this.anchor.dataset.numbering;
467
+ this.abort?.abort();
468
+ this.observer?.disconnect();
469
+ this.cancelPointer();
470
+ for (const view of this.views.values()) {
471
+ view.exit?.cancel();
472
+ view.exit = null;
473
+ }
474
+ this.subscription?.unsubscribe();
475
+ const queries = this.queries,
476
+ container = this.container;
477
+ void queries?.dispose().then(() => container.dispose());
478
+ }
479
+ get captureRoot() {
480
+ if (!this.anchorRect?.wrapped) return this.ownerDocument.documentElement;
481
+ const app = [...this.children].filter(
482
+ (node) =>
483
+ !node.localName.startsWith("nook-") &&
484
+ !["template", "style", "script"].includes(node.localName),
485
+ );
486
+ return app.length === 1 ? app[0] : this;
487
+ }
488
+ get captureExclusions() {
489
+ return [
490
+ this.anchor,
491
+ this.overlayAnchor,
492
+ this.preview,
493
+ ...this.landingSpots.values(),
494
+ ];
495
+ }
496
+ get localization() {
497
+ return this._localization;
498
+ }
499
+ set localization(value) {
500
+ this._localization = value;
501
+ this.i18n.configure(value);
502
+ if (this.isConnected) this.reconcile();
503
+ }
504
+ get locale() {
505
+ return this.i18n.locale;
506
+ }
507
+ get settingsHref() {
508
+ return this.getAttribute("settings-href")?.trim() || "";
509
+ }
510
+ set settingsHref(value) {
511
+ if (value == null || String(value).trim() === "")
512
+ this.removeAttribute("settings-href");
513
+ else this.setAttribute("settings-href", String(value));
514
+ }
515
+ get priorities() {
516
+ return this._priorities;
517
+ }
518
+ set priorities(value) {
519
+ this._priorities = value;
520
+ if (this.isConnected) this.reconcile();
521
+ }
522
+ surfaceAvailable(id) {
523
+ if (id === "all") return Boolean(this.allEntries?.length);
524
+ return id === "quick" || id === "full" || id === "overflow"
525
+ ? Boolean(this.overflowEntries?.length)
526
+ : this.availableBuiltins?.has(id);
527
+ }
528
+ get tutorial() {
529
+ return this._tutorial;
530
+ }
531
+ set tutorial(options) {
532
+ this._tutorial = options;
533
+ if (this.isConnected) this.reconcile();
534
+ }
535
+ get feedback() {
536
+ return this._feedback;
537
+ }
538
+ set feedback(hooks) {
539
+ this._feedback = hooks;
540
+ if (this.isConnected) this.reconcile();
541
+ }
542
+ feedbackDisabled() {
543
+ return this._feedback === false || this.hasAttribute("feedback-disabled");
544
+ }
545
+ viewportPositions(force = false) {
546
+ const view = this.ownerDocument.defaultView;
547
+ const width = this.anchorRect?.wrapped
548
+ ? this.anchorRect.width
549
+ : view.innerWidth,
550
+ height = this.anchorRect?.wrapped
551
+ ? this.anchorRect.height
552
+ : view.innerHeight;
553
+ let active = this.ownerDocument.activeElement;
554
+ while (active?.shadowRoot?.activeElement)
555
+ active = active.shadowRoot.activeElement;
556
+ const editing =
557
+ active?.isContentEditable ||
558
+ active?.matches(
559
+ "input,textarea,select,[contenteditable]:not([contenteditable=false])",
560
+ );
561
+ if (
562
+ force ||
563
+ !this.policyViewport ||
564
+ !editing ||
565
+ width !== this.policyViewport.width
566
+ )
567
+ this.policyViewport = { width, height };
568
+ return this.getAttribute("position-policy") === "all"
569
+ ? [...positions]
570
+ : responsivePositions({
571
+ ...this.policyViewport,
572
+ touch:
573
+ this.positionMedia?.matches ??
574
+ view.matchMedia("(pointer: coarse), (hover: none)").matches,
575
+ });
576
+ }
577
+ refreshPositions(force = false) {
578
+ this.send({
579
+ type: "configure-positions",
580
+ disabled: this.blockedPositions(),
581
+ options: this.viewportPositions(force),
582
+ });
583
+ if (this.isConnected) this.reconcile();
584
+ }
585
+ get availablePositions() {
586
+ if (this.state) return [...this.state.allowedPositions];
587
+ const options = this.viewportPositions();
588
+ return availablePositions(this.blockedPositions()).filter((position) =>
589
+ options.includes(position),
590
+ );
591
+ }
592
+ get preferredPosition() {
593
+ return this.state?.preferredPosition || this.position;
594
+ }
595
+ get position() {
596
+ return (
597
+ this.state?.corner ||
598
+ this.getAttribute("position") ||
599
+ this.getAttribute("corner") ||
600
+ "bottom-right"
601
+ );
602
+ }
603
+ set position(value) {
604
+ this.setAttribute("position", value);
605
+ }
606
+ blockedPositions() {
607
+ return `${this.getAttribute("disabled-corners") || ""} ${this.getAttribute("disabled-positions") || ""}`;
608
+ }
609
+ get disabledPositions() {
610
+ const allowed = availablePositions(this.blockedPositions());
611
+ return positions.filter((position) => !allowed.includes(position));
612
+ }
613
+ set disabledPositions(value) {
614
+ this.setAttribute(
615
+ "disabled-positions",
616
+ Array.isArray(value) ? value.join(" ") : String(value || ""),
617
+ );
618
+ }
619
+ get disabledCorners() {
620
+ return this.disabledPositions.filter((position) =>
621
+ corners.includes(position),
622
+ );
623
+ }
624
+ set disabledCorners(value) {
625
+ this.setAttribute(
626
+ "disabled-corners",
627
+ Array.isArray(value) ? value.join(" ") : String(value || ""),
628
+ );
629
+ }
630
+ canDrag() {
631
+ if (this.anchorRect && !this.anchorRect.visible) return false;
632
+ return (
633
+ this.state?.allowedPositions ||
634
+ availablePositions(this.blockedPositions())
635
+ ).some((position) => position !== this.position);
636
+ }
637
+ dismissHandleTip() {
638
+ this.handle.setAttribute("data-tooltip-dismissed", "");
639
+ }
640
+ updateHandleTip() {
641
+ if (!this.handleTipShortcut) return;
642
+ const binding = this.hasAttribute("root-keybind")
643
+ ? this.getAttribute("root-keybind")
644
+ : "Shift+?";
645
+ const supported = bindingSupported(binding, this.browserKeyboardLayout);
646
+ this.i18n.text(
647
+ this.handleTipShortcut,
648
+ supported ? "Menu · {keybind}" : "Menu",
649
+ {
650
+ keybind: keyboardLabels(
651
+ this.ownerDocument.defaultView.navigator,
652
+ ).format(binding),
653
+ },
654
+ );
655
+ }
656
+ attributeChangedCallback(name, oldValue, value) {
657
+ if (
658
+ oldValue === value &&
659
+ !(
660
+ ["corner", "position"].includes(name) &&
661
+ !this.reflectingCorner &&
662
+ this.state &&
663
+ this.state.preferredPosition !== value
664
+ )
665
+ )
666
+ return;
667
+ if (name === "boundary") this.boundsController?.refresh();
668
+ if (name === "lang") {
669
+ this.i18n.refresh();
670
+ if (this.isConnected) this.reconcile();
671
+ }
672
+ if (name === "root-keybind") this.updateHandleTip();
673
+ if (name.endsWith("-keybind") && this.isConnected) this.reconcile();
674
+ if (
675
+ ["feedback-disabled", "settings-href"].includes(name) &&
676
+ this.isConnected
677
+ )
678
+ this.reconcile();
679
+ if (
680
+ ["disabled-corners", "disabled-positions", "position-policy"].includes(
681
+ name,
682
+ )
683
+ )
684
+ this.refreshPositions();
685
+ if (["corner", "position"].includes(name) && !this.reflectingCorner) {
686
+ this.send({ type: "corner", corner: value });
687
+ if (this.isConnected) this.reconcile();
688
+ }
689
+ }
690
+ send(detail) {
691
+ if (this.isConnected && !this.queryReady) {
692
+ (this.pendingIntents ??= []).push(detail);
693
+ return;
694
+ }
695
+ this.intents.dispatchEvent(new CustomEvent("intent", { detail }));
696
+ }
697
+ open(view = "root") {
698
+ if (view === "overflow" || view === "quick") view = "full";
699
+ if (!this.views.has(view)) return;
700
+ if (["commands", "feedback"].includes(view) && !this.surfaceAvailable(view))
701
+ return;
702
+ this.send({ type: "open", view });
703
+ }
704
+ close() {
705
+ this.send({ type: "close" });
706
+ }
707
+ view(id, title) {
708
+ if (this.views.has(id)) return this.views.get(id);
709
+ const el = this.make("section", "surface");
710
+ el.className = "surface";
711
+ el.dataset.view =
712
+ typeof id === "string" ? id : id.localName.replace("nook-", "");
713
+ el.hidden = true;
714
+ el.setAttribute("role", "dialog");
715
+ this.i18n.attr(el, "aria-label", title);
716
+ const header = this.make("header", "header");
717
+ const back = this.make("button", "back");
718
+ back.innerHTML = icon("back");
719
+ back.className = "quiet";
720
+ this.i18n.attr(back, "aria-label", "Go back");
721
+ back.onclick = () => this.send({ type: "back" });
722
+ const heading = this.make("h2", "heading", title);
723
+ const close = this.make("button", "close");
724
+ close.innerHTML = icon("close");
725
+ close.className = "quiet";
726
+ this.i18n.attr(close, "aria-label", "Close menu");
727
+ close.onclick = () => this.close();
728
+ header.append(back, heading, close);
729
+ const body = this.make("div", "body");
730
+ el.append(header, body);
731
+ this.overlayAnchor.append(el);
732
+ const result = { el, body, heading, back };
733
+ this.views.set(id, result);
734
+ return result;
735
+ }
736
+ buildViews() {
737
+ if (this.views.has("root")) return;
738
+ for (const [id, title] of [
739
+ ["root", "Nook"],
740
+ ["full", "More"],
741
+ ["all", "Full menu"],
742
+ ["settings", "Quick settings"],
743
+ ["commands", "Commands"],
744
+ ["welcome", "Quick tour"],
745
+ ["feedback", "Feedback"],
746
+ ])
747
+ this.view(id, title);
748
+ for (const id of ["root", "full", "all"]) {
749
+ const grid = this.make("div", "items");
750
+ grid.className = "items";
751
+ this.views.get(id).body.append(grid);
752
+ this.views.get(id).grid = grid;
753
+ }
754
+ const settings = this.views.get("settings");
755
+ settings.footer = this.make("div", "settings-footer");
756
+ settings.footer.hidden = true;
757
+ settings.link = this.make("a", "settings-link", "All settings");
758
+ settings.footer.append(settings.link);
759
+ settings.body.append(settings.footer);
760
+ const welcome = this.views.get("welcome");
761
+ const copy = this.make("div", "tutorial");
762
+ copy.className = "tutorial";
763
+ const keyboard = this.make("div", "tutorial-desktop");
764
+ keyboard.className = "keyboard-only";
765
+ const lead = this.make(
766
+ "p",
767
+ null,
768
+ "Arrows move; Enter selects. Backspace goes back; Esc closes.",
769
+ );
770
+ const numberTip = this.make(
771
+ "p",
772
+ null,
773
+ "Hold Alt / Option for labels and numbers.",
774
+ );
775
+ numberTip.className = "layout-hint";
776
+ const keyboardGraphic = tutorialGraphic(this.make, "keyboard");
777
+ this.i18n.text(
778
+ keyboardGraphic.querySelector(".tutorial-enter-label"),
779
+ "Enter",
780
+ );
781
+ const shortcuts = this.make("dl", "tutorial-shortcuts");
782
+ shortcuts.className = "tutorial-shortcuts";
783
+ const shortcutRows = new Map();
784
+ for (const [id, title] of [
785
+ ["root", "Menu"],
786
+ ["all", "Full menu"],
787
+ ["settings", "Quick settings"],
788
+ ["commands", "Commands"],
789
+ ["feedback", "Feedback"],
790
+ ]) {
791
+ const row = this.make("div", "tutorial-shortcut");
792
+ const label = this.make("dt", "tutorial-shortcut-label", title);
793
+ const value = this.make("kbd", "tutorial-shortcut-keybind");
794
+ const description = this.make("dd");
795
+ description.append(value);
796
+ row.append(label, description);
797
+ shortcuts.append(row);
798
+ shortcutRows.set(id, { row, value });
799
+ }
800
+ keyboard.append(shortcuts, keyboardGraphic, lead, numberTip);
801
+ const touch = this.make("div", "tutorial-touch");
802
+ touch.className = "touch-only";
803
+ const touchLead = this.make(
804
+ "p",
805
+ null,
806
+ "Tap to open. Swipe sideways for the full menu or vertically for settings.",
807
+ );
808
+ const touchGraphic = tutorialGraphic(this.make, "touch");
809
+ touch.append(
810
+ touchGraphic,
811
+ touchLead,
812
+ this.make(
813
+ "p",
814
+ null,
815
+ "Hold a swipe to pick. Release elsewhere to cancel.",
816
+ ),
817
+ );
818
+ const welcomeMove = this.make(
819
+ "p",
820
+ "drag-hint",
821
+ "Hold Nook, then drag to move it.",
822
+ );
823
+ welcomeMove.setAttribute("part", "drag-hint tutorial-drag");
824
+ const dragGraphic = tutorialGraphic(this.make, "drag");
825
+ copy.append(keyboard, touch, welcomeMove, dragGraphic);
826
+ const done = this.make("button", "tutorial-continue", "Got it");
827
+ done.className = "command-run";
828
+ done.onclick = () => {
829
+ let target = this.state?.introTarget;
830
+ if (typeof target !== "string" && !this.records.has(target))
831
+ target = "root";
832
+ if (
833
+ ["commands", "settings", "feedback", "full", "all"].includes(target) &&
834
+ !this.surfaceAvailable(target)
835
+ )
836
+ target = "root";
837
+ this.send({ type: "finish-intro", view: target || "root" });
838
+ };
839
+ welcome.body.append(copy, done);
840
+
841
+ const root = this.views.get("root");
842
+ this.tutorialUI = {
843
+ welcome,
844
+ done,
845
+ lead,
846
+ numberTip,
847
+ shortcuts,
848
+ shortcutRows,
849
+ regions: [
850
+ ["tutorial", copy],
851
+ ["tutorial-desktop", keyboard],
852
+ ["tutorial-touch", touch],
853
+ ["tutorial-drag", welcomeMove],
854
+ ["tutorial-keyboard-graphic", keyboardGraphic],
855
+ ["tutorial-touch-graphic", touchGraphic],
856
+ ["tutorial-drag-graphic", dragGraphic],
857
+ ].map(([name, element]) => ({
858
+ name,
859
+ element,
860
+ defaults: [...element.childNodes],
861
+ template: null,
862
+ })),
863
+ };
864
+ this.builtins = new Map();
865
+ for (const [id, title, iconText] of [
866
+ ["feedback", "Feedback", icon("feedback")],
867
+ ["quick", "More", icon("more")],
868
+ ["all", "Full menu", icon("menu")],
869
+ ["welcome", "Quick tour", icon("guide")],
870
+ ["settings", "Settings", icon("settings")],
871
+ ["commands", "Commands", icon("commands")],
872
+ ]) {
873
+ const b = this.itemButton(null, title, iconText);
874
+ b.onclick = () =>
875
+ this.send({ type: "enter", view: id === "quick" ? "full" : id });
876
+ if (id === "welcome") {
877
+ const badge = this.make("span", "tutorial-badge");
878
+ badge.className = "tutorial-badge";
879
+ badge.setAttribute("aria-hidden", "true");
880
+ b.append(badge);
881
+ this.i18n.attr(b, "aria-description", "New: learn how to use Nook.");
882
+ }
883
+ this.builtins.set(id, b);
884
+ root.grid.append(b);
885
+ }
886
+ this.palette = commandPalette({
887
+ view: this.views.get("commands"),
888
+ make: this.make,
889
+ i18n: this.i18n,
890
+ choose: (source) => this.chooseCommand(source),
891
+ run: (source, args) => this.runCommand(source, args),
892
+ createEnum: (source, title, commit, initialValue) =>
893
+ this.createEnum(source, title, commit, initialValue),
894
+ supported: (binding) =>
895
+ bindingSupported(binding, this.browserKeyboardLayout),
896
+ });
897
+ }
898
+ itemButton(
899
+ item,
900
+ title,
901
+ iconText = icon(item && commandTarget(item) ? "commands" : "file"),
902
+ ) {
903
+ const button = this.make("button", "item");
904
+ button.className = "item";
905
+ button.type = "button";
906
+ const icon = this.make("span", "icon");
907
+ icon.className = "icon";
908
+ const template = item && iconSlot(item);
909
+ if (template)
910
+ icon.append(
911
+ template instanceof HTMLTemplateElement
912
+ ? template.content.cloneNode(true)
913
+ : template.cloneNode(true),
914
+ );
915
+ else icon.innerHTML = iconText;
916
+ const text = this.make("span", "item-label", title);
917
+ text.className = "item-label";
918
+ const tooltip = this.make("span", "tooltip");
919
+ const tooltipText = this.make("span", "tooltip-text");
920
+ const binding =
921
+ item?.keybind &&
922
+ bindingSupported(item.keybind, this.browserKeyboardLayout)
923
+ ? item.keybind
924
+ : "";
925
+ this.i18n.text(
926
+ tooltipText,
927
+ binding ? "{label} · {keybind}" : "{label}",
928
+ () => ({ label: this.i18n.t(title), keybind: binding }),
929
+ );
930
+ tooltip.append(tooltipText);
931
+ tooltipText.className = "tooltip-text";
932
+ tooltip.className = "tooltip";
933
+ tooltip.setAttribute("aria-hidden", "true");
934
+ const number = this.make("span", "number");
935
+ number.className = "number";
936
+ number.setAttribute("aria-hidden", "true");
937
+ button.append(icon, text, tooltip, number);
938
+ this.i18n.attr(button, "aria-label", title);
939
+ return button;
940
+ }
941
+ reconcileTutorialShortcuts(items) {
942
+ const ui = this.tutorialUI;
943
+ if (!ui) return;
944
+ const labels = keyboardLabels(this.ownerDocument.defaultView.navigator);
945
+ this.i18n.text(
946
+ ui.lead,
947
+ "Arrows move; Enter selects. {back} goes back; Esc closes.",
948
+ { back: this.i18n.t(labels.back) },
949
+ );
950
+ this.i18n.text(ui.numberTip, "Hold {modifier} for labels and numbers.", {
951
+ modifier: this.i18n.t(labels.alt),
952
+ });
953
+ ui.numberTip.hidden =
954
+ !this.browserKeyboardLayout ||
955
+ ![...this.browserKeyboardLayout.values()].some((value) =>
956
+ /^[1-9]$/.test(value),
957
+ );
958
+ const defaults = {
959
+ root: "Shift+?",
960
+ all: "Alt+Shift+M",
961
+ settings: "Alt+Shift+S",
962
+ commands: labels.mac ? "Meta+Shift+P" : "Ctrl+Shift+P",
963
+ feedback: "Alt+Shift+F",
964
+ };
965
+ let visible = 0;
966
+ for (const [id, { row, value }] of ui.shortcutRows) {
967
+ const attribute = `${id}-keybind`;
968
+ const binding = this.hasAttribute(attribute)
969
+ ? this.getAttribute(attribute)
970
+ : defaults[id];
971
+ const available =
972
+ id === "root" ||
973
+ this.surfaceAvailable(id) ||
974
+ (id === "feedback" &&
975
+ !this.feedbackDisabled() &&
976
+ items.some((item) => item.hasAttribute("feedback")));
977
+ row.hidden =
978
+ !available || !bindingSupported(binding, this.browserKeyboardLayout);
979
+ if (!row.hidden) visible++;
980
+ const text = labels.format(binding);
981
+ if (value.textContent !== text) value.textContent = text;
982
+ }
983
+ ui.shortcuts.hidden = !visible;
984
+ }
985
+
986
+ reconcileTutorial() {
987
+ const ui = this.tutorialUI;
988
+ if (!ui) return;
989
+ const options = this.tutorial || {};
990
+ const guide = this.builtins?.get("welcome");
991
+ if (guide) {
992
+ const title = options.label ?? "Quick tour";
993
+ this.i18n.attr(guide, "aria-label", title);
994
+ this.i18n.text(guide.querySelector(".item-label"), title);
995
+ this.i18n.text(guide.querySelector(".tooltip-text"), title);
996
+ }
997
+ for (const [view, title] of [[ui.welcome, options.title ?? "Quick tour"]]) {
998
+ this.i18n.text(view.heading, title);
999
+ this.i18n.attr(view.el, "aria-label", title);
1000
+ }
1001
+ for (const [element, text] of [
1002
+ [ui.done, options.continueLabel ?? "Got it"],
1003
+ ])
1004
+ this.i18n.text(element, text);
1005
+ for (const region of ui.regions) {
1006
+ const template =
1007
+ [...this.children].find(
1008
+ (child) =>
1009
+ child.localName === "template" &&
1010
+ child.getAttribute("slot") === region.name,
1011
+ ) || null;
1012
+ if (template !== region.template) {
1013
+ region.element.replaceChildren(
1014
+ ...(template ? [template.content.cloneNode(true)] : region.defaults),
1015
+ );
1016
+ region.template = template;
1017
+ }
1018
+ }
1019
+ }
1020
+ reconcile() {
1021
+ if (!this.isConnected || !this.builtins) return;
1022
+ const focused = this.shadowRoot.activeElement;
1023
+ const focusedView = focused?.closest(".surface")?.dataset.view;
1024
+ this.reconcileTutorial();
1025
+ this.updateHandleTip();
1026
+ const items = [...this.querySelectorAll(tags)].filter(
1027
+ (item) =>
1028
+ item.closest("nook-dock") === this &&
1029
+ available(item) &&
1030
+ !(this.feedbackDisabled() && item.closest("[feedback]")) &&
1031
+ (item.localName !== "nook-command" || validCommand(item)),
1032
+ );
1033
+ const set = new Set(items);
1034
+ const placements = new Map();
1035
+ this.planItems(items);
1036
+ this.reconcileTutorialShortcuts(items);
1037
+ for (const [item, record] of this.records)
1038
+ if (!set.has(item)) {
1039
+ record.enum?.dispose();
1040
+ record.args?.dispose?.();
1041
+ record.buttons.forEach((b) => b.remove());
1042
+ if (record.view) {
1043
+ record.view.el.remove();
1044
+ this.views.delete(item);
1045
+ }
1046
+ this.records.delete(item);
1047
+ }
1048
+ for (const item of items) {
1049
+ let record = this.records.get(item);
1050
+ if (!record) {
1051
+ record = { buttons: [], signature: "" };
1052
+ this.records.set(item, record);
1053
+ }
1054
+ if (item.localName === "nook-command") {
1055
+ if (!record.view) {
1056
+ record.view = this.view(item, item.getAttribute("name"));
1057
+ record.args = this.palette.mountForm(item, record.view.body);
1058
+ }
1059
+ this.i18n.text(record.view.heading, item.getAttribute("name"));
1060
+ this.i18n.attr(record.view.el, "aria-label", item.getAttribute("name"));
1061
+ record.args.reconcile();
1062
+ continue;
1063
+ }
1064
+ const parent = item.parentElement.closest(tags);
1065
+ const root = item.root;
1066
+ const topLevel = this.topLevelItems.includes(item);
1067
+ const destinations =
1068
+ item.localName === "nook-setting"
1069
+ ? ["settings"]
1070
+ : topLevel
1071
+ ? [this.rootItems.includes(item) ? "root" : "full"]
1072
+ : parent && this.records.has(parent)
1073
+ ? [parent]
1074
+ : ["full"];
1075
+ const isPanel = item.localName === "nook-panel",
1076
+ isMenu = item.localName === "nook-menu",
1077
+ isSetting = item.localName === "nook-setting";
1078
+ if ((isPanel || isMenu) && !record.view) {
1079
+ record.view = this.view(item, label(item));
1080
+ if (isMenu) {
1081
+ const grid = this.make("div", "items");
1082
+ grid.className = "items";
1083
+ record.view.body.append(grid);
1084
+ record.view.grid = grid;
1085
+ } else this.mountContent(item, record.view.body);
1086
+ }
1087
+ if (record.view) {
1088
+ this.i18n.text(record.view.heading, label(item));
1089
+ this.i18n.attr(record.view.el, "aria-label", label(item));
1090
+ }
1091
+ if (isSetting) {
1092
+ const schema =
1093
+ (item.getAttribute("type") || "toggle") +
1094
+ "|" +
1095
+ (childSlot(item, "control")?.outerHTML || "");
1096
+ if (record.control && record.schema !== schema) {
1097
+ record.enum?.dispose();
1098
+ record.enum = null;
1099
+ record.buttons.forEach((button) => button.remove());
1100
+ record.buttons = [];
1101
+ record.control = null;
1102
+ item._control = null;
1103
+ item._enumPicker = null;
1104
+ }
1105
+ record.schema = schema;
1106
+ }
1107
+ if (isSetting && !record.control) {
1108
+ record.control = this.setting(item);
1109
+ record.enum = item._enumPicker;
1110
+ }
1111
+ record.enum?.reconcile();
1112
+ const signature =
1113
+ label(item) +
1114
+ "|" +
1115
+ item.keybind +
1116
+ "|" +
1117
+ iconSlot(item)?.outerHTML +
1118
+ "|" +
1119
+ bindingSupported(item.keybind, this.browserKeyboardLayout);
1120
+ if (record.signature !== signature) {
1121
+ for (const button of [...record.buttons].filter(Boolean)) {
1122
+ if (isSetting) continue;
1123
+ const updated = this.itemButton(item, label(item));
1124
+ button.replaceChildren(...updated.childNodes);
1125
+ this.i18n.attr(button, "aria-label", label(item));
1126
+ }
1127
+ record.signature = signature;
1128
+ }
1129
+ if (isSetting) {
1130
+ const text = record.control.querySelector("[part=setting-label]");
1131
+ this.i18n.text(text.firstChild, label(item));
1132
+ const description =
1133
+ item.getAttribute("description") || slotText(item, "description");
1134
+ let small = text.querySelector("small");
1135
+ if (description) {
1136
+ if (!small) {
1137
+ small = this.make("small", "description");
1138
+ text.append(small);
1139
+ }
1140
+ this.i18n.text(small, description);
1141
+ } else small?.remove();
1142
+ const control = item._control;
1143
+ if (control) {
1144
+ this.i18n.attr(control, "aria-label", label(item));
1145
+ if (control.type === "checkbox")
1146
+ control.checked = item.getAttribute("value") === "true";
1147
+ else if (
1148
+ item.hasAttribute("value") &&
1149
+ control.value !== item.getAttribute("value")
1150
+ )
1151
+ control.value = item.getAttribute("value");
1152
+ if (item.hasAttribute("placeholder"))
1153
+ this.i18n.attr(
1154
+ control,
1155
+ "placeholder",
1156
+ item.getAttribute("placeholder"),
1157
+ );
1158
+ for (const attr of ["min", "max", "step"]) {
1159
+ const value = item.getAttribute(attr);
1160
+ if (value !== null && control.getAttribute(attr) !== value)
1161
+ control.setAttribute(attr, value);
1162
+ else if (value === null) control.removeAttribute(attr);
1163
+ }
1164
+ }
1165
+ }
1166
+ for (let i = 0; i < destinations.length; i++) {
1167
+ const dest = this.views.get(destinations[i]);
1168
+ if (!dest) continue;
1169
+ let button = record.buttons[i];
1170
+ if (!button) {
1171
+ button = isSetting
1172
+ ? record.control
1173
+ : this.itemButton(item, label(item));
1174
+ if (!isSetting) button.onclick = () => this.activate(item);
1175
+ record.buttons[i] = button;
1176
+ }
1177
+ const container = dest.grid || dest.body;
1178
+ if (!placements.has(container)) placements.set(container, []);
1179
+ placements.get(container).push(button);
1180
+ if (button.parentNode !== container) container.append(button);
1181
+ }
1182
+ record.buttons.slice(destinations.length).forEach((b) => b.remove());
1183
+ record.buttons.length = destinations.length;
1184
+ }
1185
+ const feedbackButton = this.builtins.get("feedback");
1186
+ if (
1187
+ feedbackButton &&
1188
+ !this.feedbackDisabled() &&
1189
+ this.rootEntries.some((entry) => entry.id === "feedback")
1190
+ ) {
1191
+ const root = this.views.get("root").grid;
1192
+ const buttons = placements.get(root) || [];
1193
+ buttons.unshift(feedbackButton);
1194
+ placements.set(root, buttons);
1195
+ }
1196
+ for (const [container, buttons] of placements) {
1197
+ let cursor = container.firstChild;
1198
+ for (const button of buttons) {
1199
+ if (button !== cursor) container.insertBefore(button, cursor);
1200
+ cursor = button.nextSibling;
1201
+ }
1202
+ }
1203
+ const customFeedback = items.some((item) => item.hasAttribute("feedback"));
1204
+ const feedbackEnabled =
1205
+ !this.feedbackDisabled() &&
1206
+ !customFeedback &&
1207
+ typeof this.feedback?.submit === "function";
1208
+ this.feedbackUI?.setEnabled(feedbackEnabled);
1209
+ this.feedbackUI?.reconcile();
1210
+ if (!feedbackEnabled && this.state?.path.includes("feedback")) this.close();
1211
+ this.builtins.get("quick").hidden = true;
1212
+ const commands = items.filter((item) => item.localName === "nook-command");
1213
+ for (const id of ["feedback", "settings", "commands", "all", "welcome"])
1214
+ this.builtins.get(id).hidden = !this.rootEntries.some(
1215
+ (entry) => entry.id === id,
1216
+ );
1217
+ this.placeRootEntries();
1218
+ const settings = this.views.get("settings");
1219
+ settings.footer.hidden = !this.settingsHref;
1220
+ if (this.settingsHref) {
1221
+ if (settings.link.getAttribute("href") !== this.settingsHref)
1222
+ settings.link.setAttribute("href", this.settingsHref);
1223
+ } else settings.link.removeAttribute("href");
1224
+ if (settings.body.lastChild !== settings.footer)
1225
+ settings.body.append(settings.footer);
1226
+ if (
1227
+ !this.availableBuiltins.has("settings") &&
1228
+ this.state?.path.includes("settings")
1229
+ )
1230
+ this.close();
1231
+ this.palette.reconcile(commands);
1232
+ if (!commands.length && this.state?.path.includes("commands")) this.close();
1233
+ for (const [id, view] of this.views) {
1234
+ const kind =
1235
+ view.kind ||
1236
+ (typeof id === "string" ? id : id.localName.replace("nook-", ""));
1237
+ const count =
1238
+ id === "all"
1239
+ ? this.allEntries.length
1240
+ : id === "full"
1241
+ ? this.overflowEntries.length
1242
+ : view.grid
1243
+ ? [...view.grid.children].filter((node) => !node.hidden).length
1244
+ : view.body.querySelectorAll(".setting").length;
1245
+ const override =
1246
+ typeof id === "string"
1247
+ ? this.getAttribute(
1248
+ id === "full" && this.hasAttribute("overflow-presentation")
1249
+ ? "overflow-presentation"
1250
+ : `${id}-presentation`,
1251
+ )
1252
+ : id.getAttribute("presentation");
1253
+ view.presentation =
1254
+ id === "root" ? "compact" : presentation(kind, count, override);
1255
+ if (view.grid && id !== "full" && id !== "all") {
1256
+ const labelsVisible = id !== "root" || view.presentation === "expanded";
1257
+ view.el.dataset.layout =
1258
+ labelsVisible &&
1259
+ [...view.grid.children].some(
1260
+ (button) => (button.getAttribute("aria-label") || "").length > 48,
1261
+ )
1262
+ ? "list"
1263
+ : "icons";
1264
+ }
1265
+ view.el.dataset.presentation = view.presentation;
1266
+ }
1267
+ this.reconcileGroups();
1268
+
1269
+ if (this.state) {
1270
+ if (!this.allEntries.length && this.state.path.includes("all")) {
1271
+ this.close();
1272
+ return;
1273
+ }
1274
+ if (!this.overflowEntries.length && this.state.path.includes("full")) {
1275
+ this.send({ type: "overflow-availability", available: false });
1276
+ return;
1277
+ }
1278
+ const valid = this.state.path.filter(
1279
+ (id) =>
1280
+ typeof id === "string" ||
1281
+ (this.views.has(id) && id.isConnected && available(id)),
1282
+ );
1283
+ if (valid.length !== this.state.path.length) {
1284
+ this.send({ type: "close" });
1285
+ return;
1286
+ }
1287
+ this.project(this.state, false);
1288
+ if (
1289
+ ["root", "full"].includes(focusedView) &&
1290
+ this.state.path.at(-1) === focusedView &&
1291
+ !this.focusables().includes(focused)
1292
+ ) {
1293
+ queueMicrotask(() => {
1294
+ if (
1295
+ this.state.path.at(-1) === focusedView &&
1296
+ this.shadowRoot.activeElement === focused
1297
+ )
1298
+ this.focusables()[0]?.focus();
1299
+ });
1300
+ }
1301
+ }
1302
+ }
1303
+ planItems(items) {
1304
+ const style = this.ownerDocument.defaultView.getComputedStyle(this);
1305
+ this.rootCapacity = Math.max(
1306
+ 2,
1307
+ Math.min(
1308
+ 6,
1309
+ parseInt(style.getPropertyValue("--nook-root-max-items"), 10) || 6,
1310
+ ),
1311
+ );
1312
+ this.topLevelItems = items.filter(
1313
+ (item) =>
1314
+ !["nook-setting", "nook-command"].includes(item.localName) &&
1315
+ (item.root || !item.parentElement.closest(tags)),
1316
+ );
1317
+ this.availableBuiltins = new Set();
1318
+ if (
1319
+ !this.feedbackDisabled() &&
1320
+ typeof this.feedback?.submit === "function" &&
1321
+ !items.some((item) => item.hasAttribute("feedback"))
1322
+ )
1323
+ this.availableBuiltins.add("feedback");
1324
+ if (
1325
+ this.settingsHref ||
1326
+ items.some((item) => item.localName === "nook-setting")
1327
+ )
1328
+ this.availableBuiltins.add("settings");
1329
+ if (items.some((item) => item.localName === "nook-command"))
1330
+ this.availableBuiltins.add("commands");
1331
+ const builtin = (id, priority) => ({
1332
+ id,
1333
+ priority: this.priorities?.[id] ?? priority,
1334
+ group: "",
1335
+ });
1336
+ const entries = [
1337
+ ...(this.availableBuiltins.has("feedback")
1338
+ ? [builtin("feedback", 30)]
1339
+ : []),
1340
+ ...this.topLevelItems.map((item) => ({
1341
+ id: item,
1342
+ priority: item.hasAttribute("priority")
1343
+ ? item.priority
1344
+ : item.root
1345
+ ? 100
1346
+ : 0,
1347
+ group: item.group.trim(),
1348
+ })),
1349
+ ...["settings", "commands"]
1350
+ .filter((id) => this.availableBuiltins.has(id))
1351
+ .map((id) => builtin(id, id === "settings" ? 20 : 10)),
1352
+ ];
1353
+ const tour = this.state?.needsIntro
1354
+ ? [{ id: "welcome", priority: Number.MAX_SAFE_INTEGER, group: "" }]
1355
+ : [];
1356
+ this.allEntries = distributeItems(
1357
+ [...tour, ...entries],
1358
+ Number.MAX_SAFE_INTEGER,
1359
+ ).root;
1360
+ if (this.allEntries.length) this.availableBuiltins.add("all");
1361
+ if (this.state?.needsIntro) this.availableBuiltins.add("welcome");
1362
+ const reserved = [
1363
+ ...(this.allEntries.length
1364
+ ? [{ id: "all", priority: 0, group: "" }]
1365
+ : []),
1366
+ ...(this.availableBuiltins.has("settings")
1367
+ ? [builtin("settings", 20)]
1368
+ : []),
1369
+ ];
1370
+ const extras = this.allEntries.filter((entry) => entry.id !== "settings");
1371
+ this.rootEntries = [
1372
+ ...reserved,
1373
+ ...extras.slice(0, Math.min(4, this.rootCapacity - reserved.length)),
1374
+ ];
1375
+ const picked = new Set(this.rootEntries.map((entry) => entry.id));
1376
+ this.overflowEntries = this.allEntries.filter(
1377
+ (entry) => !picked.has(entry.id),
1378
+ );
1379
+ this.rootItems = this.rootEntries
1380
+ .map((entry) => entry.id)
1381
+ .filter((id) => typeof id !== "string");
1382
+ this.overflowItems = this.overflowEntries
1383
+ .map((entry) => entry.id)
1384
+ .filter((id) => typeof id !== "string");
1385
+ }
1386
+ placeRootEntries() {
1387
+ const root = this.views.get("root").grid;
1388
+ const launcher = this.builtins.get("quick");
1389
+ if (launcher.parentNode !== root) root.append(launcher);
1390
+ let cursor = launcher;
1391
+ for (const entry of [...this.rootEntries].reverse()) {
1392
+ const button =
1393
+ typeof entry.id === "string"
1394
+ ? this.builtins.get(entry.id)
1395
+ : this.records.get(entry.id).buttons[0];
1396
+ if (button.nextSibling !== cursor) root.insertBefore(button, cursor);
1397
+ cursor = button;
1398
+ }
1399
+ }
1400
+ reconcileGroups() {
1401
+ // Only launch buttons are mirrored. Panels and setting controls have one
1402
+ // mounted instance, even when a menu is projected as nested sections.
1403
+ for (const [id, view] of this.views) {
1404
+ if (!view.grid || id === "root" || id === "full" || id === "all")
1405
+ continue;
1406
+ if (!view.grouped) {
1407
+ view.grouped = this.make("div", "groups");
1408
+ view.grouped.className = "groups";
1409
+ view.body.append(view.grouped);
1410
+ view.groupNodes = new Map();
1411
+ }
1412
+ const wanted = new Set();
1413
+ const children = (parent) =>
1414
+ [...this.records.keys()].filter((item) => {
1415
+ if (["nook-setting", "nook-command"].includes(item.localName))
1416
+ return false;
1417
+ if (parent === "root") return this.rootItems.includes(item);
1418
+ if (this.topLevelItems.includes(item)) return false;
1419
+ const owner = item.parentElement.closest(tags);
1420
+ return parent === "full" ? !owner : owner === parent;
1421
+ });
1422
+ const place = (parent, container) => {
1423
+ let cursor = container.firstChild;
1424
+ for (const item of children(parent)) {
1425
+ wanted.add(item);
1426
+ const menu = item.localName === "nook-menu";
1427
+ let node = view.groupNodes.get(item);
1428
+ if (!node) {
1429
+ if (menu) {
1430
+ const el = this.make("section", "group");
1431
+ el.className = "group";
1432
+ const heading = this.make("h3", "group-heading");
1433
+ const body = this.make("div", "group-body");
1434
+ body.className = "group-body";
1435
+ el.append(heading, body);
1436
+ node = { el, heading, body };
1437
+ } else {
1438
+ const el = this.itemButton(item, label(item));
1439
+ el.onclick = () => this.activate(item);
1440
+ node = { el };
1441
+ }
1442
+ view.groupNodes.set(item, node);
1443
+ }
1444
+ if (menu) {
1445
+ this.i18n.text(node.heading, label(item));
1446
+ this.i18n.attr(node.el, "aria-label", label(item));
1447
+ place(item, node.body);
1448
+ } else {
1449
+ const signature = this.records.get(item).signature;
1450
+ if (node.signature !== signature) {
1451
+ const updated = this.itemButton(item, label(item));
1452
+ node.el.replaceChildren(...updated.childNodes);
1453
+ this.i18n.attr(node.el, "aria-label", label(item));
1454
+ node.signature = signature;
1455
+ }
1456
+ }
1457
+ if (node.el !== cursor) container.insertBefore(node.el, cursor);
1458
+ cursor = node.el.nextSibling;
1459
+ }
1460
+ };
1461
+ place(id, view.grouped);
1462
+ if (id === "root") {
1463
+ view.groupLaunchers ??= new Map();
1464
+ for (const [key, source] of this.builtins) {
1465
+ let button = view.groupLaunchers.get(key);
1466
+ if (!button) {
1467
+ button = this.itemButton(
1468
+ null,
1469
+ key === "feedback"
1470
+ ? "Feedback"
1471
+ : key === "quick"
1472
+ ? "More"
1473
+ : key === "commands"
1474
+ ? "Commands"
1475
+ : "Settings",
1476
+ icon(
1477
+ key === "feedback"
1478
+ ? "feedback"
1479
+ : key === "quick"
1480
+ ? "more"
1481
+ : key === "commands"
1482
+ ? "commands"
1483
+ : "settings",
1484
+ ),
1485
+ );
1486
+ button.onclick = () => source.click();
1487
+ view.groupLaunchers.set(key, button);
1488
+ }
1489
+ button.hidden = source.hidden;
1490
+ if (button.parentNode !== view.grouped) view.grouped.append(button);
1491
+ }
1492
+ }
1493
+ if (id === "root") {
1494
+ const ordered = this.rootEntries
1495
+ .map((entry) =>
1496
+ typeof entry.id === "string"
1497
+ ? view.groupLaunchers.get(entry.id)
1498
+ : view.groupNodes.get(entry.id)?.el,
1499
+ )
1500
+ .filter(Boolean);
1501
+ if (this.overflowEntries.length)
1502
+ ordered.push(view.groupLaunchers.get("quick"));
1503
+ let cursor = view.grouped.firstChild;
1504
+ for (const node of ordered) {
1505
+ if (node !== cursor) view.grouped.insertBefore(node, cursor);
1506
+ cursor = node.nextSibling;
1507
+ }
1508
+ }
1509
+
1510
+ for (const [item, node] of view.groupNodes)
1511
+ if (!wanted.has(item)) {
1512
+ node.el.remove();
1513
+ view.groupNodes.delete(item);
1514
+ }
1515
+ }
1516
+ this.reconcileOverflow();
1517
+ }
1518
+ reconcileOverflow() {
1519
+ this.reconcileActionList(this.views.get("full"), this.overflowEntries);
1520
+ this.reconcileActionList(this.views.get("all"), this.allEntries);
1521
+ }
1522
+ reconcileActionList(view, entries) {
1523
+ if (!view.grouped) {
1524
+ view.grouped = this.make("div", "overflow-groups");
1525
+ view.grouped.className = "overflow-groups";
1526
+ view.body.append(view.grouped);
1527
+ view.overflowButtons = new Map();
1528
+ view.overflowGroups = new Map();
1529
+ }
1530
+ const wanted = new Set(this.allEntries.map((entry) => entry.id));
1531
+ for (const [id, record] of view.overflowButtons) {
1532
+ if (!wanted.has(id)) {
1533
+ record.button.remove();
1534
+ view.overflowButtons.delete(id);
1535
+ } else record.button.hidden = true;
1536
+ }
1537
+ for (const section of view.overflowGroups.values())
1538
+ section.el.hidden = true;
1539
+ const buckets = new Map();
1540
+ for (const entry of entries) {
1541
+ if (!buckets.has(entry.group)) buckets.set(entry.group, []);
1542
+ buckets.get(entry.group).push(entry);
1543
+ }
1544
+ const named = [...buckets.keys()].some(Boolean);
1545
+ let groupCursor = view.grouped.firstChild;
1546
+ for (const [group, entries] of buckets) {
1547
+ let section = view.overflowGroups.get(group);
1548
+ if (!section) {
1549
+ const el = this.make("section", "overflow-group");
1550
+ const heading = this.make("h3", "overflow-group-heading");
1551
+ const body = this.make("div", "overflow-group-body");
1552
+ el.append(heading, body);
1553
+ section = { el, heading, body };
1554
+ view.overflowGroups.set(group, section);
1555
+ }
1556
+ section.el.hidden = false;
1557
+ section.heading.hidden = !group && !named;
1558
+ this.i18n.text(section.heading, group || "Other");
1559
+ if (section.el !== groupCursor)
1560
+ view.grouped.insertBefore(section.el, groupCursor);
1561
+ groupCursor = section.el.nextSibling;
1562
+ let cursor = section.body.firstChild;
1563
+ for (const entry of entries) {
1564
+ const source = typeof entry.id === "string" ? null : entry.id;
1565
+ const title = source
1566
+ ? label(source)
1567
+ : {
1568
+ feedback: "Feedback",
1569
+ commands: "Commands",
1570
+ settings: "Settings",
1571
+ welcome: this.tutorial?.label ?? "Quick tour",
1572
+ }[entry.id];
1573
+ const signature = source ? this.records.get(source).signature : title;
1574
+ let record = view.overflowButtons.get(entry.id);
1575
+ if (!record) {
1576
+ const button = this.itemButton(
1577
+ source,
1578
+ title,
1579
+ icon(
1580
+ source
1581
+ ? commandTarget(source)
1582
+ ? "commands"
1583
+ : "file"
1584
+ : {
1585
+ feedback: "feedback",
1586
+ commands: "commands",
1587
+ settings: "settings",
1588
+ welcome: "guide",
1589
+ }[entry.id],
1590
+ ),
1591
+ );
1592
+ button.onclick = () => {
1593
+ if (source?.localName === "nook-setting") {
1594
+ this.open("settings");
1595
+ queueMicrotask(() => source._control?.focus());
1596
+ } else if (source) this.activate(source);
1597
+ else if (
1598
+ view === this.views.get("all") &&
1599
+ entry.id === "settings" &&
1600
+ this.settingsHref
1601
+ ) {
1602
+ this.views.get("settings").link.click();
1603
+ this.close();
1604
+ } else this.builtins.get(entry.id).click();
1605
+ };
1606
+ record = { button, signature };
1607
+ view.overflowButtons.set(entry.id, record);
1608
+ }
1609
+ if (record.signature !== signature) {
1610
+ const updated = this.itemButton(source, title);
1611
+ record.button.replaceChildren(...updated.childNodes);
1612
+ this.i18n.attr(record.button, "aria-label", title);
1613
+ record.signature = signature;
1614
+ }
1615
+ record.button.hidden = false;
1616
+ if (record.button !== cursor)
1617
+ section.body.insertBefore(record.button, cursor);
1618
+ cursor = record.button.nextSibling;
1619
+ }
1620
+ }
1621
+ }
1622
+ mountContent(item, body) {
1623
+ const template = [...item.children].find(
1624
+ (node) =>
1625
+ node instanceof HTMLTemplateElement &&
1626
+ (!node.hasAttribute("slot") || node.getAttribute("slot") === "content"),
1627
+ );
1628
+ const content = this.make("div", "content");
1629
+ content.className = "content";
1630
+ // Cloning is deliberate for inert templates. Live nodes are moved once so
1631
+ // addEventListener handlers and custom-element state survive navigation.
1632
+ if (template) content.append(template.content.cloneNode(true));
1633
+ else
1634
+ for (const child of [...item.childNodes])
1635
+ if (!(child instanceof Element && child.hasAttribute("slot")))
1636
+ content.append(child);
1637
+ body.append(content);
1638
+ content.addEventListener("submit", (e) => {
1639
+ e.preventDefault();
1640
+ emit(item, "nook-submit", {
1641
+ source: item,
1642
+ formData: new FormData(e.target),
1643
+ });
1644
+ });
1645
+ }
1646
+ createEnum(source, title, commit, initialValue) {
1647
+ const view = this.view(source, title());
1648
+ view.kind = "enum";
1649
+ view.el.dataset.view = "enum";
1650
+ const picker = enumPicker({
1651
+ source,
1652
+ view,
1653
+ make: this.make,
1654
+ i18n: this.i18n,
1655
+ title,
1656
+ initialValue,
1657
+ open: () => this.send({ type: "enter", view: source }),
1658
+ close: (trigger) => {
1659
+ if (this.state?.path.at(-1) !== source) return;
1660
+ this.send({ type: "back" });
1661
+ queueMicrotask(() => {
1662
+ if (this.state?.path.length) trigger.focus();
1663
+ });
1664
+ },
1665
+ commit,
1666
+ });
1667
+ view.picker = picker;
1668
+ const dispose = picker.dispose;
1669
+ picker.dispose = () => {
1670
+ dispose();
1671
+ this.views.delete(source);
1672
+ };
1673
+ return picker;
1674
+ }
1675
+ setting(item) {
1676
+ const type = item.getAttribute("type") || "toggle";
1677
+ const isEnum = ["enum", "select"].includes(type);
1678
+ const row = this.make(isEnum ? "div" : "label", "setting");
1679
+ row.className = "setting";
1680
+ const text = this.make("span", "setting-label");
1681
+ text.append(this.make("span", "setting-name", label(item)));
1682
+ const description =
1683
+ item.getAttribute("description") || slotText(item, "description");
1684
+ if (description)
1685
+ text.append(this.make("small", "description", description));
1686
+ row.append(text);
1687
+ const custom = childSlot(item, "control");
1688
+ if (custom) {
1689
+ row.append(custom.content.cloneNode(true));
1690
+ row.addEventListener("change", (e) =>
1691
+ emit(item, "nook-change", { source: item, value: e.target.value }),
1692
+ );
1693
+ return row;
1694
+ }
1695
+ if (isEnum) {
1696
+ const picker = this.createEnum(
1697
+ item,
1698
+ () => label(item),
1699
+ () => {
1700
+ emit(item, "nook-input", { source: item, value: item.value });
1701
+ emit(item, "nook-change", { source: item, value: item.value });
1702
+ },
1703
+ );
1704
+ item._control = picker.proxy;
1705
+ item._enumPicker = picker;
1706
+ row.append(picker.element);
1707
+ picker.reconcile();
1708
+ return row;
1709
+ }
1710
+ const control = this.make("input", "control");
1711
+ control.type = type === "toggle" ? "checkbox" : type;
1712
+ for (const attr of ["min", "max", "step"])
1713
+ if (item.hasAttribute(attr))
1714
+ control.setAttribute(attr, item.getAttribute(attr));
1715
+ if (control.type === "checkbox")
1716
+ control.checked = item.getAttribute("value") === "true";
1717
+ else if (item.hasAttribute("value"))
1718
+ control.value = item.getAttribute("value");
1719
+ this.i18n.attr(control, "aria-label", label(item));
1720
+ item._control = control;
1721
+ control.addEventListener("input", () => {
1722
+ item.setAttribute("value", String(item.value));
1723
+ emit(item, "nook-input", { source: item, value: item.value });
1724
+ });
1725
+ control.addEventListener("change", () => {
1726
+ item.setAttribute("value", String(item.value));
1727
+ emit(item, "nook-change", { source: item, value: item.value });
1728
+ });
1729
+ row.append(control);
1730
+ return row;
1731
+ }
1732
+ chooseCommand(source) {
1733
+ if (
1734
+ !this.records.has(source) ||
1735
+ !available(source) ||
1736
+ !validCommand(source) ||
1737
+ !source.isConnected
1738
+ )
1739
+ return;
1740
+ const hasArgs = [...source.querySelectorAll("nook-arg")].some(
1741
+ (arg) =>
1742
+ arg.closest("nook-command") === source &&
1743
+ arg.getAttribute("name") &&
1744
+ !arg.hidden,
1745
+ );
1746
+ if (hasArgs) this.send({ type: "enter", view: source });
1747
+ else this.runCommand(source, {});
1748
+ }
1749
+ runCommand(source, args) {
1750
+ if (
1751
+ !this.records.has(source) ||
1752
+ !available(source) ||
1753
+ !validCommand(source) ||
1754
+ !source.isConnected
1755
+ )
1756
+ return;
1757
+ if (
1758
+ emit(source, "nook-command", {
1759
+ source,
1760
+ id: source.id,
1761
+ name: source.getAttribute("name"),
1762
+ args,
1763
+ context: this.context,
1764
+ })
1765
+ )
1766
+ this.close();
1767
+ }
1768
+ activate(item) {
1769
+ if (!available(item) || !item.isConnected) return;
1770
+ if (item.localName === "nook-command") {
1771
+ this.chooseCommand(item);
1772
+ return;
1773
+ }
1774
+ if (item.localName === "nook-menu" || item.localName === "nook-panel") {
1775
+ this.send({ type: "enter", view: item });
1776
+ return;
1777
+ }
1778
+ const command = commandTarget(item);
1779
+ if (
1780
+ emit(
1781
+ item,
1782
+ item.localName === "nook-item" ? "nook-select" : "nook-action",
1783
+ {
1784
+ source: item,
1785
+ id: item.id,
1786
+ command,
1787
+ context: this.context,
1788
+ },
1789
+ )
1790
+ ) {
1791
+ if (command) this.chooseCommand(command);
1792
+ else this.close();
1793
+ }
1794
+ }
1795
+
1796
+ project(state, focus = true) {
1797
+ const previousCorner = this.state?.corner;
1798
+ const previousPreferred = this.state?.preferredPosition;
1799
+ const previous = this.state?.path.at(-1);
1800
+ const previousNeedsIntro = this.state?.needsIntro;
1801
+ this.state = state;
1802
+ this.queryReady = true;
1803
+ if (this.pendingIntents?.length) {
1804
+ const pending = this.pendingIntents.splice(0);
1805
+ queueMicrotask(() => {
1806
+ if (this.isConnected) for (const detail of pending) this.send(detail);
1807
+ });
1808
+ }
1809
+ const movable = this.canDrag();
1810
+ this.anchor.dataset.draggable = String(movable);
1811
+ if (
1812
+ this.getAttribute("corner") !== state.corner ||
1813
+ this.getAttribute("position") !== state.corner
1814
+ ) {
1815
+ this.reflectingCorner = true;
1816
+ try {
1817
+ this.setAttribute("corner", state.corner);
1818
+ this.setAttribute("position", state.corner);
1819
+ } finally {
1820
+ this.reflectingCorner = false;
1821
+ }
1822
+ }
1823
+ if (this.pointer && !movable) {
1824
+ clearTimeout(this.hold);
1825
+ if (this.pointer.drag) this.cancelPointer();
1826
+ }
1827
+ if (this.pointer?.drag)
1828
+ this.updateDragPreview(this.pointer.lastX, this.pointer.lastY);
1829
+ this.handle.classList.toggle(
1830
+ "inviting",
1831
+ Boolean(state.needsIntro && state.attentionActive),
1832
+ );
1833
+ this.anchor.dataset.corner = state.corner;
1834
+ this.anchor.dataset.position = state.corner;
1835
+
1836
+ const active = state.path.at(-1);
1837
+ if (active) this.dismissHandleTip();
1838
+ const effective = inheritedPresentation(
1839
+ state.path,
1840
+ (id) => this.views.get(id)?.presentation || "compact",
1841
+ );
1842
+ for (const [id, view] of this.views) {
1843
+ const visible = id === active || (id === "root" && this.swipeDial?.shown);
1844
+ view.el.dataset.presentation = visible
1845
+ ? effective
1846
+ : view.presentation || "compact";
1847
+ if (visible) {
1848
+ view.exit?.cancel();
1849
+ view.exit = null;
1850
+ if (view.el.hidden) view.el.hidden = false;
1851
+ view.el.inert = false;
1852
+ } else if (!view.el.hidden && !view.exit) {
1853
+ view.el.inert = true;
1854
+ if (
1855
+ !active &&
1856
+ !matchMedia("(prefers-reduced-motion: reduce)").matches &&
1857
+ view.el.animate
1858
+ ) {
1859
+ const configured = this.ownerDocument.defaultView
1860
+ .getComputedStyle(this)
1861
+ .getPropertyValue("--nook-exit-duration")
1862
+ .trim();
1863
+ const parsed = parseFloat(configured);
1864
+ const duration = Number.isFinite(parsed)
1865
+ ? Math.max(0, parsed * (configured.endsWith("ms") ? 1 : 1000))
1866
+ : 120;
1867
+ const fullscreen =
1868
+ view.el.dataset.presentation === "expanded" &&
1869
+ this.ownerDocument.defaultView.matchMedia(
1870
+ "(pointer: coarse), (hover: none)",
1871
+ ).matches;
1872
+ const animation = view.el.animate(
1873
+ fullscreen
1874
+ ? [{ opacity: 1 }, { opacity: 0 }]
1875
+ : [
1876
+ { opacity: 1, transform: "scale(1)" },
1877
+ { opacity: 0, transform: "scale(.985)" },
1878
+ ],
1879
+ { duration, easing: "ease-out" },
1880
+ );
1881
+ view.exit = animation;
1882
+ animation.onfinish = () => {
1883
+ if (view.exit === animation) {
1884
+ view.el.hidden = true;
1885
+ view.exit = null;
1886
+ }
1887
+ };
1888
+ } else view.el.hidden = true;
1889
+ }
1890
+ view.picker?.syncOpen(visible);
1891
+ view.back.hidden = state.path.length <= 1;
1892
+ }
1893
+ this.swipeDial?.sync();
1894
+ if (!active) delete this.anchor.dataset.numbering;
1895
+ if (active && this.anchor.dataset.numbering) this.showNumbers();
1896
+ this.handle.setAttribute("aria-expanded", String(Boolean(active)));
1897
+ this.i18n.attr(
1898
+ this.handle,
1899
+ "aria-label",
1900
+ active ? "Close Nook menu" : "Open Nook menu",
1901
+ );
1902
+ if (focus && active !== previous) {
1903
+ if (active) {
1904
+ this.returnFocus ??=
1905
+ this.shadowRoot.activeElement || this.ownerDocument.activeElement;
1906
+ if (active === "commands" && previous !== active) this.palette.reset();
1907
+ queueMicrotask(() =>
1908
+ active === "commands"
1909
+ ? this.palette.search.focus()
1910
+ : this.views.get(active)?.picker?.focus() ||
1911
+ this.focusables()[0]?.focus(),
1912
+ );
1913
+ } else {
1914
+ const target = this.returnFocus;
1915
+ this.returnFocus = null;
1916
+ (target?.isConnected && target !== document.body
1917
+ ? target
1918
+ : this.handle
1919
+ ).focus();
1920
+ }
1921
+ }
1922
+ if (previousNeedsIntro !== state.needsIntro) this.reconcile();
1923
+ if (previousCorner && previousCorner !== state.corner) {
1924
+ this.settleCorner();
1925
+ emit(this, "nook-corner-change", { corner: state.corner });
1926
+ }
1927
+ if (
1928
+ previousCorner &&
1929
+ (previousCorner !== state.corner ||
1930
+ previousPreferred !== state.preferredPosition)
1931
+ )
1932
+ emit(this, "nook-position-change", {
1933
+ position: state.corner,
1934
+ preferredPosition: state.preferredPosition,
1935
+ reason:
1936
+ previousPreferred !== state.preferredPosition
1937
+ ? "preference"
1938
+ : "availability",
1939
+ });
1940
+ }
1941
+ focusables() {
1942
+ const view = this.views.get(this.state?.path.at(-1));
1943
+ const nodes = view
1944
+ ? [
1945
+ ...view.el.querySelectorAll(
1946
+ "button,input,select,textarea,a[href],[tabindex]",
1947
+ ),
1948
+ ].filter(
1949
+ (n) => !n.disabled && n.tabIndex >= 0 && n.getClientRects().length,
1950
+ )
1951
+ : [];
1952
+ return this.state?.path.at(-1) === "root"
1953
+ ? nodes.sort(
1954
+ (a, b) => Number(a.dataset.dialOrder) - Number(b.dataset.dialOrder),
1955
+ )
1956
+ : nodes;
1957
+ }
1958
+ numberedItems() {
1959
+ if (this.state?.path.at(-1) === "root")
1960
+ return this.focusables()
1961
+ .filter((node) => node.classList.contains("item"))
1962
+ .slice(0, 9);
1963
+ const view = this.views.get(this.state?.path.at(-1));
1964
+ return view
1965
+ ? [...view.body.querySelectorAll(".item,.command-result")]
1966
+ .filter(
1967
+ (node) =>
1968
+ !node.hidden && !node.disabled && node.getClientRects().length,
1969
+ )
1970
+ .slice(0, 9)
1971
+ : [];
1972
+ }
1973
+ showNumbers() {
1974
+ if (!this.state?.path.length) return;
1975
+ if (!this.browserKeyboardLayout) {
1976
+ delete this.anchor.dataset.numbering;
1977
+ return;
1978
+ }
1979
+ for (const badge of this.shadowRoot.querySelectorAll("[part=number]"))
1980
+ badge.hidden = true;
1981
+ this.numberedItems().forEach((button, index) => {
1982
+ if (
1983
+ !this.browserKeyboardLayout ||
1984
+ ![...this.browserKeyboardLayout.values()].includes(String(index + 1))
1985
+ )
1986
+ return;
1987
+ const badge = button.querySelector("[part=number]");
1988
+ if (badge) {
1989
+ badge.hidden = false;
1990
+ if (badge.textContent !== String(index + 1))
1991
+ badge.textContent = String(index + 1);
1992
+ }
1993
+ });
1994
+ this.anchor.dataset.numbering = "true";
1995
+ }
1996
+ keydown(e) {
1997
+ if (e.defaultPrevented || e.isComposing) return;
1998
+ if (this.feedbackUI?.capturing) return;
1999
+ this.lastKey = e.key;
2000
+ this.dismissHandleTip();
2001
+ this.altHeld = e.altKey || e.key === "Alt";
2002
+ this.anchor.toggleAttribute("data-alt-held", this.altHeld);
2003
+ if (!e.repeat && (e.key === "Alt" || (e.key === "Shift" && e.altKey)))
2004
+ void this.refreshKeyboardLayout();
2005
+ if (e.altKey || e.key === "Alt") this.showNumbers();
2006
+ else delete this.anchor.dataset.numbering;
2007
+ const target = e.composedPath()[0];
2008
+ const editing =
2009
+ target?.matches?.(
2010
+ "input,textarea,select,[contenteditable]:not([contenteditable=false])",
2011
+ ) || target?.isContentEditable;
2012
+ const layout = this.browserKeyboardLayout;
2013
+ const bindingMatches = (binding) => matchesKey(e, binding, layout);
2014
+ const number = selectionNumber(e, layout);
2015
+ if (
2016
+ number &&
2017
+ this.state?.path.length &&
2018
+ (!editing || target === this.palette.search)
2019
+ ) {
2020
+ const button = this.numberedItems()[number - 1];
2021
+ if (button) {
2022
+ e.preventDefault();
2023
+ button.click();
2024
+ return;
2025
+ }
2026
+ }
2027
+ if (e.key === "Escape" && this.state?.path.length) {
2028
+ e.preventDefault();
2029
+ this.close();
2030
+ return;
2031
+ }
2032
+ if (
2033
+ !editing &&
2034
+ !e.altKey &&
2035
+ !e.ctrlKey &&
2036
+ !e.metaKey &&
2037
+ ["Backspace", "Delete"].includes(e.key) &&
2038
+ this.state?.path.length
2039
+ ) {
2040
+ e.preventDefault();
2041
+ this.send({ type: "back" });
2042
+ return;
2043
+ }
2044
+ if (!editing && !e.repeat) {
2045
+ const paletteBinding = this.hasAttribute("commands-keybind")
2046
+ ? this.getAttribute("commands-keybind")
2047
+ : null;
2048
+ if (
2049
+ this.surfaceAvailable("commands") &&
2050
+ (paletteBinding !== null
2051
+ ? bindingMatches(paletteBinding)
2052
+ : bindingMatches("Ctrl+Shift+P") || bindingMatches("Meta+Shift+P"))
2053
+ ) {
2054
+ e.preventDefault();
2055
+ this.open("commands");
2056
+ return;
2057
+ }
2058
+
2059
+ for (const [view, attr, fallback] of [
2060
+ ["root", "root-keybind", "Shift+?"],
2061
+ ["settings", "settings-keybind", "Alt+Shift+S"],
2062
+ ["all", "all-keybind", "Alt+Shift+M"],
2063
+ [
2064
+ "quick",
2065
+ this.hasAttribute("overflow-keybind")
2066
+ ? "overflow-keybind"
2067
+ : "quick-keybind",
2068
+ "Alt+Shift+Q",
2069
+ ],
2070
+ ])
2071
+ if (
2072
+ bindingMatches(
2073
+ this.hasAttribute(attr) ? this.getAttribute(attr) : fallback,
2074
+ )
2075
+ ) {
2076
+ if (
2077
+ view === "quick"
2078
+ ? !this.surfaceAvailable("full")
2079
+ : view !== "root" && !this.surfaceAvailable(view)
2080
+ )
2081
+ return;
2082
+ e.preventDefault();
2083
+ this.open(view === "quick" ? "full" : view);
2084
+ return;
2085
+ }
2086
+ const feedback = [...this.records.keys()].find((i) =>
2087
+ i.hasAttribute("feedback"),
2088
+ );
2089
+ if (
2090
+ (feedback || this.surfaceAvailable("feedback")) &&
2091
+ bindingMatches(
2092
+ this.hasAttribute("feedback-keybind")
2093
+ ? this.getAttribute("feedback-keybind")
2094
+ : "Alt+Shift+F",
2095
+ )
2096
+ ) {
2097
+ e.preventDefault();
2098
+ if (feedback) this.activate(feedback);
2099
+ else this.open("feedback");
2100
+ return;
2101
+ }
2102
+ for (const item of this.records.keys())
2103
+ if (
2104
+ !(isLeaf(item) && item.hasAttribute("command")) &&
2105
+ bindingMatches(item.keybind)
2106
+ ) {
2107
+ e.preventDefault();
2108
+ this.activate(item);
2109
+ return;
2110
+ }
2111
+ }
2112
+ if (!this.state?.path.length) return;
2113
+ const nodes = this.focusables(),
2114
+ current = this.shadowRoot.activeElement,
2115
+ index = nodes.indexOf(current);
2116
+ if (e.key === "Tab") {
2117
+ e.preventDefault();
2118
+ nodes[
2119
+ (index + (e.shiftKey ? -1 : 1) + nodes.length) % nodes.length
2120
+ ]?.focus();
2121
+ return;
2122
+ }
2123
+ if (editing) return;
2124
+ if (
2125
+ [
2126
+ "ArrowRight",
2127
+ "ArrowDown",
2128
+ "ArrowLeft",
2129
+ "ArrowUp",
2130
+ "Home",
2131
+ "End",
2132
+ ].includes(e.key)
2133
+ ) {
2134
+ e.preventDefault();
2135
+ if (this.state.path.at(-1) === "root" && e.key.startsWith("Arrow")) {
2136
+ directionalChoice(nodes, current, e.key)?.focus();
2137
+ return;
2138
+ }
2139
+ const next =
2140
+ e.key === "Home"
2141
+ ? 0
2142
+ : e.key === "End"
2143
+ ? nodes.length - 1
2144
+ : (index +
2145
+ (["ArrowLeft", "ArrowUp"].includes(e.key) ? -1 : 1) +
2146
+ nodes.length) %
2147
+ nodes.length;
2148
+ nodes[next]?.focus();
2149
+ }
2150
+ }
2151
+
2152
+ settleCorner() {
2153
+ this.motion?.play(this.handle, [
2154
+ { transform: "scale(.9)" },
2155
+ { transform: "scale(1.035)", offset: 0.65 },
2156
+ { transform: "scale(1)" },
2157
+ ]);
2158
+ const active = this.views.get(this.state?.path.at(-1));
2159
+ if (active && !active.el.hidden)
2160
+ this.motion?.play(active.body, [
2161
+ { transform: "scale(.985)" },
2162
+ { transform: "scale(1.006)", offset: 0.7 },
2163
+ { transform: "scale(1)" },
2164
+ ]);
2165
+ }
2166
+ pointerdown(e) {
2167
+ this.lastKey = null;
2168
+ this.dismissHandleTip();
2169
+ if (e.button !== 0 || this.pointer) return;
2170
+ this.suppressClick = false;
2171
+ this.pointer = {
2172
+ id: e.pointerId,
2173
+ x: e.clientX,
2174
+ y: e.clientY,
2175
+ lastX: e.clientX,
2176
+ lastY: e.clientY,
2177
+ drag: false,
2178
+ };
2179
+ this.handle.setPointerCapture(e.pointerId);
2180
+ if (!this.canDrag()) return;
2181
+ this.hold = setTimeout(() => {
2182
+ if (this.pointer && this.canDrag()) {
2183
+ this.swipeDial?.cancel();
2184
+ this.pointer.drag = true;
2185
+ this.handle.classList.add("dragging");
2186
+ this.updateDragPreview(this.pointer.lastX, this.pointer.lastY);
2187
+ }
2188
+ }, 450);
2189
+ }
2190
+ pointermove(e) {
2191
+ const p = this.pointer;
2192
+ if (!p || e.pointerId !== p.id) return;
2193
+ p.lastX = e.clientX;
2194
+ p.lastY = e.clientY;
2195
+ const dx = e.clientX - p.x,
2196
+ dy = e.clientY - p.y;
2197
+ if (!p.drag && Math.hypot(dx, dy) > 10) clearTimeout(this.hold);
2198
+ if (p.drag) {
2199
+ this.updateDragPreview(e.clientX, e.clientY);
2200
+ } else this.swipeDial?.move(p);
2201
+ }
2202
+ updateDragPreview(x, y) {
2203
+ if (!this.canDrag()) {
2204
+ this.cancelPointer();
2205
+ return null;
2206
+ }
2207
+ const allowed =
2208
+ this.state?.allowedPositions ||
2209
+ availablePositions(this.blockedPositions());
2210
+ const rect = this.anchorRect || {
2211
+ left: 0,
2212
+ top: 0,
2213
+ width: this.ownerDocument.defaultView.innerWidth,
2214
+ height: this.ownerDocument.defaultView.innerHeight,
2215
+ };
2216
+ const corner = closestAllowedPosition(
2217
+ x - rect.left,
2218
+ y - rect.top,
2219
+ rect.width,
2220
+ rect.height,
2221
+ allowed,
2222
+ );
2223
+ for (const [position, spot] of this.landingSpots) {
2224
+ spot.hidden = !allowed.includes(position);
2225
+ spot.toggleAttribute("data-selected", position === corner);
2226
+ }
2227
+ this.preview.hidden = false;
2228
+ if (this.preview.dataset.corner !== corner) {
2229
+ this.preview.dataset.corner = corner;
2230
+ this.motion?.play(
2231
+ this.preview.firstElementChild,
2232
+ [{ transform: "scale(.96)" }, { transform: "scale(1)" }],
2233
+ { token: "--nook-duration", fallback: 180 },
2234
+ );
2235
+ this.i18n.text(this.live, "Release to move to {position}.", () => ({
2236
+ position: this.i18n.t(corner.replace("-", " ")),
2237
+ }));
2238
+ }
2239
+ return corner;
2240
+ }
2241
+ pointerup(e) {
2242
+ const p = this.pointer;
2243
+ if (!p || e.pointerId !== p.id) return;
2244
+ clearTimeout(this.hold);
2245
+ const dx = e.clientX - p.x,
2246
+ dy = e.clientY - p.y;
2247
+ if (p.drag) {
2248
+ const corner = this.updateDragPreview(e.clientX, e.clientY);
2249
+ if (corner === this.state.corner) this.settleCorner();
2250
+ this.position = corner;
2251
+
2252
+ this.i18n.text(this.live, "Moved to {position}.", () => ({
2253
+ position: this.i18n.t(corner.replace("-", " ")),
2254
+ }));
2255
+ this.suppressClick = true;
2256
+ } else {
2257
+ const dialShown = this.swipeDial?.shown;
2258
+ const view = gesture(dx, dy);
2259
+ const destination = dialShown
2260
+ ? this.swipeDial.release(e.clientX, e.clientY)
2261
+ : view;
2262
+ if (view || p.swipeStarted || dialShown) this.suppressClick = true;
2263
+ this.cancelPointer();
2264
+ if (
2265
+ typeof destination === "string" &&
2266
+ this.surfaceAvailable(destination)
2267
+ ) {
2268
+ if (dialShown) {
2269
+ this.open("root");
2270
+ this.send({ type: "enter", view: destination });
2271
+ } else this.open(destination);
2272
+ } else if (destination && this.records.has(destination)) {
2273
+ if (dialShown) this.open("root");
2274
+ this.activate(destination);
2275
+ } else if (dialShown) this.close();
2276
+ }
2277
+ this.cancelPointer();
2278
+ }
2279
+ cancelPointer() {
2280
+ this.swipeDial?.cancel();
2281
+ clearTimeout(this.hold);
2282
+ const pointer = this.pointer;
2283
+ this.pointer = null;
2284
+ if (pointer?.drag) this.suppressClick = true;
2285
+ this.preview.hidden = true;
2286
+ for (const spot of this.landingSpots.values()) spot.hidden = true;
2287
+ delete this.preview.dataset.corner;
2288
+ this.handle.classList.remove("dragging");
2289
+ if (pointer && this.handle.hasPointerCapture?.(pointer.id))
2290
+ this.handle.releasePointerCapture(pointer.id);
2291
+ }
2292
+ }
2293
+
2294
+ export function defineNook(registry = customElements) {
2295
+ for (const tag of [
2296
+ "nook-item",
2297
+ "nook-action",
2298
+ "nook-menu",
2299
+ "nook-setting",
2300
+ "nook-panel",
2301
+ "nook-command",
2302
+ "nook-arg",
2303
+ "nook-option",
2304
+ ])
2305
+ if (!registry.get(tag)) registry.define(tag, class extends NookItem {});
2306
+ if (!registry.get("nook-dock")) registry.define("nook-dock", NookDock);
2307
+ }
2308
+ defineNook();