From 5c9a909c03a20ff486229e1deafc0f90567d5743 Mon Sep 17 00:00:00 2001 From: 0ko <0ko@noreply.codeberg.org> Date: Mon, 10 Nov 2025 13:35:38 +0100 Subject: [PATCH] feat(ui): arrow key navigation in dropdown (#10033) Addresses https://codeberg.org/forgejo/forgejo/pulls/7906#issuecomment-5455662, https://codeberg.org/forgejo/forgejo/pulls/10025#issuecomment-8182184 Add arrow navigation to the JS-less/JS-enchanced dropdown component. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10033 Reviewed-by: Otto Reviewed-by: Gusted Co-authored-by: 0ko <0ko@noreply.codeberg.org> Co-committed-by: 0ko <0ko@noreply.codeberg.org> --- tests/e2e/dropdown.test.e2e.ts | 54 +++++++++++++++-- web_src/js/modules/dropdown.ts | 107 ++++++++++++++++++++++++++++----- 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/tests/e2e/dropdown.test.e2e.ts b/tests/e2e/dropdown.test.e2e.ts index 75e44ae1d8..1cca75e75d 100644 --- a/tests/e2e/dropdown.test.e2e.ts +++ b/tests/e2e/dropdown.test.e2e.ts @@ -19,6 +19,7 @@ test('JS enhanced interaction', async ({page}) => { await expect(nojsNotice).toBeHidden(); // Open and close by clicking summary + const dropdown = page.locator('details.dropdown'); const dropdownSummary = page.locator('details.dropdown > summary'); const dropdownContent = page.locator('details.dropdown > .content'); await expect(dropdownContent).toBeHidden(); @@ -37,21 +38,64 @@ test('JS enhanced interaction', async ({page}) => { // Open and close with keypressing await dropdownSummary.focus(); + // Open with Enter, close with Space await dropdownSummary.press(`Enter`); await expect(dropdownContent).toBeVisible(); await dropdownSummary.press(`Space`); await expect(dropdownContent).toBeHidden(); - + // Open with Space, close with Enter await dropdownSummary.press(`Space`); await expect(dropdownContent).toBeVisible(); await dropdownSummary.press(`Enter`); await expect(dropdownContent).toBeHidden(); - + // Open with Enter, close with Enter await dropdownSummary.press(`Enter`); await expect(dropdownContent).toBeVisible(); await dropdownSummary.press(`Escape`); await expect(dropdownContent).toBeHidden(); + // Open and navigate with ArrowDown, close with Tab + await dropdownSummary.focus(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".rss"]`)).toBeFocused(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".atom"]`)).toBeFocused(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".keys"]`)).toBeFocused(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".gpg"]`)).toBeFocused(); + // ArrowDown won't move us farther than the last dropdown item + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".gpg"]`)).toBeFocused(); + // Pressing Tab on last item will move us away from the dropdown and close the dropdown + await dropdown.press(`Tab`); + await expect(dropdownContent).toBeHidden(); + + // Navigate and close with Shift+Tab + await dropdownSummary.focus(); + await dropdown.press(`Enter`); + await expect(dropdownSummary).toBeFocused(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".rss"]`)).toBeFocused(); + await dropdown.press('Shift+Tab'); + await expect(dropdownSummary).toBeFocused(); + await dropdown.press('Shift+Tab'); + await expect(dropdownContent).toBeHidden(); + + // Navigate with ArrowUp + await dropdownSummary.focus(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".rss"]`)).toBeFocused(); + await dropdown.press(`ArrowDown`); + await expect(page.locator(`a[href$=".atom"]`)).toBeFocused(); + await dropdown.press(`ArrowUp`); + await expect(page.locator(`a[href$=".rss"]`)).toBeFocused(); + // Pressing ArrowUp on first item will move us to summary, but no farther from here + await dropdown.press(`ArrowUp`); + await expect(dropdownSummary).toBeFocused(); + await dropdown.press(`Escape`); + await expect(dropdownContent).toBeHidden(); + // Open and then close by opening a different dropdown const languageMenu = page.locator('.language-menu'); await dropdownSummary.click(); @@ -90,17 +134,17 @@ test('No JS interaction', async ({browser}) => { await expect(dropdownContent).toBeHidden(); // Open and close with keypressing + // Open with Enter, close with Space await dropdownSummary.press(`Enter`); await expect(dropdownContent).toBeVisible(); await dropdownSummary.press(`Space`); await expect(dropdownContent).toBeHidden(); - + // Open with Space, close with Enter await dropdownSummary.press(`Space`); await expect(dropdownContent).toBeVisible(); await dropdownSummary.press(`Enter`); await expect(dropdownContent).toBeHidden(); - - // Escape is not usable w/o JS enhancements + // Closing by Escape is not possible w/o JS enhancements await dropdownSummary.press(`Enter`); await expect(dropdownContent).toBeVisible(); await dropdownSummary.press(`Escape`); diff --git a/web_src/js/modules/dropdown.ts b/web_src/js/modules/dropdown.ts index 0731eeb86f..393e7150b1 100644 --- a/web_src/js/modules/dropdown.ts +++ b/web_src/js/modules/dropdown.ts @@ -7,29 +7,108 @@ // click iteration with anything on the page and pressing Escape. export function initDropdowns() { - document.addEventListener('click', (event) => { + // Close open dropdown by clicking elsewhere on the page + document.addEventListener('click', (event: MouseEvent) => { const dropdown = document.querySelector('details.dropdown[open]'); - // No open dropdowns on page, nothing to do. - if (dropdown === null) return; + if (dropdown === null) { + // No open dropdowns on page, nothing to do + return; + } const target = event.target as HTMLElement; - // User clicked something in the open dropdown, don't interfere. - if (dropdown.contains(target)) return; + if (dropdown.contains(target)) { + // User clicked something in the open dropdown, don't interfere + return; + } - // User clicked something that isn't the open dropdown, so close it. + // User clicked something elsewhere, close the open dropdown dropdown.removeAttribute('open'); }); - // Close open dropdowns on Escape press - document.addEventListener('keydown', (event) => { - // This press wasn't escape, nothing to do. - if (event.key !== 'Escape') return; + // Close open dropdown when it is unfocused (e.g. when user pressed Tab or Shift+Tab), + // but not when user lost focus completely (e.g. browser window became unfocused) + document.addEventListener('focusout', (event: FocusEvent) => { + const dropdown = document.querySelector('details.dropdown[open]'); + if (dropdown === null) { + // No open dropdowns on page, nothing to do + return; + } + + const target = event.target as HTMLElement; + const newTarget = event.relatedTarget as HTMLElement; + + if (newTarget !== null && dropdown.contains(target) && !dropdown.contains(newTarget)) { + // The previously focused element was within the open dropdown, but something + // else is now focused, so the dropdown should be closed + dropdown.removeAttribute('open'); + } + }); + + // Keyboard interaction with dropdowns + document.addEventListener('keydown', (event: KeyboardEvent) => { + if (!['Escape', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + // This eventListener is only concerned about a few keys + return; + } + + if (document.activeElement.localName === 'summary' && event.key === 'ArrowDown') { + const parentDropdown = document.activeElement.parentElement as HTMLDetailsElement; + if (parentDropdown.classList.contains('dropdown')) { + // User pressed ArrowDown on a focused summary of a closed dropdown. + // We'll open the dropdown and focus it's first item + parentDropdown.setAttribute('open', 'true'); + parentDropdown.querySelector('.content > ul > li > :is(a, button)').focus(); + event.preventDefault(); + return; + } + } const dropdown = document.querySelector('details.dropdown[open]'); - // No open dropdowns on page, nothing to do. - if (dropdown === null) return; + // This part of the code only knows how to work with open dropdown + if (dropdown === null) { + // No open dropdowns on page, nothing to do + return; + } - // User pressed Escape while having an open dropdown, probably wants it be closed. - dropdown.removeAttribute('open'); + if (event.key === 'Escape') { + // User pressed Escape while having an open dropdown, we'll close it + dropdown.removeAttribute('open'); + return; + } + + // Knowing document.activeElement, find the
  • that contains it + const dropdownItems = dropdown.querySelectorAll('.content > ul > li'); + let activeLi: HTMLLIElement, activeLiIndex: number; + for (let i = 0; i < dropdownItems.length; i++) { + const li = dropdownItems[i] as HTMLLIElement; + if (!li.contains(document.activeElement)) continue; + activeLi = li; + activeLiIndex = i; + break; + } + if (activeLi === undefined) { + // The focused element is not a list item or it's contents, but something else in the dropdown + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (activeLiIndex === 0) { + // Last child is already selected, but we can navigate back to the opener and close the dropdown + dropdown.querySelector('summary').focus(); + dropdown.removeAttribute('open'); + return; + } + dropdownItems[activeLiIndex - 1].querySelector(':is(a, button)').focus(); + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (activeLiIndex === dropdownItems.length - 1) { + // First child is already selected + return; + } + dropdownItems[activeLiIndex + 1].querySelector(':is(a, button)').focus(); + } }); }