1/** 2 * smrtBuild â Dropdown Menu Helper 3 * 4 * Single source of truth for the "button toggles a menu; outside-click + Esc 5 * close it" wiring duplicated byte-identically across 11+ list-detail pages 6 * (export menus, filter menus, action menus). Sets the proper ARIA attributes 7 * (`aria-haspopup`, `aria-expanded`, `aria-controls`) once at init â every 8 * hand-rolled copy was missing `aria-haspopup` and `aria-controls`. 9 * 10 * Each previous site looked like ~20 LOC of: 11 * const exportBtn = document.getElementById('btn-foo-export'); 12 * const exportMenu = document.getElementById('foo-export-menu'); 13 * if (exportBtn && exportMenu) { 14 * const toggleExportMenu = (show) => { 15 * const isOpen = show ?? exportMenu.classList.contains('hidden'); 16 * exportMenu.classList.toggle('hidden', !isOpen); 17 * exportBtn.setAttribute('aria-expanded', String(isOpen)); 18 * }; 19 * exportBtn.addEventListener('click', (e) => { e.stopPropagation(); toggleExportMenu(); }, opts); 20 * document.addEventListener('click', () => toggleExportMenu(false), opts); 21 * document.addEventListener('keydown', (e) => { 22 * if (e.key === 'Escape' && !exportMenu.classList.contains('hidden')) { 23 * toggleExportMenu(false); 24 * exportBtn.focus(); 25 * } 26 * }, opts); 27 * } 28 * 29 * Becomes: 30 * const menu = attachDropdownMenu('btn-foo-export', 'foo-export-menu', { signal }); 31 * // later, after handling an item click: 32 * menu.close(); 33 */ 34 35/** 36 * Resolve a button/menu ref to an element. Accepts a bare element id 37 * ("btn-foo-export" â the documented convention here), a CSS selector 38 * ("#btn-foo", ".foo"), an element, or a getter fn. 39 * 40 * Bare ids are tried via getElementById FIRST â a plain querySelector would treat 41 * "btn-foo-export" as a *tag* selector and find nothing, silently no-op'ing the 42 * dropdown (the bug that made Export do nothing on every bare-id caller). 43 */ 44function resolveDropdownEl(ref) { 45 if (!ref) return null; 46 if (typeof ref === 'function') return ref() || null; 47 if (typeof ref !== 'string') return ref; 48 return document.getElementById(ref) || document.querySelector(ref); 49} 50 51/** 52 * Wire a button â menu dropdown with click-toggle, outside-click-close, 53 * Esc-close, and correct ARIA attributes. 54 * 55 * The menu is assumed to start hidden (i.e. the `.hidden` class controls 56 * visibility). Listeners are attached with the supplied `signal` so they 57 * auto-remove on page navigation. 58 * 59 * @param {string|HTMLElement} btnRef - The toggle button (id, selector, or element). 60 * @param {string|HTMLElement} menuRef - The menu element (id, selector, or element). 61 * @param {Object} [options] 62 * @param {AbortSignal} [options.signal] - AbortSignal for auto-cleanup on destroy. 63 * @returns {{ toggle: Function, close: Function, open: Function, isOpen: Function, btn: HTMLElement|null, menu: HTMLElement|null }} 64 * A null-safe controller. If either element is missing the returned 65 * object is still callable (no-ops) so callers don't need null guards. 66 */ 67export function attachDropdownMenu(btnRef, menuRef, options = {}) { 68 const btn = resolveDropdownEl(btnRef); 69 const menu = resolveDropdownEl(menuRef); 70 if (!btn || !menu) { 71 return { toggle() {}, close() {}, open() {}, isOpen: () => false, btn: null, menu: null }; 72 } 73 74 const { signal } = options; 75 const listenerOpts = signal ? { signal } : {}; 76 77 // ARIA attributes â set once at init. Every hand-rolled copy was missing 78 // these (only aria-expanded was being toggled), which is an a11y bug. 79 btn.setAttribute('aria-haspopup', 'true'); 80 btn.setAttribute('aria-expanded', menu.classList.contains('hidden') ? 'false' : 'true'); 81 if (menu.id) btn.setAttribute('aria-controls', menu.id); 82 83 const setOpen = (open) => { 84 menu.classList.toggle('hidden', !open); 85 btn.setAttribute('aria-expanded', String(open)); 86 }; 87 const isOpen = () => !menu.classList.contains('hidden'); 88 const toggle = () => setOpen(!isOpen()); 89 const close = () => setOpen(false); 90 const open = () => setOpen(true); 91 92 // Click button â toggle. stopPropagation so the document-level "close on 93 // outside click" handler below doesn't immediately undo us. 94 btn.addEventListener('click', (e) => { 95 e.stopPropagation(); 96 toggle(); 97 }, listenerOpts); 98
99 // Click anywhere else â close. 100 document.addEventListener('click', close, listenerOpts); 101 102 // Escape â close + return focus to the button. 103 document.addEventListener('keydown', (e) => { 104 if (e.key === 'Escape' && isOpen()) { 105 close(); 106 btn.focus(); 107 } 108 }, listenerOpts); 109 110 return { toggle, close, open, isOpen, btn, menu }; 111}
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.