1import { createDropdown, getUniqueDropdownMenuId } from './dropdown.js'; 2 3// The select owns the value and form events; the button and listbox present it. 4const bindDropdownToSelect = ({ 5 dropdownRoot, 6 trigger, 7 currentLabel, 8 select, 9 labelEl, 10 menuIdBase = 'dropdown-menu', 11 spacing, 12 menuClassName = '', 13 itemClassName = '', 14 menuAttributes = {}, 15 emitInput = false, 16}) => { 17 if (!dropdownRoot || !trigger || !currentLabel || !select) { 18 return null; 19 } 20 21 const previousSelectHidden = select.hidden; 22 const previousTriggerHidden = trigger.hidden; 23 const previousLabelFor = labelEl?.getAttribute('for'); 24 let destroyed = false; 25 let resetTimer = null; 26 const isDisabled = () => select.disabled || select.matches(':disabled'); 27 const updateCurrent = () => { 28 const opt = Array.from(select.options).find((option) => option.value === select.value); 29 const label = (opt?.label || opt?.textContent || select.value || '').trim(); 30 if (currentLabel.textContent !== label) currentLabel.textContent = label; 31 if (trigger.disabled !== isDisabled()) trigger.disabled = isDisabled(); 32 }; 33 34 const getOptions = () => 35 Array.from(select.options).map((option) => ({ 36 value: option.value, 37 label: (option.label || option.textContent || '').trim(), 38 disabled: option.disabled || option.parentElement?.disabled === true, 39 hidden: option.hidden || (option.parentElement?.tagName === 'OPTGROUP' && option.parentElement.hidden), 40 })); 41 42 const setValue = (value, { emit = true } = {}) => { 43 const next = String(value ?? ''); 44 if (select.value === next) { 45 updateCurrent(); 46 return; 47 } 48 49 select.value = next; 50 updateCurrent(); 51 controller?.render(); 52 if (emit) { 53 if (emitInput) select.dispatchEvent(new Event('input', { bubbles: true })); 54 select.dispatchEvent(new Event('change', { bubbles: true })); 55 } 56 }; 57 58 const menuId = trigger.getAttribute('aria-controls') || menuIdBase; 59 if (!trigger.id) trigger.id = getUniqueDropdownMenuId(`${menuIdBase}-trigger`); 60 if (!currentLabel.id) currentLabel.id = getUniqueDropdownMenuId(`${menuIdBase}-value`); 61 trigger.setAttribute('role', 'combobox'); 62 trigger.setAttribute('aria-haspopup', 'listbox'); 63 trigger.setAttribute('aria-controls', menuId); 64 65 const controller = createDropdown({ 66 dropdown: dropdownRoot, 67 trigger, 68 menuId, 69 spacing, 70 menuClassName: `fixed z-60 ${menuClassName}`.trim(), 71 menuAttributes: { role: 'listbox', 'aria-labelledby': trigger.id, ...menuAttributes, 'data-select-menu': '' }, 72 openOnTypeahead: true, 73 renderMenu: (menuEl, setOpen) => { 74 updateCurrent(); 75 const focusedValue = menuEl.contains(document.activeElement) 76 ? document.activeElement.getAttribute('data-select-value') 77 : null; 78 menuEl.replaceChildren(); 79 80 for (const opt of getOptions()) { 81 if (opt.hidden) continue; 82 const item = document.createElement('button'); 83 item.type = 'button'; 84 item.tabIndex = -1; 85 item.className = itemClassName; 86 item.textContent = opt.label || opt.value; 87 item.setAttribute('role', 'option'); 88 item.setAttribute('aria-selected', String(opt.value === select.value)); 89 item.dataset.selectOption = ''; 90 item.dataset.selectValue = opt.value; 91 item.dataset.dropdownItem = ''; 92 item.dataset.label = opt.label || opt.value; 93 94 if (opt.disabled) { 95 item.disabled = true; 96 item.setAttribute('aria-disabled', 'true'); 97 } 98 item.addEventListener('click', () => { 99 if (isDisabled() || opt.disabled) return; 100 setOpen(false, { restoreFocus: true }); 101 setValue(opt.value); 102 }); 103 104 menuEl.appendChild(item); 105 } 106 107 const rect = trigger.getBoundingClientRect(); 108 menuEl.style.minWidth = `${Math.min(rect.width, window.innerWidth - 16)}px`; 109 if (focusedValue !== null) { 110 const enabled = Array.from(menuEl.querySelectorAll('[data-select-option]:not(:disabled)')); 111 const focused = enabled.find((item) => item.dataset.selectValue === focusedValue) || enabled[0]; 112 focused?.focus({ preventScroll: true }); 113 } 114 }, 115 }); 116 117 if (labelEl) { 118 labelEl.setAttribute('for', trigger.id); 119 } 120 121 trigger.hidden = false;
122 select.hidden = true; 123 124 const refresh = () => { 125 if (destroyed) return; 126 updateCurrent(); 127 if (isDisabled()) controller?.close({ restoreFocus: false }); 128 controller?.render(); 129 }; 130 const onReset = (event) => { 131 window.clearTimeout(resetTimer); 132 resetTimer = window.setTimeout(() => { 133 if (!event.defaultPrevented) refresh(); 134 }, 0); 135 }; 136 const form = select.form; 137 select.addEventListener('change', refresh); 138 form?.addEventListener('reset', onReset); 139 140 const destroyController = controller?.destroy; 141 if (controller) { 142 controller.destroy = (options) => { 143 if (destroyed) return; 144 destroyed = true; 145 window.clearTimeout(resetTimer); 146 select.removeEventListener('change', refresh); 147 form?.removeEventListener('reset', onReset); 148 destroyController(options); 149 select.hidden = previousSelectHidden; 150 trigger.hidden = previousTriggerHidden; 151 if (labelEl) { 152 if (previousLabelFor === null) labelEl.removeAttribute('for'); 153 else labelEl.setAttribute('for', previousLabelFor); 154 } 155 }; 156 } 157 158 updateCurrent(); 159 controller?.render(); 160 161 return { controller, setValue, updateCurrent, refresh }; 162}; 163 164export { bindDropdownToSelect };
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.