From 402c244e2b039515d30d0b081db8c0790fc7e9c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Krela?= Date: Tue, 31 Mar 2026 14:27:27 +0200 Subject: [PATCH] Enhance keyboard navigation and focus management in DiffFileExtensionFilter --- .../js/components/DiffFileExtensionFilter.vue | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/web_src/js/components/DiffFileExtensionFilter.vue b/web_src/js/components/DiffFileExtensionFilter.vue index 8b08a0a41f..ee4b09ef47 100644 --- a/web_src/js/components/DiffFileExtensionFilter.vue +++ b/web_src/js/components/DiffFileExtensionFilter.vue @@ -28,9 +28,11 @@ export default defineComponent({ }, mounted() { document.body.addEventListener('click', this.onBodyClick, true); + this.$el.addEventListener('keydown', this.onKeyDown); }, unmounted() { document.body.removeEventListener('click', this.onBodyClick, true); + this.$el.removeEventListener('keydown', this.onKeyDown); }, methods: { onBodyClick(event: MouseEvent) { @@ -40,6 +42,49 @@ export default defineComponent({ this.toggleMenu(); } }, + onKeyDown(event: KeyboardEvent) { + if (!this.menuVisible) return; + const currentFocused = document.activeElement as HTMLElement; + if (!this.$el.contains(currentFocused)) return; + + // Get all focusable menu items (checkboxes and buttons) + const menu = this.$el.querySelector('.menu') as HTMLElement; + const focusableItems = Array.from(menu.querySelectorAll('[role="menuitem"]')) as HTMLElement[]; + + if (!focusableItems.length) return; + + const currentIndex = focusableItems.indexOf(currentFocused.closest('[role="menuitem"]') as HTMLElement); + + switch (event.key) { + case 'ArrowDown': // select next element + event.preventDefault(); + const nextIndex = currentIndex === -1 ? 0 : Math.min(currentIndex + 1, focusableItems.length - 1); + this.focusElem(focusableItems[nextIndex], currentIndex >= 0 ? focusableItems[currentIndex] : null); + break; + case 'ArrowUp': // select previous element + event.preventDefault(); + const prevIndex = currentIndex === -1 ? focusableItems.length - 1 : Math.max(currentIndex - 1, 0); + this.focusElem(focusableItems[prevIndex], currentIndex >= 0 ? focusableItems[currentIndex] : null); + break; + case 'Escape': // close menu + event.preventDefault(); + if (currentIndex >= 0) { + focusableItems[currentIndex].tabIndex = -1; + } + this.toggleMenu(); + break; + } + }, + /** Focus given element */ + focusElem(elem: HTMLElement | null, prevElem: HTMLElement | null) { + if (elem) { + elem.tabIndex = 0; + if (prevElem) prevElem.tabIndex = -1; + // Focus the input/button inside the menuitem if it exists, otherwise focus the item itself + const focusTarget = elem.querySelector('input, button') as HTMLElement || elem; + focusTarget.focus(); + } + }, /** * Extract file extension from filename * Returns the extension with dot (e.g., ".ts", ".go") @@ -101,6 +146,11 @@ export default defineComponent({ this.menuVisible = !this.menuVisible; if (this.menuVisible) { this.scanExtensions(); + } else { + // when closing menu, restore focus to the button + const button = this.$refs.expandBtn as HTMLElement; + button.tabIndex = 0; + button.focus(); } }, /** @@ -162,7 +212,6 @@ export default defineComponent({ :aria-controls="uniqueIdMenu" > -