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