Restyle the panel on ComfyUI palette tokens and drop the built-in themes. Add a header with Back/Forward, Prev/Next, Focus, Lock and a searchable Go to node picker, a subgraph/group breadcrumb, and simplified modes: Follow glass or Float, plus Sticky, Follow selection and Lock to node (replaces Hold). Fix widget edits (step2 steps, INT/FLOAT, commit on Enter/blur, undo), subgraph lookups, orphaned dropdown menus, global arrow-key capture, the pop-out editor, and remove Google Fonts loading and dead code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
215 lines
7.4 KiB
TypeScript
215 lines
7.4 KiB
TypeScript
/**
|
|
* Shared dropdown: open/close lifecycle, filter, hover and keyboard navigation.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { CustomDropdown, createDropdownTrigger } from '../../src/shared/CustomDropdown';
|
|
import { InlineControlFactory } from '../../src/info-panel/widget-editors/InlineControlFactory';
|
|
import { WidgetSyncManager } from '../../src/info-panel/widget-editors/WidgetSyncManager';
|
|
|
|
const press = (key: string) => {
|
|
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true });
|
|
(document.activeElement ?? document.body).dispatchEvent(event);
|
|
return event;
|
|
};
|
|
const menus = () => document.querySelectorAll('.custom-dropdown-menu');
|
|
const items = () => [...document.querySelectorAll<HTMLElement>('.custom-dropdown-item')];
|
|
const visible = () => items().filter(item => !item.hidden).map(item => item.textContent);
|
|
const active = () => document.querySelector('.custom-dropdown-item.active')?.textContent;
|
|
|
|
describe('CustomDropdown', () => {
|
|
let anchor: HTMLElement;
|
|
let onChange: ReturnType<typeof vi.fn>;
|
|
let onClose: ReturnType<typeof vi.fn>;
|
|
let dropdown: CustomDropdown;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
document.body.innerHTML = '';
|
|
anchor = createDropdownTrigger('euler');
|
|
document.body.appendChild(anchor);
|
|
onChange = vi.fn();
|
|
onClose = vi.fn();
|
|
dropdown = new CustomDropdown({
|
|
options: ['euler', 'euler_ancestral', 'dpmpp_2m', 'ddim'],
|
|
currentValue: 'euler',
|
|
anchor,
|
|
onChange,
|
|
onClose
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
dropdown.hide();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('show() does not fire onClose, hide() fires it once', () => {
|
|
dropdown.show();
|
|
dropdown.show();
|
|
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(menus()).toHaveLength(1);
|
|
|
|
dropdown.hide();
|
|
dropdown.hide();
|
|
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(menus()).toHaveLength(0);
|
|
});
|
|
|
|
it('closing before the listeners attach leaves none behind', () => {
|
|
const add = vi.spyOn(window, 'addEventListener');
|
|
dropdown.show();
|
|
dropdown.hide();
|
|
vi.advanceTimersByTime(100);
|
|
|
|
expect(add).not.toHaveBeenCalledWith('keydown', expect.any(Function), true);
|
|
});
|
|
|
|
it('has listbox roles and tracks the active option', () => {
|
|
dropdown.show();
|
|
const filter = document.querySelector('.custom-dropdown-filter') as HTMLInputElement;
|
|
const list = document.querySelector('.custom-dropdown-list')!;
|
|
|
|
expect(document.activeElement).toBe(filter);
|
|
expect(anchor.getAttribute('aria-expanded')).toBe('true');
|
|
expect(list.getAttribute('role')).toBe('listbox');
|
|
expect(filter.getAttribute('aria-controls')).toBe(list.id);
|
|
expect(items().map(item => item.getAttribute('role'))).toEqual(['option', 'option', 'option', 'option']);
|
|
expect(items().map(item => item.getAttribute('aria-selected'))).toEqual(['true', 'false', 'false', 'false']);
|
|
expect(filter.getAttribute('aria-activedescendant')).toBe(items()[0].id);
|
|
|
|
dropdown.hide();
|
|
expect(anchor.getAttribute('aria-expanded')).toBe('false');
|
|
});
|
|
|
|
it('hover moves the item that Enter picks', () => {
|
|
dropdown.show();
|
|
vi.advanceTimersByTime(100);
|
|
|
|
items()[2].dispatchEvent(new MouseEvent('mouseenter'));
|
|
expect(active()).toBe('dpmpp_2m');
|
|
press('ArrowDown');
|
|
expect(active()).toBe('ddim');
|
|
press('ArrowUp');
|
|
press('ArrowUp');
|
|
expect(active()).toBe('euler_ancestral');
|
|
|
|
expect(press('Enter').defaultPrevented).toBe(true);
|
|
expect(onChange).toHaveBeenCalledWith('euler_ancestral');
|
|
expect(menus()).toHaveLength(0);
|
|
expect(document.activeElement).toBe(anchor);
|
|
});
|
|
|
|
it('typing filters the options and Enter picks the first match', () => {
|
|
dropdown.show();
|
|
vi.advanceTimersByTime(100);
|
|
const filter = document.activeElement as HTMLInputElement;
|
|
|
|
filter.value = 'EUL';
|
|
filter.dispatchEvent(new Event('input'));
|
|
expect(visible()).toEqual(['euler', 'euler_ancestral']);
|
|
press('ArrowDown');
|
|
press('ArrowDown');
|
|
expect(active()).toBe('euler_ancestral');
|
|
|
|
filter.value = 'dd';
|
|
filter.dispatchEvent(new Event('input'));
|
|
expect(visible()).toEqual(['ddim']);
|
|
expect(active()).toBe('ddim');
|
|
|
|
press('Enter');
|
|
expect(onChange).toHaveBeenCalledWith('ddim');
|
|
});
|
|
|
|
it('Enter with no match keeps the menu open', () => {
|
|
dropdown.show();
|
|
vi.advanceTimersByTime(100);
|
|
const filter = document.activeElement as HTMLInputElement;
|
|
|
|
filter.value = 'zzz';
|
|
filter.dispatchEvent(new Event('input'));
|
|
press('Enter');
|
|
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
expect(menus()).toHaveLength(1);
|
|
expect(filter.hasAttribute('aria-activedescendant')).toBe(false);
|
|
});
|
|
|
|
it('Tab closes without a change and leaves focus on the trigger to move on from', () => {
|
|
dropdown.show();
|
|
vi.advanceTimersByTime(60);
|
|
press('ArrowDown');
|
|
|
|
const tab = press('Tab');
|
|
|
|
expect(tab.defaultPrevented).toBe(false);
|
|
expect(menus()).toHaveLength(0);
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
expect(document.activeElement).toBe(anchor);
|
|
expect(press('Enter').defaultPrevented).toBe(false);
|
|
});
|
|
|
|
it('Escape closes without a change', () => {
|
|
dropdown.show();
|
|
vi.advanceTimersByTime(100);
|
|
|
|
press('Escape');
|
|
|
|
expect(onChange).not.toHaveBeenCalled();
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
expect(menus()).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe('inline combo control', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
document.body.innerHTML = '';
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('destroy() closes an open menu, so it cannot write after a re-render', () => {
|
|
const sync = vi.spyOn(WidgetSyncManager, 'syncWidgetValue');
|
|
const control = InlineControlFactory.createControl({
|
|
nodeId: 1, widgetName: 'sampler_name', widgetType: 'combo', currentValue: 'euler',
|
|
constraints: { options: ['euler', 'ddim'] }
|
|
})!;
|
|
document.body.appendChild(control.element);
|
|
const trigger = control.element.querySelector('.inline-dropdown-trigger') as HTMLElement;
|
|
|
|
trigger.click();
|
|
expect(menus()).toHaveLength(1);
|
|
expect(trigger.style.background).toBe('');
|
|
|
|
control.destroy();
|
|
vi.advanceTimersByTime(100);
|
|
|
|
expect(menus()).toHaveLength(0);
|
|
press('Enter');
|
|
expect(sync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('clicking the trigger again closes the menu', () => {
|
|
const control = InlineControlFactory.createControl({
|
|
nodeId: 1, widgetName: 'sampler_name', widgetType: 'combo', currentValue: 'euler',
|
|
constraints: { options: ['euler', 'ddim'] }
|
|
})!;
|
|
document.body.appendChild(control.element);
|
|
const trigger = control.element.querySelector('.inline-dropdown-trigger') as HTMLElement;
|
|
|
|
trigger.click();
|
|
trigger.click();
|
|
expect(menus()).toHaveLength(0);
|
|
trigger.click();
|
|
expect(menus()).toHaveLength(1);
|
|
|
|
control.destroy();
|
|
});
|
|
});
|