PageSourceSearch

https://site.fr/_static/8c8af5c529149f32/scripts/ui/dropdown.js

js site.fr collected 2026-09-24 18:19:18 UTC 19,683 bytes, 684 lines download raw bytes

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.