diff --git a/frontend/src/utils/dropdown.test.ts b/frontend/src/utils/dropdown.test.ts index 94b5020..7bb7d96 100644 --- a/frontend/src/utils/dropdown.test.ts +++ b/frontend/src/utils/dropdown.test.ts @@ -1,9 +1,11 @@ +// @vitest-environment happy-dom /** * Tests for the pure ranking helper shared by the console autocompletes and - * the Create Aircraft modal's type dropdown. + * the Create Aircraft modal's type dropdown, plus the keyboard navigation of + * the shared Dropdown widget itself. */ -import { describe, it, expect } from 'vitest'; -import { rankByPrefixThenContains } from './dropdown'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { Dropdown, rankByPrefixThenContains } from './dropdown'; describe('rankByPrefixThenContains', () => { const items = ['A320', 'A321', 'B738', 'E75L', 'GLF6'] as const; @@ -28,3 +30,75 @@ describe('rankByPrefixThenContains', () => { expect(rankByPrefixThenContains(items, 'ZZZ')).toEqual([]); }); }); + +describe('Dropdown keyboard navigation', () => { + let dropdown: Dropdown; + let onSelect: ReturnType void>>; + + beforeEach(() => { + document.body.innerHTML = ''; + onSelect = vi.fn<(value: string, index: number) => void>(); + dropdown = new Dropdown({ + container: document.body, + rootClass: 'test-dropdown', + itemClass: 'test-item', + renderItem: value => value, + onSelect, + }); + dropdown.setItems(['A320', 'A321', 'B738']); + }); + + const selectedText = (): string | null => + document.querySelector('.test-item.selected')?.textContent ?? null; + + it('starts with nothing selected', () => { + expect(dropdown.getSelectedIndex()).toBe(-1); + expect(selectedText()).toBeNull(); + }); + + it('ArrowUp with no selection selects the top item', () => { + expect(dropdown.handleKey('ArrowUp')).toBe(true); + expect(dropdown.getSelectedIndex()).toBe(0); + expect(selectedText()).toBe('A320'); + }); + + it('ArrowUp on the top item keeps it selected', () => { + dropdown.handleKey('ArrowDown'); + expect(dropdown.getSelectedIndex()).toBe(0); + dropdown.handleKey('ArrowUp'); + dropdown.handleKey('ArrowUp'); + expect(dropdown.getSelectedIndex()).toBe(0); + expect(selectedText()).toBe('A320'); + }); + + it('ArrowUp moves the selection up from a lower item', () => { + dropdown.handleKey('ArrowDown'); + dropdown.handleKey('ArrowDown'); + expect(dropdown.getSelectedIndex()).toBe(1); + dropdown.handleKey('ArrowUp'); + expect(dropdown.getSelectedIndex()).toBe(0); + }); + + it('ArrowDown walks down and stops at the last item', () => { + dropdown.handleKey('ArrowDown'); + dropdown.handleKey('ArrowDown'); + dropdown.handleKey('ArrowDown'); + dropdown.handleKey('ArrowDown'); + expect(dropdown.getSelectedIndex()).toBe(2); + expect(selectedText()).toBe('B738'); + }); + + it('Enter selects the highlighted item and does nothing without a selection', () => { + expect(dropdown.handleKey('Enter')).toBe(false); + expect(onSelect).not.toHaveBeenCalled(); + dropdown.handleKey('ArrowUp'); + expect(dropdown.handleKey('Enter')).toBe(true); + expect(onSelect).toHaveBeenCalledWith('A320', 0); + }); + + it('ignores keys when hidden', () => { + dropdown.hide(); + expect(dropdown.handleKey('ArrowUp')).toBe(false); + expect(dropdown.getSelectedIndex()).toBe(-1); + }); +}); diff --git a/frontend/src/utils/dropdown.ts b/frontend/src/utils/dropdown.ts index df8fcf1..8cead41 100644 --- a/frontend/src/utils/dropdown.ts +++ b/frontend/src/utils/dropdown.ts @@ -91,7 +91,10 @@ export class Dropdown { return true; } if (key === 'ArrowUp') { - this.selectedIndex = Math.max(this.selectedIndex - 1, -1); + // With no selection yet, Up picks the top item (mirroring Down); + // once on the top item, repeated Up keeps it selected rather than + // dropping back to "nothing selected". + this.selectedIndex = this.selectedIndex < 0 ? 0 : Math.max(this.selectedIndex - 1, 0); this.render(); return true; }