diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js index cf6c408..06a9b47 100644 --- a/bookmarks/frontend/behaviors/details-modal.js +++ b/bookmarks/frontend/behaviors/details-modal.js @@ -1,5 +1,5 @@ import { registerBehavior } from "./index"; -import { isKeyboardActive } from "./focus-utils"; +import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils"; import { ModalBehavior } from "./modal"; class DetailsModalBehavior extends ModalBehavior { @@ -15,14 +15,9 @@ class DetailsModalBehavior extends ModalBehavior { // Try restore focus to view details to view details link of respective bookmark const bookmarkId = this.element.dataset.bookmarkId; - const restoreFocusElement = - document.querySelector( - `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`, - ) || - document.querySelector("ul.bookmark-list") || - document.body; - - restoreFocusElement.focus({ focusVisible: isKeyboardActive() }); + setAfterPageLoadFocusTarget( + `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`, + ); } } diff --git a/bookmarks/frontend/behaviors/focus-utils.js b/bookmarks/frontend/behaviors/focus-utils.js index ab6053a..3851fa3 100644 --- a/bookmarks/frontend/behaviors/focus-utils.js +++ b/bookmarks/frontend/behaviors/focus-utils.js @@ -57,3 +57,68 @@ export class FocusTrapController { } } } + +let afterPageLoadFocusTarget = []; +let firstPageLoad = true; + +export function setAfterPageLoadFocusTarget(...targets) { + afterPageLoadFocusTarget = targets; +} + +function programmaticFocus(element) { + // Ensure element is focusable + // Hide focus outline if element is not focusable by default - might + // reconsider this later + const isFocusable = element.tabIndex >= 0; + if (!isFocusable) { + // Apparently the default tabIndex is -1, even though an element is still + // not focusable with that. Setting an explicit -1 also sets the attribute + // and the element becomes focusable. + element.tabIndex = -1; + // `focusVisible` is not supported in all browsers, so hide the outline manually + element.style["outline"] = "none"; + } + element.focus({ + focusVisible: isKeyboardActive() && isFocusable, + preventScroll: true, + }); +} + +// Register global listener for navigation and try to focus an element that +// results in a meaningful announcement. +document.addEventListener("turbo:load", () => { + // Ignore initial page load to let the browser handle announcements + if (firstPageLoad) { + firstPageLoad = false; + return; + } + + // Check if there is an explicit focus target for the next page load + for (const target of afterPageLoadFocusTarget) { + const element = document.querySelector(target); + if (element) { + programmaticFocus(element); + return; + } + } + afterPageLoadFocusTarget = []; + + // If there is some autofocus element, let the browser handle it + const autofocus = document.querySelector("[autofocus]"); + if (autofocus) { + return; + } + + // If there is a toast as a result of some action, focus it + const toast = document.querySelector(".toast"); + if (toast) { + programmaticFocus(toast); + return; + } + + // Otherwise go with main + const main = document.querySelector("main"); + if (main) { + programmaticFocus(main); + } +}); diff --git a/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py b/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py new file mode 100644 index 0000000..6d0c851 --- /dev/null +++ b/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py @@ -0,0 +1,72 @@ +from django.urls import reverse +from playwright.sync_api import sync_playwright, expect + +from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase + + +class A11yNavigationFocusTest(LinkdingE2ETestCase): + def test_initial_page_load_focus(self): + with sync_playwright() as p: + # First page load should keep focus on the body + page = self.open(reverse("linkding:bookmarks.index"), p) + focused_tag = page.evaluate("document.activeElement?.tagName") + self.assertEqual("BODY", focused_tag) + + page.goto(self.live_server_url + reverse("linkding:bookmarks.archived")) + focused_tag = page.evaluate("document.activeElement?.tagName") + self.assertEqual("BODY", focused_tag) + + page.goto(self.live_server_url + reverse("linkding:settings.general")) + focused_tag = page.evaluate("document.activeElement?.tagName") + self.assertEqual("BODY", focused_tag) + + # Bookmark form views should focus the URL input + page.goto(self.live_server_url + reverse("linkding:bookmarks.new")) + focused_tag = page.evaluate( + "document.activeElement?.tagName + '|' + document.activeElement?.name" + ) + self.assertEqual("INPUT|url", focused_tag) + + def test_page_navigation_focus(self): + bookmark = self.setup_bookmark() + + with sync_playwright() as p: + page = self.open(reverse("linkding:bookmarks.index"), p) + + # Subsequent navigation should move focus to main content + self.reset_focus() + self.navigate_menu("Bookmarks", "Active") + focused = page.locator("main:focus") + expect(focused).to_be_visible() + + self.reset_focus() + self.navigate_menu("Bookmarks", "Archived") + focused = page.locator("main:focus") + expect(focused).to_be_visible() + + self.reset_focus() + self.navigate_menu("Settings", "General") + focused = page.locator("main:focus") + expect(focused).to_be_visible() + + # Bookmark form views should focus the URL input + self.reset_focus() + self.navigate_menu("Add bookmark") + focused = page.locator("input[name='url']:focus") + expect(focused).to_be_visible() + + # Opening details modal should move focus to close button + self.navigate_menu("Bookmarks", "Active") + self.open_details_modal(bookmark) + focused = page.locator(".modal button.close:focus") + expect(focused).to_be_visible() + + # Closing modal should move focus back to the bookmark item + page.keyboard.press("Escape") + focused = self.locate_bookmark(bookmark.title).locator( + "a.view-action:focus" + ) + expect(focused).to_be_visible() + + def reset_focus(self): + self.page.evaluate("document.activeElement.blur()") diff --git a/bookmarks/tests_e2e/helpers.py b/bookmarks/tests_e2e/helpers.py index c19852e..48ce248 100644 --- a/bookmarks/tests_e2e/helpers.py +++ b/bookmarks/tests_e2e/helpers.py @@ -79,3 +79,12 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin): .locator('select[name="bulk_action"]') .select_option(value) ) + + def navigate_menu(self, main_menu_item: str, sub_menu_item: str | None = None): + if sub_menu_item: + self.page.locator("nav").get_by_role("button", name=main_menu_item).click() + self.page.locator("nav ul.menu").get_by_text( + sub_menu_item, exact=True + ).click() + else: + self.page.locator("nav").get_by_text(main_menu_item, exact=True).click()