From 69ac32fa4ac7c6675a60ba3a1be9dbc4a4b2352c Mon Sep 17 00:00:00 2001 From: Andres Morfin Veytia <78442543+amorfinv@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:18:45 +0200 Subject: [PATCH] fix(frontend): keep top dropdown item selected on ArrowUp With no selection, ArrowUp now selects the top item (mirroring ArrowDown), and repeated ArrowUp on the top item keeps it selected instead of dropping back to "nothing selected". Adds keyboard-navigation tests for Dropdown. Co-Authored-By: Claude Opus 5.5 --- frontend/src/utils/dropdown.test.ts | 80 +++++++++++++++++++++++++++-- frontend/src/utils/dropdown.ts | 5 +- 2 files changed, 81 insertions(+), 4 deletions(-) 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; }