1// A11y-first dropdown primitive with: 2// - Portal for menu (body-level) 3// - ARIA wiring (aria-controls, aria-expanded, aria-haspopup) 4// - Typeahead with repeated-letter cycling (native-like) 5// - Safe item collection (skips disabled/hidden) 6// - Outside click + Escape + resize/scroll handling 7// - Shared global listeners while dropdowns are open 8// - destroy() to clean up listeners 9 10const getDropdownPortal = () => { 11 let portal = document.querySelector('[data-dropdown-portal]'); 12 if (!portal) { 13 portal = document.createElement('div'); 14 portal.setAttribute('data-dropdown-portal', ''); 15 document.body.appendChild(portal); 16 } 17 return portal; 18}; 19 20const getUniqueDropdownMenuId = (() => { 21 const usedDropdownMenuIds = new Set(); 22 let dropdownMenuIdIndex = 0; 23 24 return (preferredId) => { 25 const baseId = preferredId || 'dropdown-menu'; 26 let candidate = baseId; 27 while (usedDropdownMenuIds.has(candidate) || document.getElementById(candidate)) { 28 dropdownMenuIdIndex += 1; 29 candidate = `${baseId}-${dropdownMenuIdIndex}`; 30 } 31 usedDropdownMenuIds.add(candidate); 32 return candidate; 33 }; 34})(); 35 36const isElementVisible = (el) => el instanceof HTMLElement && el.getClientRects().length > 0; 37 38const setAttrIfMissing = (el, name, value) => { 39 if (!(el instanceof HTMLElement)) return; 40 if (!el.hasAttribute(name)) el.setAttribute(name, value); 41}; 42 43const normalizeDropdownAlign = (value) => { 44 if (value === 'center' || value === 'right') { 45 return value; 46 } 47 return 'left'; 48}; 49 50const normalizeDropdownSide = (value) => { 51 if (value === 'top' || value === 'bottom') { 52 return value; 53 } 54 return 'auto'; 55}; 56 57const isTypeaheadKey = (event) => event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey; 58 59const openDropdownControllers = new Set(); 60let sharedDropdownListenersBound = false; 61 62const getOpenDropdownControllers = () => Array.from(openDropdownControllers); 63 64const onSharedDocumentPointerDown = (event) => { 65 const target = event.target; 66 getOpenDropdownControllers().forEach((controller) => { 67 controller.handleDocumentPointerDown(target); 68 }); 69}; 70 71const onSharedDocumentKeyDown = (event) => { 72 getOpenDropdownControllers().forEach((controller) => { 73 controller.handleDocumentKeyDown(event); 74 }); 75}; 76 77const onSharedWindowScroll = () => { 78 getOpenDropdownControllers().forEach((controller) => { 79 controller.handleWindowScroll(); 80 }); 81}; 82 83const onSharedWindowResize = () => { 84 getOpenDropdownControllers().forEach((controller) => { 85 controller.handleWindowResize(); 86 }); 87}; 88 89const onSharedFocusIn = (event) => { 90 const target = event.target; 91 getOpenDropdownControllers().forEach((controller) => { 92 controller.handleFocusIn(target); 93 }); 94}; 95 96const bindSharedDropdownListeners = () => { 97 if (sharedDropdownListenersBound) { 98 return; 99 } 100 101 document.addEventListener('pointerdown', onSharedDocumentPointerDown, true); 102 document.addEventListener('keydown', onSharedDocumentKeyDown, true); 103 document.addEventListener('focusin', onSharedFocusIn, true); 104 105 window.addEventListener('resize', onSharedWindowResize); 106 window.addEventListener('scroll', onSharedWindowScroll, true); 107 108 sharedDropdownListenersBound = true; 109}; 110 111const unbindSharedDropdownListeners = () => { 112 if (!sharedDropdownListenersBound || openDropdownControllers.size > 0) { 113 return; 114 } 115 116 document.removeEventListener('pointerdown', onSharedDocumentPointerDown, true); 117 document.removeEventListener('keydown', onSharedDocumentKeyDown, true); 118 document.removeEventListener('focusin', onSharedFocusIn, true); 119 120 window.removeEventListener('resize', onSharedWindowResize); 121 window.removeEventListener('scroll', onSharedWindowScroll, true); 122 123 sharedDropdownListenersBound = false; 124}; 125 126const registerOpenDropdownController = (controller) => { 127 if (!controller) { 128 return; 129 } 130 openDropdownControllers.add(controller); 131 bindSharedDropdownListeners(); 132}; 133 134const unregisterOpenDropdownController = (controller) => { 135 if (!controller) { 136 return; 137 } 138 openDropdownControllers.delete(controller); 139 unbindSharedDropdownListeners(); 140}; 141 142const createDropdown = ({ 143 dropdown, 144 trigger, 145 anchor = null, 146 menuId, 147 spacing = 8, 148 align = 'left', 149 side = 'auto', 150 menuClassName = '', 151 menuAttributes = {}, 152 renderMenu, 153 closeOnResize = true, 154 toggleOnClick = true, 155 enableTriggerKeyboard = true, 156 openOnTypeahead = false, 157 onOpenChange = null, 158 itemSelector = '[data-dropdown-item], [role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"], [role="option"], button, a', 159} = {}) => { 160 if (!dropdown || !(dropdown instanceof HTMLElement) || !trigger || !(trigger instanceof HTMLElement)) { 161 return null; 162 } 163 164 let menu = null; 165 let isOpen = false;
166 167 // Typeahead 168 let typeaheadQuery = ''; 169 let lastTypeaheadChar = ''; 170 let typeaheadTimeout = null; 171 172 // For restore focus decisions 173 let lastActiveBeforeOpen = null; 174 175 const resetTypeahead = () => { 176 typeaheadQuery = ''; 177 lastTypeaheadChar = ''; 178 if (typeaheadTimeout) { 179 window.clearTimeout(typeaheadTimeout); 180 typeaheadTimeout = null; 181 } 182 }; 183 184 const ensureMenu = () => { 185 if (!menu) { 186 menu = document.createElement('div'); 187 188 if (menuClassName) { 189 menu.className = menuClassName; 190 } 191 192 const ensuredId = getUniqueDropdownMenuId(menuId || trigger.getAttribute('aria-controls') || undefined); 193 menu.id = ensuredId; 194 195 Object.entries(menuAttributes || {}).forEach(([attr, value]) => { 196 if (value === null || value === undefined) { 197 menu.setAttribute(attr, ''); 198 } else { 199 menu.setAttribute(attr, String(value)); 200 } 201 }); 202 203 // Reasonable defaults if not provided 204 setAttrIfMissing(menu, 'tabindex', '-1'); // allows focusing container in edge cases 205 menu.hidden = true; 206 207 menu.addEventListener('keydown', onMenuKeyDown); 208 } 209 210 const portal = getDropdownPortal(); 211 if (menu.parentElement !== portal) { 212 portal.appendChild(menu); 213 } 214 215 // Wire trigger <-> menu relationship 216 trigger.setAttribute('aria-controls', menu.id); 217 trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 218 dropdown.querySelector('[data-dropdown-indicator]')?.classList.toggle('rotate-180', isOpen); 219 // If consumer forgot aria-haspopup, infer from menu role 220 const role = (menu.getAttribute('role') || '').toLowerCase(); 221 if (!trigger.hasAttribute('aria-haspopup')) { 222 if (role === 'listbox') { 223 trigger.setAttribute('aria-haspopup', 'listbox'); 224 } else if (role) { 225 trigger.setAttribute('aria-haspopup', 'menu'); 226 } else { 227 trigger.setAttribute('aria-haspopup', 'menu'); 228 } 229 } 230 231 return menu; 232 }; 233 234 const getAnchorElement = () => { 235 if (anchor instanceof HTMLElement) { 236 return anchor; 237 } 238 239 return trigger; 240 }; 241 242 const getMenuItems = () => { 243 if (!menu) { 244 return []; 245 } 246 return Array.from(menu.querySelectorAll(itemSelector)) 247 .filter((item) => item instanceof HTMLElement) 248 .filter((item) => item.getAttribute('aria-disabled') !== 'true' && !item.hasAttribute('disabled')) 249 .filter(isElementVisible); 250 }; 251 252 const getSelectedItem = (items) => { 253 return items.find( 254 (item) => item.getAttribute('aria-current') === 'true' || item.getAttribute('aria-selected') === 'true', 255 ); 256 }; 257 258 const scrollMenuItemIntoView = (item) => { 259 if (!menu || !(item instanceof HTMLElement)) { 260 return; 261 } 262 263 const menuRect = menu.getBoundingClientRect(); 264 const itemRect = item.getBoundingClientRect(); 265 const footer = menu.querySelector('[data-dropdown-footer]:not([hidden])'); 266 const visibleBottom = isElementVisible(footer) 267 ? Math.min(menuRect.bottom, footer.getBoundingClientRect().top) 268 : menuRect.bottom; 269 270 if (itemRect.top < menuRect.top) { 271 menu.scrollTop -= menuRect.top - itemRect.top; 272 return; 273 } 274 275 if (itemRect.bottom > visibleBottom) { 276 menu.scrollTop += itemRect.bottom - visibleBottom; 277 } 278 }; 279 280 const focusMenuItem = (item) => { 281 if (item && typeof item.focus === 'function') { 282 item.focus({ preventScroll: true }); 283 scrollMenuItemIntoView(item); 284 } 285 }; 286 287 const focusMenuItemAtIndex = (index, items) => { 288 if (!items.length) { 289 return; 290 } 291 const total = items.length; 292 const normalized = ((index % total) + total) % total; 293 focusMenuItem(items[normalized]); 294 }; 295 296 const focusMenuItemByDelta = (delta) => { 297 const items = getMenuItems(); 298 if (!items.length) { 299 return; 300 } 301 302 const currentIndex = items.indexOf(document.activeElement); 303 const selectedIndex = items.indexOf(getSelectedItem(items)); 304 const startIndex = currentIndex >= 0 ? currentIndex : selectedIndex >= 0 ? selectedIndex : 0; 305 306 focusMenuItemAtIndex(startIndex + delta, items); 307 }; 308 309 const getItemLabel = (item) => { 310 if (!item) { 311 return ''; 312 } 313 const label = item.getAttribute('data-label') || item.textContent || ''; 314 return label.trim().toLowerCase(); 315 }; 316 317 const applyTypeahead = (char) => { 318 const items = getMenuItems(); 319 if (!items.length) return; 320 321 const lower = char.toLowerCase(); 322 323 // Native-like behavior: 324 // - If user repeats the same char quickly, cycle through items starting with that char 325 // - Otherwise, build up a multi-char query 326 if (typeaheadQuery && lower === lastTypeaheadChar && typeaheadQuery === lower) { 327 // cycle mode, keep query as single char 328 typeaheadQuery = lower; 329 } else if (!typeaheadQuery || lower !== lastTypeaheadChar) { 330 // either starting new or building multi-char query 331 typeaheadQuery += lower; 332 } else { 333 // same char but query longer than 1: continue building 334 typeaheadQuery += lower; 335 } 336 337 lastTypeaheadChar = lower; 338 339 if (typeaheadTimeout) window.clearTimeout(typeaheadTimeout); 340 typeaheadTimeout = window.setTimeout(resetTypeahead, 500); 341 342 const currentIndex = items.indexOf(document.activeElement); 343 const startIndex = currentIndex >= 0 ? currentIndex : -1; 344 const total = items.length; 345 346 for (let offset = 1; offset <= total; offset += 1) { 347 const index = (startIndex + offset) % total; 348 if (getItemLabel(items[index]).startsWith(typeaheadQuery)) { 349 focusMenuItem(items[index]); 350 break; 351 } 352 } 353 }; 354 355 const updateMenuPosition = () => { 356 if (!isOpen || !menu) return; 357 358 const rect = getAnchorElement().getBoundingClientRect(); 359 const resolvedAlign = normalizeDropdownAlign(align); 360 const resolvedSide = normalizeDropdownSide(side); 361 362 // Make sure menu has measurable size 363 const menuWidth = menu.offsetWidth; 364 const menuHeight = menu.offsetHeight; 365 366 const maxLeft = Math.max(spacing, window.innerWidth - menuWidth - spacing); 367 let left = rect.left; 368 369 if (resolvedAlign === 'center') { 370 left = rect.left + (rect.width - menuWidth) / 2; 371 } else if (resolvedAlign === 'right') { 372 left = rect.right - menuWidth; 373 } 374 375 if (resolvedAlign === 'left') { 376 left = Math.min(left, maxLeft); 377 } else { 378 left = Math.min(Math.max(left, spacing), maxLeft); 379 } 380 381 const spaceBelow = window.innerHeight - rect.bottom - spacing; 382 const shouldPlaceAbove = resolvedSide === 'top' || (resolvedSide === 'auto' && spaceBelow < menuHeight); 383 384 const top = shouldPlaceAbove ? Math.max(spacing, rect.top - spacing - menuHeight) : rect.bottom + spacing; 385 386 menu.style.left = `${left}px`; 387 menu.style.top = `${top}px`; 388 menu.dataset.placement = shouldPlaceAbove ? 'top' : 'bottom'; 389 }; 390 391 const focusInitialMenuItem = (options = {}) => { 392 const items = getMenuItems(); 393 if (!items.length) return; 394 395 if (options.focusLast) { 396 focusMenuItemAtIndex(items.length - 1, items); 397 return; 398 } 399 400 const selected = getSelectedItem(items); 401 focusMenuItem(selected || items[0]); 402 }; 403 404 const sharedDropdownController = { 405 handleDocumentPointerDown: (target) => { 406 if (!isOpen || !(target instanceof Node)) return; 407 408 // Ignore clicks on trigger or within dropdown/menu 409 if (dropdown.contains(target) || (menu && menu.contains(target))) return; 410 411 close(); 412 }, 413 handleDocumentKeyDown: (event) => { 414 if (!isOpen) return; 415 if (event.key === 'Escape') { 416 event.preventDefault(); 417 event.stopPropagation(); 418 close({ restoreFocus: true }); 419 } 420 }, 421 handleWindowScroll: () => { 422 if (!isOpen) return; 423 updateMenuPosition(); 424 }, 425 handleWindowResize: () => { 426 if (!isOpen) return; 427 if (closeOnResize) close(); 428 else updateMenuPosition(); 429 }, 430 handleFocusIn: (target) => { 431 if (!isOpen || !menu || !(target instanceof Node)) return; 432 433 // If focus moves outside both trigger and menu, close 434 if (!dropdown.contains(target) && !menu.contains(target)) { 435 close(); 436 } 437 }, 438 }; 439 440 const setOpen = (nextState, options = {}) => { 441 if (nextState === isOpen) return; 442 443 isOpen = nextState; 444 445 const menuEl = ensureMenu(); 446 if (isOpen) { 447 lastActiveBeforeOpen = document.activeElement instanceof HTMLElement ? document.activeElement : null; 448 449 if (renderMenu) { 450 renderMenu(menuEl, setOpen); 451 } 452 453 menuEl.hidden = false;
454 dropdown.dataset.open = 'true'; 455 trigger.setAttribute('aria-expanded', 'true'); 456 trigger.removeAttribute('aria-activedescendant'); 457 registerOpenDropdownController(sharedDropdownController); 458 459 updateMenuPosition(); 460 if (menuEl.offsetHeight === 0) requestAnimationFrame(updateMenuPosition); 461 462 if (options.focusMenu) { 463 requestAnimationFrame(() => { 464 // A second key may have already moved focus before this frame. 465 if (isOpen && !menuEl.contains(document.activeElement)) { 466 focusInitialMenuItem({ focusLast: options.focusLast }); 467 } 468 }); 469 } 470 } else { 471 menuEl.hidden = true; 472 delete dropdown.dataset.open; 473 trigger.setAttribute('aria-expanded', 'false'); 474 trigger.removeAttribute('aria-activedescendant'); 475 476 resetTypeahead(); 477 unregisterOpenDropdownController(sharedDropdownController); 478 479 const shouldRestore = 480 options.restoreFocus || 481 (menuEl && menuEl.contains(document.activeElement)) || 482 document.activeElement === document.body; 483 484 if (shouldRestore && document.contains(trigger)) { 485 trigger.focus({ preventScroll: true }); 486 } else if (!options.suppressFocusFallback && restoreFocusFallbackNeeded()) { 487 // If focus is lost into nowhere, try returning it to trigger 488 trigger.focus({ preventScroll: true }); 489 } 490 } 491 492 if (!options.suppressOnOpenChange && typeof onOpenChange === 'function') { 493 onOpenChange(isOpen); 494 } 495 }; 496 497 const restoreFocusFallbackNeeded = () => { 498 // If activeElement is null or body, it usually feels better to return focus. 499 const active = document.activeElement; 500 return !active || active === document.body || active === document.documentElement; 501 }; 502 503 const close = (options) => setOpen(false, options); 504 505 const onTriggerClick = (event) => { 506 if (!toggleOnClick) { 507 return; 508 } 509 event.preventDefault(); 510 setOpen(!isOpen); 511 }; 512 513 const onTriggerKeyDown = (event) => { 514 if (!enableTriggerKeyboard) { 515 return; 516 } 517 518 const { key } = event; 519 520 if (key === 'ArrowDown' || key === 'ArrowUp') { 521 event.preventDefault(); 522 if (!isOpen) { 523 setOpen(true, { focusMenu: true, focusLast: key === 'ArrowUp' }); 524 return; 525 } 526 focusMenuItemByDelta(key === 'ArrowDown' ? 1 : -1); 527 return; 528 } 529 530 if (key === 'Enter' || key === ' ') { 531 event.preventDefault(); 532 if (!isOpen) setOpen(true, { focusMenu: true }); 533 else setOpen(false, { restoreFocus: true }); 534 return; 535 } 536 537 // Optional: Alt+ArrowDown often opens native selects 538 if (key === 'Escape' && isOpen) { 539 event.preventDefault(); 540 setOpen(false, { restoreFocus: true }); 541 return; 542 } 543 544 if ((isOpen || openOnTypeahead) && isTypeaheadKey(event)) { 545 event.preventDefault(); 546 if (!isOpen) { 547 setOpen(true); 548 } 549 applyTypeahead(key); 550 } 551 }; 552 553 const onMenuKeyDown = (event) => { 554 if (!isOpen) return; 555 556 const { key } = event; 557 558 if (key === 'Escape') { 559 event.preventDefault(); 560 event.stopPropagation(); 561 setOpen(false, { restoreFocus: true }); 562 return; 563 } 564 565 if (key === 'ArrowDown') { 566 event.preventDefault(); 567 focusMenuItemByDelta(1); 568 return; 569 } 570 571 if (key === 'ArrowUp') { 572 event.preventDefault(); 573 focusMenuItemByDelta(-1); 574 return; 575 } 576 577 if (key === 'Home') { 578 event.preventDefault(); 579 const items = getMenuItems(); 580 focusMenuItemAtIndex(0, items); 581 return; 582 } 583 584 if (key === 'End') { 585 event.preventDefault(); 586 const items = getMenuItems(); 587 focusMenuItemAtIndex(items.length - 1, items); 588 return; 589 } 590 591 if (key === 'Enter' || key === ' ') { 592 const active = document.activeElement; 593 if (active && menu && menu.contains(active)) { 594 event.preventDefault(); 595 active.click(); 596 } 597 return; 598 } 599 600 if (key === 'Tab') { 601 // Close and allow tabbing to continue in document 602 setOpen(false); 603 return; 604 } 605 606 if (isTypeaheadKey(event)) { 607 event.preventDefault(); 608 applyTypeahead(key); 609 } 610 }; 611 612 // Initialize ARIA baseline 613 trigger.setAttribute('aria-expanded', 'false'); 614 trigger.removeAttribute('aria-activedescendant'); 615 616 // Use existing aria-controls if present, else ensure menu will get an id later. 617 if (!trigger.getAttribute('aria-controls')) { 618 // Will be set in ensureMenu() 619 } 620 621 // Bind listeners 622 trigger.addEventListener('click', onTriggerClick); 623 trigger.addEventListener('keydown', onTriggerKeyDown); 624 625 return { 626 render: () => { 627 if (!isOpen) return; 628 const menuEl = ensureMenu(); 629 if (renderMenu) renderMenu(menuEl, setOpen); 630 updateMenuPosition(); 631 }, 632 open: (options = {}) => { 633 if (isOpen) { 634 if (options.focusMenu) { 635 focusInitialMenuItem({ focusLast: options.focusLast }); 636 } 637 return; 638 } 639 setOpen(true, { focusMenu: true, ...options }); 640 }, 641 close: (options = {}) => close({ restoreFocus: true, ...options }), 642 destroy: (options = {}) => { 643 const shouldRestoreFocus = options.restoreFocus === true; 644 645 // Close first (restores ARIA and state) 646 if (isOpen) { 647 setOpen(false, { 648 restoreFocus: shouldRestoreFocus, 649 suppressOnOpenChange: true, 650 suppressFocusFallback: !shouldRestoreFocus, 651 }); 652 } 653 654 trigger.removeEventListener('click', onTriggerClick); 655 trigger.removeEventListener('keydown', onTriggerKeyDown); 656 unregisterOpenDropdownController(sharedDropdownController); 657 658 if (menu) { 659 menu.removeEventListener('keydown', onMenuKeyDown); 660 menu.remove(); 661 menu = null; 662 } 663 664 resetTypeahead(); 665 666 // If focus was on trigger at init time, restore it 667 if ( 668 shouldRestoreFocus && 669 restoreFocusFallbackNeeded() && 670 lastActiveBeforeOpen && 671 document.contains(lastActiveBeforeOpen) 672 ) { 673 lastActiveBeforeOpen.focus({ preventScroll: true }); 674 } 675 }, 676 }; 677}; 678 679if (typeof globalThis !== 'undefined' && globalThis) { 680 globalThis.createDropdown = createDropdown; 681 globalThis.getUniqueDropdownMenuId = getUniqueDropdownMenuId; 682} 683 684export { createDropdown, getUniqueDropdownMenuId };
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.