PageSourceSearch

https://smrtbld.ai/js/utils/dropdown-menu.js

js smrtbld.ai collected 2026-10-05 15:10:37 UTC 4,805 bytes, 111 lines download raw bytes

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.