mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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 <otto@codeberg.org> Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: 0ko <0ko@noreply.codeberg.org> Co-committed-by: 0ko <0ko@noreply.codeberg.org>
This commit is contained in:
parent
53fcf182c5
commit
5c9a909c03
2 changed files with 142 additions and 19 deletions
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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<HTMLDetailsElement>('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<HTMLDetailsElement>('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<HTMLElement>('.content > ul > li > :is(a, button)').focus();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dropdown = document.querySelector<HTMLDetailsElement>('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 <li> that contains it
|
||||
const dropdownItems = dropdown.querySelectorAll<HTMLLIElement>('.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<HTMLElement>(':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<HTMLElement>(':is(a, button)').focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue