// SortDropdown.js — Decoupled sort trigger. // // The native with a custom trigger + menu that mirror its state, so the // trigger sizes to the selected text while the menu sizes to its content. // // The native behavior so the control remains fully accessible. const SORT_GROUP_SELECTOR = '.sort-dropdown-group'; const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active'; /** * Initialize a decoupled sort dropdown around a native behavior. The seeded Random sort is // the exception: clicking it again should reshuffle, so let the // change handler (PageControls) generate a fresh seed. if (String(value).startsWith('random')) { select.dispatchEvent(new Event('change', { bubbles: true })); } return; } select.value = value; select.dispatchEvent(new Event('change', { bubbles: true })); }; const open = () => { document.querySelectorAll(ACTIVE_GROUP_SELECTOR).forEach((g) => { if (g !== group) g.classList.remove('active'); }); group.classList.add('active'); trigger.setAttribute('aria-expanded', 'true'); // Focus the currently selected option (or the first option) so // keyboard navigation starts from a sensible position. requestAnimationFrame(() => { const selected = menu.querySelector('.sort-option.is-selected'); (selected || getOptions()[0])?.focus(); }); }; const close = () => { group.classList.remove('active'); trigger.setAttribute('aria-expanded', 'false'); }; const toggle = () => { if (group.classList.contains('active')) close(); else open(); }; // ---- keyboard navigation ---- // Type-to-select buffer: accumulate characters and reset after a pause. // Shared between trigger and menu keydown handlers. let typeBuffer = ''; let typeTimer = null; const focusOptionByText = (prefix) => { const options = getOptions(); const lower = prefix.toLowerCase(); for (let i = 0; i < options.length; i++) { if (options[i].textContent.toLowerCase().startsWith(lower)) { options[i].focus(); return; } } }; const moveFocus = (options, direction) => { const focused = menu.querySelector('.sort-option:focus'); let idx = focused ? Array.from(options).indexOf(focused) : -1; idx = Math.max(0, Math.min(options.length - 1, idx + direction)); options[idx]?.focus(); }; const handleTypeToSelect = (event) => { if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return false; event.preventDefault(); clearTimeout(typeTimer); typeBuffer += event.key; focusOptionByText(typeBuffer); typeTimer = setTimeout(() => { typeBuffer = ''; }, 800); return true; }; trigger.addEventListener('click', (event) => { event.stopPropagation(); if (select.disabled) return; toggle(); }); trigger.addEventListener('keydown', (event) => { if (event.key === 'Escape') { close(); } else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); if (!select.disabled) toggle(); } else if (!group.classList.contains('active')) { // Type-to-select on closed dropdown: open and highlight match if (handleTypeToSelect(event)) { open(); } } }); menu.addEventListener('keydown', (event) => { const options = getOptions(); if (options.length === 0) return; switch (event.key) { case 'Escape': event.preventDefault(); close(); trigger.focus(); return; case 'ArrowDown': event.preventDefault(); moveFocus(options, 1); return; case 'ArrowUp': event.preventDefault(); moveFocus(options, -1); return; case 'Home': event.preventDefault(); options[0]?.focus(); return; case 'End': event.preventDefault(); options[options.length - 1]?.focus(); return; case 'Enter': case ' ': event.preventDefault(); if (select.disabled) return; const focused = menu.querySelector('.sort-option:focus'); if (focused) { choose(focused.dataset.value); close(); trigger.focus(); } return; } handleTypeToSelect(event); }); // Close dropdown when clicking outside document.addEventListener('click', (event) => { if (!group.contains(event.target)) { const wasOpen = group.classList.contains('active'); close(); // Only return focus to the trigger when the dropdown was actually // open — avoids forcing scrollIntoView on every page click (which // causes the scroll container to jump when clicking a model card). if (wasOpen) trigger.focus(); } }); // ---- property overrides ---- // Override `value` and `disabled` on this instance so programmatic // changes (loadSortPreference, VLM toggle, excluded-view sync, ...) keep // the trigger label and disabled styling in sync without touching callers. const proto = Object.getPrototypeOf(select); const valueDescriptor = Object.getOwnPropertyDescriptor(proto, 'value') || Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value'); const disabledDescriptor = Object.getOwnPropertyDescriptor(proto, 'disabled') || Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'disabled'); if (valueDescriptor) { Object.defineProperty(select, 'value', { get() { return valueDescriptor.get.call(this); }, set(v) { valueDescriptor.set.call(this, v); syncSelected(); }, configurable: true, }); } if (disabledDescriptor) { Object.defineProperty(select, 'disabled', { get() { return disabledDescriptor.get.call(this); }, set(v) { disabledDescriptor.set.call(this, v); group.classList.toggle('is-disabled', Boolean(v)); trigger.disabled = Boolean(v); if (v) close(); }, configurable: true, }); } // Rebuild the menu when