PageSourceSearch

https://www.touchstonetonewoods.co.uk/common/js/shortcuts.js?v=255

js touchstonetonewoods.co.uk collected 2026-10-04 03:08:19 UTC 31,767 bytes, 1,130 lines download raw bytes

1const DomUtils = {
2    /**
3     * Return the first element that matches any selector (in order).
4     * @param {string[]} selectors
5     * @returns {HTMLElement|null}
6     */
7    queryFirst(selectors) {
8        if (!Array.isArray(selectors)) {
9            return null;
10        }
11
12        for (const selector of selectors) {
13            const element = document.querySelector(selector);
14            if (element) {
15                return element;
16            }
17        }
18
19        return null;
20    },
21
22    /**
23     * Ensure an element can be programmatically focused.
24     * Adds tabindex="-1" if absent.
25     * @param {HTMLElement} element
26     * @returns {void}
27     */
28    ensureTabbable(element) {
29        if (!element) {
30            return;
31        }
32
33        if (!element.hasAttribute("tabindex")) {
34            element.setAttribute("tabindex", "-1");
35        }
36    },
37
38    /**
39     * Focus an element, optionally preventing scroll jumps.
40     * @param {HTMLElement} element
41     * @param {FocusOptions} [options]
42     * @returns {void}
43     */
44    focusElement(element, options) {
45        if (!element) {
46            return;
47        }
48
49        element.focus(options || { preventScroll: false });
50    }
51};
52
53class BaseCommand {
54    /**
55     * Unique command id (used for programmatic invocation).
56     * @type {string}
57     */
58    id;
59
60    /**
61     * Human-friendly title used in the overlay.
62     * @type {string}
63     */
64    title;
65
66    /**
67     * Short description of what the command does.
68     * @type {string}
69     */
70    description;
71
72    /**
73     * One or more key bindings that can trigger this command.
74     * @type {KeyBinding[]}
75     */
76    bindings = [];
77
78    /**
79     * Declarative command options; `when(ctx)` gates activation.
80     * @type {CommandOptions}
81     */
82    options = { includeInOverlay: true, overlayLabel: "", when: null };
83
84    /**
85     * @param {string} id
86     * @param {string} title
87     * @param {string} description
88     * @param {KeyBinding[]|KeyBinding} bindings
89     * @param {CommandOptions} [options]
90     */
91    constructor(id, title, description, bindings = [], options = {}) {
92        this.id = id;
93        this.title = title;
94        this.description = description;
95        this.bindings = Array.isArray(bindings) ? bindings : [bindings];
96        this.options = { includeInOverlay: true, overlayLabel: title, when: null, ...options };
97    }
98
99    /**
100     * Determine whether the command is active under the given context.
101     * @param {CommandContext} context
102     * @returns {boolean}
103     */
104    isActive(context) {
105        if (typeof this.options.when === "function") {
106            const result = this.options.when(context);
107            return result;
108        }
109
110        return true;
111    }
112
113    /**
114     * @param {CommandContext} _context
115     * @returns {void}
116     */
117    execute(_context) {
118        throw new Error("execute(context) must be implemented by the concrete command.");
119    }
120}
121
122class ShortcutsMenu {
123    /** @type {ShortcutEngine} */
124    engine;
125
126    /** @type {HTMLElement|null} */
127    rootElement = null;
128
129    /** @type {HTMLElement|null} */
130    listElement = null;
131
132    /** @type {boolean} */
133    isApplePlatform = false;
134
135    /**
136     * The last focused element before the shortcuts menu was opened
137     * @type {HTMLElement|null}
138     */
139    restoreFocusElement = null;
140
141    /**
142     * Indicates whether we should suppress focus restoration.
143     * Useful in instances when a command needs to set focus i.e. search / skip to main
144     * @type {boolean}
145     */
146    suppressRestoreOnClose = true;
147
148    /**
149     * @param {ShortcutEngine} engine
150     */
151    constructor(engine) {
152        this.engine = engine;
153
154        this.rootElement = document.querySelector(".shortcuts-menu");
155        if (!this.rootElement) {
156            console.error("[ShortcutsMenu] .shortcuts-menu not found in DOM.");
157            return;
158        }
159
160        this.listElement = this.rootElement.querySelector(".shortcuts-menu__list");
161        if (!this.listElement) {
162            console.error("[ShortcutsMenu] .shortcuts-menu__list not found.");
163            return;
164        }
165
166        this.rootElement.classList.add("is-hidden");
167        this.isApplePlatform = this.checkIfIsApplePlatform();
168
169        this.rootElement.addEventListener("focusin", this.onMenuFocusIn);
170        this.rootElement.addEventListener("focusout", this.onMenuFocusOut);
171        this.rootElement.addEventListener("keydown", this.onMenuKey
171down);
172
173        // Keep the menu open on background clicks inside it
174        this.rootElement.addEventListener("mousedown", (event) => {
175            // If the click is on or inside a button, allow it through
176            if (event.target instanceof Element && event.target.closest(".shortcuts-menu__button")) {
177                return;
178            }
179
180            // Otherwise, prevent background clicks from removing focus from menu
181            event.preventDefault();
182        });
183    }
184
185    /**
186     * Platform detection used only for visual keycap labels.
187     * @returns {boolean}
188     */
189    checkIfIsApplePlatform() {
190        if (navigator.userAgentData?.platform) {
191            return /mac|ios|ipados/i.test(navigator.userAgentData.platform);
192        }
193
194        return /Mac|iPhone|iPad|iPod/i.test(navigator.userAgent);
195    }
196
197    /**
198     * Build menu items from registered + active commands.
199     * @returns {void}
200     */
201    buildOnceFromCommands() {
202        if (!this.listElement) {
203            return;
204        }
205
206        const context = this.engine.buildContext(null);
207        for (const command of this.engine.commands) {
208            const shouldInclude = this.shouldIncludeInMenu(command, context);
209            if (shouldInclude !== true) {
210                continue;
211            }
212
213            const item = this.createListItemForCommand(command);
214            if (item) {
215                this.listElement.appendChild(item);
216            }
217        }
218    }
219
220    /**
221     * @param {BaseCommand} command
222     * @param {CommandContext} context
223     * @returns {boolean}
224     */
225    shouldIncludeInMenu(command, context) {
226        const explicitlyIncluded = command.options?.includeInOverlay === true;
227        if (explicitlyIncluded !== true) {
228            return false;
229        }
230
231        const active = command.isActive(context);
232        if (active !== true) {
233            return false;
234        }
235
236        return true;
237    }
238
239    /**
240     * Opens the shortcuts menu and moves focus to the first menu item.
241     * Captures the currently focused element for restoration when the menu closes.
242     * 
243     * @returns {void}
244     */
245    open() {
246        if (!this.rootElement || !this.listElement) {
247            return;
248        }
249
250        const wasHidden = this.rootElement.classList.contains("is-hidden");
251        this.rootElement.classList.remove("is-hidden");
252
253        if (wasHidden === true) {
254            // Capture the currently focused element (only if we haven't already saved one)
255            if (!this.restoreFocusElement) {
256                const focusedElement = document.activeElement;
257                // Save only if it's a real element, not <body>, and not inside the menu
258                this.restoreFocusElement = (focusedElement && focusedElement !== document.body && !this.rootElement.contains(focusedElement)) ? focusedElement : null;
259            }
260
261            const firstButton = this.listElement.querySelector(".shortcuts-menu__button");
262            if (firstButton instanceof HTMLElement) {
263                firstButton.focus();
264            }
265        }
266    }
267
268    /**
269     * @returns {void}
270     */
271    close() {
272        this.rootElement.classList.add("is-hidden");
273
274        // Skip restoring focus
275        if (this.suppressRestoreOnClose) {
276            this.suppressRestoreOnClose = false;
277            this.restoreFocusElement = null;
278            return;
279        }
280
281        // Ensure element we are about to try to focus still exists
282        if (this.restoreFocusElement && document.contains(this.restoreFocusElement)) {
283            this.restoreFocusElement.focus({ preventScroll: true });
284        }
285
286        this.restoreFocusElement = null;
287    }
288
289    /**
290     * @returns {void}
291     */
292    toggle() {
293        if (this.isOpen() === true) {
294            this.close();
295        } else {
296            this.open();
297        }
298    }
299
300    /**
301     * @returns {boolean}
302     */
303    isOpen() {
304        if (!this.rootElement) {
305            return false;
306        }
307
308        return !this.rootElement.classList.contains("is-hidden");
309    }
310
311    /**
312     * Show the menu when any of its descendants gain focus.
313     * @param {FocusEvent} _event
314     * @returns {void}
315     */
316    onMenuFocusIn = (_event) => {
317        this.open();
318    };
319
320    /**
321     * Hide the menu when focus leaves the menu tree (deferred to allow the new focus to land).
322     * @param {FocusEvent} _event
323     * @returns {void}
324     */
325    onMenuFocusOut = (_event) => {
326        setTimeout(() => {
327            const active = document.activeElement;
328            const stillInside = active instanceof Node && this.rootElement.contains(active);
329            if (stillInside !== true) {
330                this.close();
331            }
332        }, 0);
333    };
334
335    /**
336     * Key handling while the menu is open.
337     * Esc closes; Enter activates focused item; ArrowUp/Down move focus.
338     * @param {KeyboardEvent} event
339     * @returns {boolean} True if the event was handled by the menu's internal logic (Tab, Esc, Enter, Arrows).
340     */
341    onMenuKeydown = (event) => {
342        // If user is tabbing (forward or backward), allow natural focus movement out of the menu.
343        if (event.key === "Tab") {
344            this.suppressRestoreOnClose = true;
345            return true;
346        }
347
348        if (event.key === "Escape") {
349            event.preventDefault();
350            this.close();
351            return true;
352        }
353
354        if (event.key === "Enter") {
355            event.preventDefault();
356            this.handleEnterOnFocusedButton();
357            return true;
358        }
359
360        if (event.key === "ArrowDown" || event.key === "ArrowUp") {
361            event.preventDefault();
362            this.handleArrowNavigation(event.key === "ArrowDown" ? 1 : -1);
363            return true;
364        }
365
366        // Key was not handled by menu internal logic
367        return false;
368    };
369
370    /**
371     * Activate the currently focused shortcuts-menu button.
372     * Runs its command and closes the menu.
373     * @returns {void}
374     */
375    handleEnterOnFocusedButton() {
376        const activeElement = document.activeElement;
377
378        if (
379            activeElement instanceof HTMLButtonElement &&
380            activeElement.classList.contains("shortcuts-menu__button")
381        ) {
382            const commandId = activeElement.dataset.commandId;
383            if (commandId) {
384                // If any command except the menu toggle command ran, suppress focus restoration 
385                if (commandId !== "toggle-menu") {
386                    this.suppressRestoreOnClose = true;
387                }
388
389                this.engine.runCommandById(commandId, null);
390            }
391
392            this.close();
393        }
394    }
395
396    /**
397     * Keyboard navigation within the shortcuts menu.
398     * Moves focus by `delta` (+1 next, -1 previous) among .shortcuts-menu__button elements,
399     * wrapping at the ends. No-op when the list is empty or missing.
400     * @param {number} delta
401     * @returns {void}
402     */
403    handleArrowNavigation(delta) {
404        const buttons = Array.from(
405            this.listElement.querySelectorAll(".shortcuts-menu__button")
406        );
407
408        if (buttons.length === 0) {
409            return;
410        }
411
412        let index = 0;
413
414        const activeElement = document.activeElement;
415        if (activeElement instanceof HTMLButtonElement) {
416            const found = buttons.indexOf(activeElement);
417            if (found >= 0) {
418                index = found;
419            }
420        }
421
422        let next = index + delta;
423        if (next < 0) {
424            next = buttons.length - 1;
425        }
426
427        if (next > buttons.length - 1) {
428            next = 0;
429        }
430
431        const target = buttons[next];
432        if (target instanceof HTMLElement) {
433            target.focus();
434        }
435    }
436
437    /**
438     * Create an <li> containing a button for the given command.
439     * Returns null when the command is not valid for rendering.
440     * @param {BaseCommand} command
441     * @returns {HTMLLIElement|null}
442     */
443    createListItemForCommand(command) {
444        if (!command || typeof command.id !== "string") {
445            return null;
446        }
447
448        const listItem = document.createElement("li");
449        listItem.className = "shortcuts-menu__item";
450        listItem.setAttribute("role", "presentation");
451
452        const button = this.createButtonForCommand(command);
453        listItem.appendChild(button);
454
455        return listItem;
456    }
457
458    /**
459     * Build a clickable command button with label, keycaps, and aria-keyshortcuts.
460     * @param {BaseCommand} command
461     * @returns {HTMLButtonElement}
462     */
463    createButtonForCommand(command) {
464        const button = document.createElement("button");
465        button.type = "button";
466        button.className = "shortcuts-menu__button";
467        button.dataset.commandId = command.id;
468        button.setAttribute("role", "menuitem");
469
470        const label = document.createElement("span");
471        label.className = "shortcuts-menu__label";
472        label.textContent = command.options.overlayLabel || command.title;
473
474        const keycaps = document.createElement("span");
475        keycaps.className = "shortcuts-menu__keycap";
476
477        const binding = Array.isArray(command.bindings) && command.bindings.length > 0
478            ? command.bindings[0]
479            : null;
480
481        if (binding) {
482            const parts = this.bindingToKeycapParts(binding, this.isApplePlatform);
483            this.appendKeycaps(keycaps, parts);
484            this.applyAriaKeyShortcuts(button, binding, parts);
485        }
486
487        // Prevent Safari from moving focus on mousedown, which would trigger focusout/close before click.
488        button.addEventListener("mousedown", (event) => {
489            event.preventDefault();
490        });
491
492        button.addEventListener("click", () => {
493            const commandId = button.dataset.commandId;
494            if (commandId) {
495                if (commandId !== "toggle-menu") {
496                    this.suppressRestoreOnClose = true;
497                }
498
499                this.engine.runCommandById(commandId, null);
500            }
501
502            this.close();
503        });
504
505        button.appendChild(label);
506        button.appendChild(keycaps);
507        return button;
508    }
509
510    /**
511     * Convert a binding to keycap tokens for display.
512     * @param {KeyBinding} binding
513     * @param {boolean} isMac
514     * @returns {string[]}
515     */
516    bindingToKeycapParts(binding, isMac) {
517        /** @type {string[]} */
518        const parts = [];
519
520        if (binding.shift === true) {
521            parts.push("shift");
522        }
523
524        if (binding.alt === true) {
525            parts.push(isMac ? "opt" : "alt");
526        }
527
528        if (binding.ctrl === true) {
529            parts.push("ctrl");
530        }
531
532        if (binding.meta === true) {
533            parts.push(isMac ? "⌘" : "meta");
534        }
535
536        if (typeof binding.code === "string") {
537            if (binding.code.startsWith("Key") && binding.code.length === 4) {
538                parts.push(binding.code.substring(3));
539            } else if (binding.code.startsWith("Digit") && binding.code.length === 6) {
540                parts.push(binding.code.substring(5));
541            } else {
542                parts.push(binding.code);
543            }
544        }
545
546        return parts;
547    }
548
549    /**
550     * Human-friendly ARIA string for assistive tech.
551     * @param {KeyBinding} binding
552     * @param {string} printable
553     * @param {boolean} isMac
554     * @returns {string}
555     */
556    buildAriaKeyShortcuts(binding, printable, isMac) {
557        /** @type {string[]} */
558        const aria = [];
559
560        if (binding.shift === true) {
561            aria.push("Shift");
562        }
563
564        if (binding.ctrl === true) {
565            aria.push("Control");
566        }
567
568        if (binding.alt === true) {
569            // Use "Option" on Mac, "Alt" elsewhere
570            aria.push(isMac ? "Option" : "Alt");
571        }
572
573        if (binding.meta === true) {
574            // Use "Command" on Mac, "Meta" elsewhere
575            aria.push(isMac ? "Command" : "Meta");
576        }
577
578        if (typeof printable === "string" && printable.length > 0) {
579            aria.push(printable);
580        }
581
582        if (aria.length === 0) {
583            return "";
584        }
585
586        return aria.join("+");
587    }
588
589    /**
590     * Render visual keycaps into `container` (e.g., Shift + Alt + M).
591     * Inserts "+" separators between keys.
592     * @param {HTMLElement} container
593     * @param {string[]} parts
594     * @returns {void}
595     */
596    appendKeycaps(container, parts) {
597        for (let i = 0; i < parts.length; i++) {
598            const cap = document.createElement("span");
599            cap.className = "shortcuts-menu__key";
600            cap.textContent = parts[i];
601            container.appendChild(cap);
602
603            const isLast = i === parts.length - 1;
604            if (isLast !== true) {
605                const plus = document.createElement("span");
606                plus.className = "shortcuts-menu__plus";
607                plus.setAttribute("aria-hidden", "true");
608                plus.textContent = "+";
609                container.appendChild(plus);
610            }
611        }
612    }
613
614    /**
615     * Set `aria-keyshortcuts` to match the binding (e.g., "Shift+Option+M" on Mac).
616     * No-op when no printable token is available.
617     * @param {HTMLButtonElement} button
618     * @param {KeyBinding} binding
619     * @param {string[]} parts
620     * @returns {void}
621     */
622    applyAriaKeyShortcuts(button, binding, parts) {
623        const printable = parts.length > 0 ? parts[parts.length - 1] : "";
624
625        // Pass the platform info to buildAriaKeyShortcuts
626        const aria = this.buildAriaKeyShortcuts(binding, printable, this.isApplePlatform);
627        if (aria.length > 0) {
628            button.setAttribute("aria-keyshortcuts", aria);
629        }
630    }
631}
632
633class ShortcutEngine {
634    /**
635     * Runtime configuration merged with user options at construction.
636     * - `namespace`: short id for DOM hooks or log prefixes
637     * - `selectors`: query candidates for common targets
638     * - `urls`: fallback navigation targets for commands
639     * @type {Object}
640     */
641    config = {
642        namespace: "ws",
643        selectors: {
644            mainBannerCandidates: ["[data-main-banner]", "#main-banner", ".main-banner", ".hero", ".banner", ".hero-banner"],
645            mainContentCandidates: ["main", "[role='main']", "#main", ".main"],
646            searchFieldCandidates: ["#product-search"]
647        },
648        urls: { cart: "/checkout.html", account: "/account/dashboard.html", home: "/" }
649    };
650
651    /**
652     * Short name used for ids, data-attributes, and logs.
653     * @type {string}
654     */
655    namespace = "ws";
656
657    /**
658     * Selector candidates read by commands at runtime.
659     * Pulled from `config.selectors` during construction.
660     * @type {Object}
661     */
662    selectors = {};
663
664    /**
665     * Fallback navigation hrefs used by nav commands.
666     * Pulled from `config.urls` during construction.
667     * @type {Object}
668     */
669    urls = {};
670
671    /**
672     * Registered commands searched in binding order.
673     * @type {BaseCommand[]}
674     */
675    commands = [];
676
677    constructor(options = {}) {
678        const merged = { ...this.config, ...options };
679
680        this.config = { ...merged, ...options };
681        this.namespace = this.config.namespace;
682        this.selectors = this.config.selectors;
683        this.urls = this.config.urls;
684    }
685
686    /**
687     * Initialize the engine once: attach the keydown listener.
688     * @returns {void}
689     */
690    init() {
691        document.addEventListener("keydown", (event) => this.handleKeyDown(event), true);
692    }
693
694    /**
695     * Register a command instance.
696     * @param {BaseCommand} command
697     * @returns {void}
698     */
699    register(command) {
700        this.commands.push(command);
701    }
702
703    /**
704     * Programmatically execute a command by id.
705     * @param {string} commandId
706     * @param {KeyboardEvent|null} [event]
707     * @returns {void}
708     */
709    runCommandById(commandId, event) {
710        const found = this.commands.find((c) => c.id === commandId);
711
712        if (!found) {
713            console.warn("[ShortcutEngine] Command not found:", commandId);
714            return;
715        }
716        this._run(found, event);
717    }
718
719    /**
720     * Handle keydown events and dispatch to the first matching command.
721     * Guards:
722     *  - Ignore while typing in form fields/contenteditable.
723     *  - Ignore repeated keydown events (held key).
724     *  - If Ctrl/Meta is pressed and no command explicitly requires them for this key, do nothing.
725     * @param {KeyboardEvent} event
726     * @returns {void}
727     */
728    handleKeyDown(event) {
729        if (this.shouldIgnoreDueToTyping(event) === true) {
730            return;
731        }
732
733        // Repeat guard
734        if (event.repeat === true) {
735            return;
736        }
737
738        if (this.shouldIgnoreDueToCtrlMetaConflict(event) === true) {
739            return;
740        }
741
742        const matched = this.findFirstMatchingActiveCommand(event);
743        if (matched !== null) {
744            event.preventDefault();
745            this._run(matched, event);
746        }
747    }
748
749    /**
750     * True when the event target is an editing surface (inputs or contenteditable).
751     * @param {KeyboardEvent} event
752     * @returns {boolean}
753     */
754    shouldIgnoreDueToTyping(event) {
755        const target = event && event.target ? event.target : null;
756
757        if (!(target instanceof HTMLElement)) {
758            return false;
759        }
760
761        const tagName = target.tagName.toLowerCase();
762        const isInput = tagName === "input";
763        const isTextarea = tagName === "textarea";
764        const isSelect = tagName === "select";
765        if (isInput === true || isTextarea === true || isSelect === true) {
766            return true;
767        }
768
769        const contentEditable = target.getAttribute("contenteditable");
770        if (typeof contentEditable === "string" && contentEditable.toLowerCase() !== "false") {
771            return true;
772        }
773
774        return false;
775    }
776
777    /**
778     * If Ctrl/Meta is held, only allow if some active binding for this code
779     * explicitly requires Ctrl or Meta (prevents accidental conflicts).
780     * @param {KeyboardEvent} event
781     * @returns {boolean} True when we should ignore the event.
782     */
783    shouldIgnoreDueToCtrlMetaConflict(event) {
784        const ctrlOrMetaHeld = event.ctrlKey === true || event.metaKey === true;
785        if (ctrlOrMetaHeld !== true) {
786            return false;
787        }
788
789        const code = event.code;
790
791        let allowed = false;
792
793        for (const command of this.commands) {
794            if (!Array.isArray(command.bindings) || command.bindings.length === 0) {
795                continue;
796            }
797
798            const context = this.buildContext();
799            const isActive = command.isActive(context);
800            if (isActive !== true) {
801                continue;
802            }
803
804            for (const binding of command.bindings) {
805                const sameCode = binding.code === code;
806                const requiresCtrlOrMeta = binding.ctrl === true || binding.meta === true;
807
808                if (sameCode === true && requiresCtrlOrMeta === true) {
809                    allowed = true;
810                    break;
811                }
812            }
813
814            if (allowed === true) {
815                break;
816            }
817        }
818
819        if (allowed !== true) {
820            return true;
821        }
822
823        return false;
824    }
825
826    /**
827     * Return the first active command whose bindings match this keyboard event.
828     * @param {KeyboardEvent} event
829     * @returns {BaseCommand|null}
830     */
831    findFirstMatchingActiveCommand(event) {
832        const code = event.code;
833
834        for (const command of this.commands) {
835            if (!Array.isArray(command.bindings) || command.bindings.length === 0) {
836                continue;
837            }
838
839            const context = this.buildContext(event);
840            const isActive = command.isActive(context);
841            if (isActive !== true) {
842                continue;
843            }
844
845            const matches = this.matchesAnyBinding(event, code, command.bindings);
846            if (matches === true) {
847                return command;
848            }
849        }
850
851        return null;
852    }
853
854
855    /**
856     * True when at least one binding matches the event.
857     * @param {KeyboardEvent} event
858     * @param {string} code
859     * @param {KeyBinding[]} bindings
860     * @returns {boolean}
861     */
862    matchesAnyBinding(event, code, bindings) {
863        for (const binding of bindings) {
864            const doesMatch = this._matchesBinding(event, code, binding);
865            if (doesMatch === true) {
866                return true;
867            }
868        }
869
870        return false;
871    }
872
873    /**
874     * True when the physical code matches and all modifier keys match exactly.
875     * @param {KeyboardEvent} event
876     * @param {string} code
877     * @param {KeyBinding} binding
878     * @returns {boolean}
879     */
880    _matchesBinding(event, code, binding) {
881        if (binding.code !== code) {
882            return false;
883        }
884
885        const eventMods = {
886            alt: event.altKey === true,
887            shift: event.shiftKey === true,
888            ctrl: event.ctrlKey === true,
889            meta: event.metaKey === true
890        };
891
892        const requiredMods = {
893            alt: binding.alt === true,
894            shift: binding.shift === true,
895            ctrl: binding.ctrl === true,
896            meta: binding.meta === true
897        };
898
899        for (const mod of ["alt", "shift", "ctrl", "meta"]) {
900            if (eventMods[mod] !== requiredMods[mod]) {
901                return false;
902            }
903        }
904
905        return true;
906    }
907
908    /**
909     * Execute a command with a fresh context.
910     * @param {BaseCommand} command
911     * @param {KeyboardEvent|null} event
912     * @returns {void}
913     */
914    _run(command, event) {
915        const context = this.buildContext(event);
916        command.execute(context);
917    }
918
919    /**
920     * Build the execution context for a command.
921     * @param {KeyboardEvent|null} event
922     * @returns {CommandContext}
923     */
924    buildContext(event) {
925        return {
926            engine: this,
927            event: event || null,
928            isHomePage: location.pathname === "/",
929            selectors: { ...this.selectors },
930            urls: { ...this.urls }
931        };
932    }
933}
934
935/**
936 * Skip to Main Content (prefer main banner).
937 * Alt/Option + M
938 */
939class FocusMainCommand extends BaseCommand {
940    constructor() {
941        super(
942            "focus-main",
943            "Main Content",
944            "Skip to main content (prefer main banner).",
945            [{ code: "KeyM", alt: true }],
946            { when: () => true, includeInOverlay: true }
947        );
948    }
949
950    /**
951     * @param {CommandContext} context
952     * @returns {void}
953     */
954    execute(context) {
955        const mainSelectors = context.selectors.mainContentCandidates || [];
956        /** @type {HTMLElement|null} */
957        const target = DomUtils.queryFirst(mainSelectors);
958
959        if (target) {
960            DomUtils.ensureTabbable(target);
961            this.smoothScrollIntoView(target);
962            DomUtils.focusElement(target, { preventScroll: true });
963        }
964    }
965
966    /**
967     * Smoothly scroll an element into view
968     * @param {HTMLElement} element
969     * @returns {void}
970     */
971    smoothScrollIntoView(element) {
972        element.scrollIntoView({ behavior: "smooth", block: "start" });
973    }
974}
975
976/**
977 * Focus Search.
978 * Alt/Option + S
979 */
980class FocusSearchCommand extends BaseCommand {
981    constructor() {
982        super(
983            "focus-search",
984            webshop._.webshop.placeholder.searchbutton,
985            "Move focus to search input.",
986            [{ code: "KeyS", alt: true }],
987            { when: () => true, includeInOverlay: true }
988        );
989    }
990
991    /**
992     * @param {CommandContext} context
993     * @returns {void}
994     */
995    execute(context) {
996        const selectors = context.selectors.searchFieldCandidates || [];
997        const element = DomUtils.queryFirst(selectors);
998
999        if (element) {
1000            DomUtils.ensureTabbable(element);
1001            DomUtils.focusElement(element, { preventScroll: false });
1002
1003            if (element instanceof HTMLInputElement) {
1004                element.select();
1005            }
1006        }
1007    }
1008}
1009
1010/**
1011 * Go to Cart.
1012 * Alt/Option + C
1013 */
1014class GoCartCommand extends BaseCommand {
1015    constructor() {
1016        super(
1017            "goto-cart",
1018            webshop._.header.cart.title,
1019            "Open the cart page.",
1020            [{ code: "KeyC", alt: true }],
1021            { when: () => true, includeInOverlay: true }
1022        );
1023    }
1024
1025    /**
1026     * @param {CommandContext} context
1027     * @returns {void}
1028     */
1029    execute(context) {
1030        const href = context.urls.cart || "/checkout.html";
1031        location.href = href;
1032    }
1033}
1034
1035/**
1036 * Go to Account.
1037 * Alt/Option + A
1038 */
1039class GoAccountCommand extends BaseCommand {
1040    constructor() {
1041        super(
1042            "goto-account",
1043            webshop._.header.account.title,
1044            "Open account/dashboard page.",
1045            [{ code: "KeyA", alt: true }],
1046            { when: () => true, includeInOverlay: true }
1047        );
1048    }
1049
1050    /**
1051     * @param {CommandContext} context
1052     * @returns {void}
1053     */
1054    execute(context) {
1055        const href = context.urls.account || "/account/dashboard.html";
1056        location.href = href;
1057    }
1058}
1059
1060/**
1061 * Go Home (non-home only).
1062 * Alt/Option + H
1063 */
1064class GoHomeCommand extends BaseCommand {
1065    constructor() {
1066        super(
1067            "goto-home",
1068            webshop._.breadcrumb.root,
1069            "Return to home page.",
1070            [{ code: "KeyH", alt: true }],
1071            {
1072                when: (context) =>
1072 context.isHomePage !== true,
1073                includeInOverlay: true
1074            }
1075        );
1076    }
1077
1078    /**
1079     * @param {CommandContext} context
1080     * @returns {void}
1081     */
1082    execute(context) {
1083        const href = context.urls.home || "/";
1084        location.href = href;
1085    }
1086}
1087
1088/**
1089 * Toggle menu visibility.
1090 * Shift + Alt/Option + Z
1091 */
1092class ToggleMenuCommand extends BaseCommand {
1093    constructor() {
1094        super(
1095            "toggle-menu",
1096            "Show/Hide",
1097            "Toggle shortcuts menu.",
1098            [{ code: "KeyZ", alt: true, shift: true }],
1099            { when: () => true, includeInOverlay: true }
1100        );
1101    }
1102
1103    /**
1104     * @param {CommandContext} _context
1105     * @returns {void}
1106     */
1107    execute(_context) {
1108        document.dispatchEvent(new CustomEvent("shortcuts:toggle-menu"));
1109    }
1110}
1111
1112document.addEventListener("DOMContentLoaded", () => {
1113    const engine = new ShortcutEngine({ namespace: "ws" });
1114
1115    // Registration order determines the order in which the commands will be displayed in the menu
1116    engine.register(new FocusMainCommand());
1117    engine.register(new FocusSearchCommand());
1118    engine.register(new GoAccountCommand());
1119    engine.register(new GoCartCommand());
1120    engine.register(new GoHomeCommand());
1121    engine.register(new ToggleMenuCommand());
1122
1123    // Create and wire the menu UI
1124    const menu = new ShortcutsMenu(engine);
1125    menu.buildOnceFromCommands();
1126
1127    engine.init();
1128
1129    document.addEventListener("shortcuts:toggle-menu", () => menu.toggle());
1130});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.