Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 77 additions & 3 deletions frontend/src/utils/dropdown.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -28,3 +30,75 @@ describe('rankByPrefixThenContains', () => {
expect(rankByPrefixThenContains(items, 'ZZZ')).toEqual([]);
});
});

describe('Dropdown keyboard navigation', () => {
let dropdown: Dropdown<string>;
let onSelect: ReturnType<typeof vi.fn<(value: string, index: number) => void>>;

beforeEach(() => {
document.body.innerHTML = '';
onSelect = vi.fn<(value: string, index: number) => void>();
dropdown = new Dropdown<string>({
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);
});
});
5 changes: 4 additions & 1 deletion frontend/src/utils/dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ export class Dropdown<T> {
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;
}
Expand Down
Loading