feat(controls): add model sub-type quick-switch dropdown to the controls bar

Single-select Model Type dropdown next to the sort control on the loras,
checkpoints and other pages (hidden when only one sub-type exists).
Selection maps to filters.modelTypes, persists via the existing filter
storage, and stays in sync with the filter panel's multi-select chips.
This commit is contained in:
Will Miao
2026-10-18 16:16:59 +08:00
parent 6ab4b041c0
commit 3d65e044f8
16 changed files with 599 additions and 15 deletions
+5
View File
@@ -970,6 +970,11 @@
"random": "Zufällig",
"randomAction": "Zufällig mischen"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "Modelliste aktualisieren",
"full": "Cache neu aufbauen",
+5
View File
@@ -970,6 +970,11 @@
"random": "Random",
"randomAction": "Randomize (shuffle)"
},
"modelType": {
"title": "Filter by model type",
"all": "All Types",
"multiple": "{count} types"
},
"refresh": {
"title": "Refresh model list",
"full": "Rebuild Cache",
+5
View File
@@ -970,6 +970,11 @@
"random": "Aleatorio",
"randomAction": "Aleatorizar (barajar)"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "Actualizar lista de modelos",
"full": "Reconstruir caché",
+5
View File
@@ -970,6 +970,11 @@
"random": "Aléatoire",
"randomAction": "Aléatoire (mélanger)"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "Actualiser la liste des modèles",
"full": "Reconstruire le cache",
+5
View File
@@ -970,6 +970,11 @@
"random": "אקראי",
"randomAction": "ערבוב אקראי"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "רענן רשימת מודלים",
"full": "בניית מטמון מחדש",
+5
View File
@@ -970,6 +970,11 @@
"random": "ランダム",
"randomAction": "シャッフル(ランダム)"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "モデルリストを更新",
"full": "キャッシュを再構築",
+5
View File
@@ -970,6 +970,11 @@
"random": "랜덤",
"randomAction": "셔플 (무작위)"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "모델 목록 새로고침",
"full": "캐시 재구성",
+5
View File
@@ -970,6 +970,11 @@
"random": "Случайно",
"randomAction": "Перемешать"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "Обновить список моделей",
"full": "Перестроить кэш",
+5
View File
@@ -970,6 +970,11 @@
"random": "随机",
"randomAction": "随机排序(洗牌)"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "刷新模型列表",
"full": "重建缓存",
+5
View File
@@ -970,6 +970,11 @@
"random": "隨機",
"randomAction": "隨機排序(洗牌)"
},
"modelType": {
"title": "[TODO: Translate] Filter by model type",
"all": "[TODO: Translate] All Types",
"multiple": "[TODO: Translate] {count} types"
},
"refresh": {
"title": "重新整理模型列表",
"full": "重建快取",
+35 -15
View File
@@ -351,14 +351,17 @@ body.sticky-controls .sticky-topbar {
sizes to the currently selected text only; the dropdown menu sizes to
its content independently. The native <select> is kept in the DOM
(visually hidden) so existing JS that reads/writes `.value` / `.disabled`
and dynamically adds/removes <option>s keeps working. */
and dynamically adds/removes <option>s keeps working. The model-type
dropdown (.model-type-*) shares these styles — same trigger/menu look. */
.sort-dropdown-group {
.sort-dropdown-group,
.model-type-dropdown-group {
position: relative;
display: flex;
}
.sort-trigger {
.sort-trigger,
.model-type-trigger {
display: flex;
align-items: center;
gap: 6px;
@@ -375,8 +378,14 @@ body.sticky-controls .sticky-topbar {
box-shadow: var(--shadow-xs);
}
.model-type-trigger {
min-width: 90px;
}
.sort-trigger:hover,
.sort-trigger:focus-visible {
.sort-trigger:focus-visible,
.model-type-trigger:hover,
.model-type-trigger:focus-visible {
border-color: var(--lora-accent);
background: var(--bg-color);
transform: translateY(-1px);
@@ -384,28 +393,33 @@ body.sticky-controls .sticky-topbar {
outline: none;
}
.sort-trigger:active {
.sort-trigger:active,
.model-type-trigger:active {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.sort-trigger__label {
.sort-trigger__label,
.model-type-trigger__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sort-trigger__caret {
.sort-trigger__caret,
.model-type-trigger__caret {
opacity: 0.8;
transition: transform var(--transition-base);
flex-shrink: 0;
}
.sort-dropdown-group.active .sort-trigger__caret {
.sort-dropdown-group.active .sort-trigger__caret,
.model-type-dropdown-group.active .model-type-trigger__caret {
transform: rotate(180deg);
}
.sort-dropdown-group.active .sort-trigger {
.sort-dropdown-group.active .sort-trigger,
.model-type-dropdown-group.active .model-type-trigger {
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px color-mix(in oklch, var(--lora-accent) 15%, transparent);
}
@@ -423,7 +437,8 @@ body.sticky-controls .sticky-topbar {
/* Dropdown menu — sizes to its content, independent of trigger width.
Inherits base .dropdown-menu styling; capped for very long i18n text. */
.sort-dropdown-menu {
.sort-dropdown-menu,
.model-type-dropdown-menu {
min-width: max-content;
max-width: 320px;
width: max-content;
@@ -446,7 +461,8 @@ body.sticky-controls .sticky-topbar {
}
/* Option items */
.sort-dropdown-group .sort-option {
.sort-dropdown-group .sort-option,
.model-type-dropdown-group .model-type-option {
display: flex;
align-items: center;
gap: 8px;
@@ -457,7 +473,8 @@ body.sticky-controls .sticky-topbar {
white-space: nowrap;
}
.sort-dropdown-group .sort-option::before {
.sort-dropdown-group .sort-option::before,
.model-type-dropdown-group .model-type-option::before {
content: '';
width: 14px;
flex-shrink: 0;
@@ -465,16 +482,19 @@ body.sticky-controls .sticky-topbar {
font-weight: 700;
}
.sort-dropdown-group .sort-option:hover {
.sort-dropdown-group .sort-option:hover,
.model-type-dropdown-group .model-type-option:hover {
background-color: color-mix(in oklch, var(--lora-accent) 10%, transparent);
}
.sort-dropdown-group .sort-option.is-selected {
.sort-dropdown-group .sort-option.is-selected,
.model-type-dropdown-group .model-type-option.is-selected {
color: var(--lora-accent);
font-weight: 600;
}
.sort-dropdown-group .sort-option.is-selected::before {
.sort-dropdown-group .sort-option.is-selected::before,
.model-type-dropdown-group .model-type-option.is-selected::before {
content: '\2713';
color: var(--lora-accent);
}
@@ -0,0 +1,276 @@
// ModelTypeDropdown.js — Single-select model sub-type switcher for the
// controls bar (next to the sort dropdown).
//
// The filter panel's "Model Types" section is a multi-select filter buried
// behind the Filter button; switching the current view between sub-types
// (e.g. VAE vs Text Encoder on the Other page) through it takes several
// clicks and leaves no visible state. This dropdown is the quick,
// always-visible, single-select counterpart: picking a type sets
// filterManager.filters.modelTypes to exactly that type, "All Types" clears
// it. Multi-selections made in the filter panel are still honored — the
// trigger then shows "{count} types" until the user picks a single entry.
//
// Rendered on pages whose model-types endpoint can return more than one
// sub-type (loras / checkpoints / other); it hides itself otherwise.
import { MODEL_TYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { translate } from '../../utils/i18nHelpers.js';
const GROUP_ID = 'modelTypeDropdown';
const ALL_TYPES_VALUE = '';
const ACTIVE_GROUP_SELECTOR = '.dropdown-group.active, .sort-dropdown-group.active';
function getLabelText() {
return {
all: translate('loras.controls.modelType.all', {}, 'All Types'),
multiple: (count) => translate('loras.controls.modelType.multiple', { count }, `${count} types`),
};
}
/**
* Initialize the model type dropdown for the current page.
* Idempotent: safe to call more than once.
* @param {string} pageType - 'loras' | 'checkpoints' | 'other'
*/
export async function initModelTypeDropdown(pageType) {
const group = document.getElementById(GROUP_ID);
if (!group || group.dataset.modelTypeReady === '1') return;
group.dataset.modelTypeReady = '1';
const trigger = group.querySelector('.model-type-trigger');
const menu = group.querySelector('.model-type-dropdown-menu');
const label = group.querySelector('.model-type-trigger__label');
if (!trigger || !menu || !label) return;
let types = [];
try {
const response = await fetch(`/api/lm/${pageType}/model-types?limit=20`);
if (!response.ok) throw new Error('Failed to fetch model types');
const data = await response.json();
if (!data.success || !Array.isArray(data.model_types)) throw new Error('Invalid response format');
// Same normalization as FilterManager.createModelTypeTags: lowercase,
// and only sub-types we have a display name for.
types = data.model_types
.map((entry) => {
if (!entry || !entry.type) return null;
const typeKey = entry.type.toString().trim().toLowerCase();
if (!typeKey || !MODEL_TYPE_DISPLAY_NAMES[typeKey]) return null;
return { type: typeKey, count: Number(entry.count) || 0 };
})
.filter(Boolean);
} catch (error) {
console.error('Error loading model types for dropdown:', error);
return;
}
// Nothing to switch between — keep the control hidden.
if (types.length <= 1) return;
const filterManager = () => window.filterManager;
const getSelectedTypes = () => filterManager()?.filters?.modelTypes ?? [];
const buildMenu = () => {
const labels = getLabelText();
menu.innerHTML = '';
const fragment = document.createDocumentFragment();
const allItem = document.createElement('div');
allItem.className = 'model-type-option';
allItem.setAttribute('role', 'option');
allItem.tabIndex = -1;
allItem.dataset.value = ALL_TYPES_VALUE;
allItem.textContent = labels.all;
fragment.appendChild(allItem);
types.forEach(({ type, count }) => {
const item = document.createElement('div');
item.className = 'model-type-option';
item.setAttribute('role', 'option');
item.tabIndex = -1;
item.dataset.value = type;
item.textContent = `${MODEL_TYPE_DISPLAY_NAMES[type]} (${count})`;
fragment.appendChild(item);
});
menu.appendChild(fragment);
};
const getOptions = () => menu.querySelectorAll('.model-type-option');
const syncFromFilters = () => {
const labels = getLabelText();
const selected = getSelectedTypes();
let labelText;
if (selected.length === 0) {
labelText = labels.all;
} else if (selected.length === 1 && MODEL_TYPE_DISPLAY_NAMES[selected[0]]) {
labelText = MODEL_TYPE_DISPLAY_NAMES[selected[0]];
} else {
labelText = labels.multiple(selected.length);
}
label.textContent = labelText;
const singleValue = selected.length === 1 ? selected[0] : null;
getOptions().forEach((el) => {
const isSelected = selected.length === 0
? el.dataset.value === ALL_TYPES_VALUE
: el.dataset.value === singleValue;
el.classList.toggle('is-selected', isSelected);
el.setAttribute('aria-selected', isSelected ? 'true' : 'false');
});
};
const choose = async (value) => {
const fm = filterManager();
if (!fm) return;
fm.filters.modelTypes = value === ALL_TYPES_VALUE ? [] : [value];
fm.updateModelTypeSelections();
fm.updateActiveFiltersCount();
syncFromFilters();
await fm.applyFilters(false);
};
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');
requestAnimationFrame(() => {
const selected = menu.querySelector('.model-type-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 (mirrors SortDropdown.js) ----
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('.model-type-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;
};
menu.addEventListener('click', (event) => {
const item = event.target.closest('.model-type-option');
if (!item) return;
event.stopPropagation();
choose(item.dataset.value);
close();
trigger.focus();
});
trigger.addEventListener('click', (event) => {
event.stopPropagation();
toggle();
});
trigger.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
close();
} else if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') {
event.preventDefault();
toggle();
} else if (!group.classList.contains('active')) {
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();
const focused = menu.querySelector('.model-type-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)) {
close();
}
});
buildMenu();
syncFromFilters();
group.classList.remove('hidden');
// Let FilterManager push external changes (panel chips, presets, clear)
// back into the trigger label and menu selection.
window.modelTypeDropdown = { syncFromFilters };
}
@@ -7,6 +7,7 @@ import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
import { initModelTypeDropdown } from './ModelTypeDropdown.js';
import { renderScanScopeMenu, resolveScanScopeTarget } from './ScanScopeMenu.js';
/**
@@ -139,6 +140,10 @@ export class PageControls {
await this.resetAndReload();
});
}
// Model type quick-switch dropdown (loras / checkpoints / other pages;
// hides itself when the page has a single sub-type)
initModelTypeDropdown(this.pageType);
// Refresh button handler
const refreshBtn = document.querySelector('[data-action="refresh"]');
@@ -992,6 +997,7 @@ export class PageControls {
'[data-action="find-duplicates"]',
'#favoriteFilterBtn',
'.update-filter-group',
'#modelTypeDropdown',
];
const customFilterIndicator = document.getElementById('customFilterIndicator');
+4
View File
@@ -631,6 +631,8 @@ export class FilterManager {
}
this.updateActiveFiltersCount();
// Keep the controls-bar quick switcher in sync (no-op when absent)
window.modelTypeDropdown?.syncFromFilters?.();
await this.applyFilters(false);
});
@@ -781,6 +783,8 @@ export class FilterManager {
tag.classList.remove('active');
}
});
// Keep the controls-bar quick switcher in sync (no-op when absent)
window.modelTypeDropdown?.syncFromFilters?.();
}
updateActiveFiltersCount() {
+9
View File
@@ -64,6 +64,15 @@
</optgroup>
</select>
</div>
{% if page_id in ['loras', 'checkpoints', 'other'] %}
<div class="control-group model-type-dropdown-group dropdown-group hidden" id="modelTypeDropdown" title="{{ t('loras.controls.modelType.title') }}">
<button type="button" class="model-type-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="model-type-trigger__label"></span>
<i class="fas fa-caret-down model-type-trigger__caret" aria-hidden="true"></i>
</button>
<div class="dropdown-menu model-type-dropdown-menu" role="listbox"></div>
</div>
{% endif %}
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span><span>{{ t('common.actions.refresh') }}</span> <kbd class="shortcut-key">R</kbd></span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
@@ -0,0 +1,219 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { initModelTypeDropdown } from '../../../static/js/components/controls/ModelTypeDropdown.js';
import { readTemplate } from '../utils/domFixtures.js';
function renderDropdownDom() {
document.body.innerHTML = `
<div class="control-group model-type-dropdown-group dropdown-group hidden" id="modelTypeDropdown">
<button type="button" class="model-type-trigger" aria-haspopup="listbox" aria-expanded="false">
<span class="model-type-trigger__label"></span>
<i class="fas fa-caret-down model-type-trigger__caret" aria-hidden="true"></i>
</button>
<div class="dropdown-menu model-type-dropdown-menu" role="listbox"></div>
</div>
`;
}
function createFilterManagerStub(modelTypes = []) {
return {
filters: { modelTypes: [...modelTypes] },
updateModelTypeSelections: vi.fn(),
updateActiveFiltersCount: vi.fn(),
applyFilters: vi.fn(async () => {}),
};
}
function mockModelTypesResponse(modelTypes) {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, model_types: modelTypes }),
});
}
const OTHER_TYPES = [
{ type: 'vae', count: 12 },
{ type: 'upscaler', count: 5 },
{ type: 'text_encoder', count: 30 },
];
beforeEach(() => {
vi.clearAllMocks();
renderDropdownDom();
window.filterManager = createFilterManagerStub();
mockModelTypesResponse(OTHER_TYPES);
});
afterEach(() => {
delete window.filterManager;
delete window.modelTypeDropdown;
delete global.fetch;
});
describe('ModelTypeDropdown', () => {
it('template carries dropdown-group so the menu can become visible', () => {
// Regression: the menu only displays via `.dropdown-group.active .dropdown-menu`,
// so the group element in controls.html must carry the `dropdown-group` class.
const template = readTemplate('components/controls.html');
const groupLine = template.split('\n').find((line) => line.includes('id="modelTypeDropdown"'));
expect(groupLine).toBeTruthy();
expect(groupLine).toContain('model-type-dropdown-group');
expect(groupLine).toContain('dropdown-group');
});
it('stays hidden when the page has a single model type', async () => {
mockModelTypesResponse([{ type: 'embedding', count: 40 }]);
await initModelTypeDropdown('embeddings');
const group = document.getElementById('modelTypeDropdown');
expect(group.classList.contains('hidden')).toBe(true);
expect(window.modelTypeDropdown).toBeUndefined();
});
it('stays hidden when no element with the dropdown id exists', async () => {
document.body.innerHTML = '';
await initModelTypeDropdown('other');
expect(window.modelTypeDropdown).toBeUndefined();
});
it('renders All Types plus one item per type with counts', async () => {
await initModelTypeDropdown('other');
const group = document.getElementById('modelTypeDropdown');
expect(group.classList.contains('hidden')).toBe(false);
const options = group.querySelectorAll('.model-type-option');
expect(options).toHaveLength(4);
expect(options[0].dataset.value).toBe('');
expect(options[0].textContent).toBe('All Types');
expect(options[1].textContent).toBe('VAE (12)');
expect(options[2].textContent).toBe('Upscaler (5)');
expect(options[3].textContent).toBe('Text Encoder (30)');
});
it('ignores types without a known display name', async () => {
mockModelTypesResponse([
{ type: 'vae', count: 12 },
{ type: 'mystery_type', count: 3 },
{ type: 'upscaler', count: 5 },
]);
await initModelTypeDropdown('other');
const options = document.querySelectorAll('.model-type-option');
expect(options).toHaveLength(3);
expect([...options].map((o) => o.dataset.value)).toEqual(['', 'vae', 'upscaler']);
});
it('initializes the trigger label from the restored filters', async () => {
window.filterManager = createFilterManagerStub(['text_encoder']);
await initModelTypeDropdown('other');
const label = document.querySelector('.model-type-trigger__label');
expect(label.textContent).toBe('Text Encoder');
});
it('shows a count label when multiple types are selected in the filter panel', async () => {
window.filterManager = createFilterManagerStub(['vae', 'upscaler']);
await initModelTypeDropdown('other');
const label = document.querySelector('.model-type-trigger__label');
expect(label.textContent).toBe('2 types');
});
it('selecting a type sets a single-entry modelTypes filter and applies it', async () => {
await initModelTypeDropdown('other');
const item = document.querySelector('.model-type-option[data-value="vae"]');
item.click();
await vi.waitFor(() => {
expect(window.filterManager.applyFilters).toHaveBeenCalledWith(false);
});
expect(window.filterManager.filters.modelTypes).toEqual(['vae']);
expect(window.filterManager.updateModelTypeSelections).toHaveBeenCalled();
expect(window.filterManager.updateActiveFiltersCount).toHaveBeenCalled();
expect(document.querySelector('.model-type-trigger__label').textContent).toBe('VAE');
});
it('selecting All Types clears the modelTypes filter', async () => {
window.filterManager = createFilterManagerStub(['vae']);
await initModelTypeDropdown('other');
document.querySelector('.model-type-option[data-value=""]').click();
await vi.waitFor(() => {
expect(window.filterManager.applyFilters).toHaveBeenCalled();
});
expect(window.filterManager.filters.modelTypes).toEqual([]);
expect(document.querySelector('.model-type-trigger__label').textContent).toBe('All Types');
});
it('syncFromFilters reflects external filter changes (filter panel multi-select)', async () => {
await initModelTypeDropdown('other');
window.filterManager.filters.modelTypes = ['vae', 'upscaler'];
window.modelTypeDropdown.syncFromFilters();
expect(document.querySelector('.model-type-trigger__label').textContent).toBe('2 types');
window.filterManager.filters.modelTypes = ['upscaler'];
window.modelTypeDropdown.syncFromFilters();
expect(document.querySelector('.model-type-trigger__label').textContent).toBe('Upscaler');
const selected = document.querySelector('.model-type-option.is-selected');
expect(selected.dataset.value).toBe('upscaler');
});
it('toggles the menu on trigger click and closes on outside click', async () => {
await initModelTypeDropdown('other');
const group = document.getElementById('modelTypeDropdown');
const trigger = group.querySelector('.model-type-trigger');
trigger.click();
expect(group.classList.contains('active')).toBe(true);
expect(trigger.getAttribute('aria-expanded')).toBe('true');
document.body.click();
expect(group.classList.contains('active')).toBe(false);
expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
it('selects the focused option with Enter and closes the menu', async () => {
await initModelTypeDropdown('other');
const group = document.getElementById('modelTypeDropdown');
const trigger = group.querySelector('.model-type-trigger');
const menu = group.querySelector('.model-type-dropdown-menu');
trigger.click();
const item = menu.querySelector('.model-type-option[data-value="upscaler"]');
item.focus();
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await vi.waitFor(() => {
expect(window.filterManager.applyFilters).toHaveBeenCalled();
});
expect(window.filterManager.filters.modelTypes).toEqual(['upscaler']);
expect(group.classList.contains('active')).toBe(false);
});
it('Escape closes the menu without changing the filter', async () => {
await initModelTypeDropdown('other');
const group = document.getElementById('modelTypeDropdown');
const menu = group.querySelector('.model-type-dropdown-menu');
group.querySelector('.model-type-trigger').click();
menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(group.classList.contains('active')).toBe(false);
expect(window.filterManager.filters.modelTypes).toEqual([]);
expect(window.filterManager.applyFilters).not.toHaveBeenCalled();
});
});