mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-07 18:58:30 +02:00
Improve announcements after navigation (#1015)
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { registerBehavior } from "./index";
|
import { registerBehavior } from "./index";
|
||||||
import { isKeyboardActive } from "./focus-utils";
|
import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils";
|
||||||
import { ModalBehavior } from "./modal";
|
import { ModalBehavior } from "./modal";
|
||||||
|
|
||||||
class DetailsModalBehavior extends ModalBehavior {
|
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
|
// Try restore focus to view details to view details link of respective bookmark
|
||||||
const bookmarkId = this.element.dataset.bookmarkId;
|
const bookmarkId = this.element.dataset.bookmarkId;
|
||||||
const restoreFocusElement =
|
setAfterPageLoadFocusTarget(
|
||||||
document.querySelector(
|
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
||||||
`ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
|
);
|
||||||
) ||
|
|
||||||
document.querySelector("ul.bookmark-list") ||
|
|
||||||
document.body;
|
|
||||||
|
|
||||||
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
72
bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py
Normal file
72
bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py
Normal file
@@ -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()")
|
@@ -79,3 +79,12 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
|
|||||||
.locator('select[name="bulk_action"]')
|
.locator('select[name="bulk_action"]')
|
||||||
.select_option(value)
|
.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()
|
||||||
|
Reference in New Issue
Block a user