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.