Skip to content

Commit 8c02392

Browse files
Merge issue-126-header-focus: use keyboard-only design focus indicators (#126)
2 parents 1aefd22 + 11dcf3d commit 8c02392

3 files changed

Lines changed: 156 additions & 8 deletions

File tree

core/static/core/accessibility.css

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,3 @@
1-
:root {
2-
--a11y-focus: #9b4d00;
3-
--a11y-focus-contrast: #ffffff;
4-
}
5-
61
html {
72
scroll-padding-block: 1rem;
83
}
@@ -27,8 +22,8 @@ body {
2722
transform: translateY(0);
2823
}
2924

30-
html body :is(a, button, input, select, textarea, summary, [tabindex]):focus {
31-
outline: 3px solid var(--a11y-focus) !important;
25+
html body :is(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
26+
outline: 3px solid var(--link-color, #315f8f) !important;
3227
outline-offset: 3px !important;
3328
scroll-margin-block: 1rem;
3429
}
@@ -238,7 +233,7 @@ body.login button[type="submit"]:hover {
238233
}
239234

240235
@media (forced-colors: active) {
241-
html body :is(a, button, input, select, textarea, summary, [tabindex]):focus {
236+
html body :is(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
242237
outline-color: Highlight !important;
243238
}
244239

core/tests/test_accessibility.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,36 @@
2323
from course_management.datamailer_templates.definitions.registry import TEMPLATES
2424

2525

26+
class FocusStyleContractTests(SimpleTestCase):
27+
def test_pointer_focus_is_unstyled_while_keyboard_focus_uses_design_token(self) -> None:
28+
stylesheet = (Path(settings.BASE_DIR) / "core/static/core/accessibility.css").read_text(
29+
encoding="utf-8"
30+
)
31+
32+
interactive_selector = (
33+
"html body :is(a, button, input, select, textarea, summary, [tabindex]):focus-visible"
34+
)
35+
self.assertEqual(stylesheet.count(f"{interactive_selector} {{"), 2)
36+
self.assertIn(
37+
"outline: 3px solid var(--link-color, #315f8f) !important;",
38+
stylesheet,
39+
)
40+
self.assertNotIn(
41+
"html body :is(a, button, input, select, textarea, summary, [tabindex]):focus {",
42+
stylesheet,
43+
)
44+
self.assertNotIn("--a11y-focus", stylesheet)
45+
46+
def test_skip_link_and_programmatic_main_focus_exceptions_remain_scoped(self) -> None:
47+
stylesheet = (Path(settings.BASE_DIR) / "core/static/core/accessibility.css").read_text(
48+
encoding="utf-8"
49+
)
50+
51+
self.assertIn(".skip-link:focus {", stylesheet)
52+
self.assertIn("#main-content:focus {", stylesheet)
53+
self.assertIn("outline: 0 !important;", stylesheet)
54+
55+
2656
class AccessibilityRegistryTests(SimpleTestCase):
2757
def test_registry_identifiers_and_rendered_surfaces_are_fail_closed(self) -> None:
2858
identifiers = [state.identifier for state in CRITICAL_STATES]
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
from pathlib import Path
2+
3+
import pytest
4+
from playwright.sync_api import Page, expect
5+
6+
SCREENSHOTS = Path(".tmp/screenshots/issue-126")
7+
VIEWPORTS = (
8+
({"width": 1440, "height": 900}, "desktop"),
9+
({"width": 390, "height": 844}, "mobile"),
10+
)
11+
12+
13+
def focus_style(locator) -> dict[str, str | bool]:
14+
return locator.evaluate(
15+
"""
16+
element => {
17+
const style = getComputedStyle(element);
18+
return {
19+
focused: element === document.activeElement,
20+
focusVisible: element.matches(':focus-visible'),
21+
outlineColor: style.outlineColor,
22+
outlineStyle: style.outlineStyle,
23+
outlineWidth: style.outlineWidth,
24+
linkColor: style.color,
25+
};
26+
}
27+
"""
28+
)
29+
30+
31+
@pytest.mark.core
32+
@pytest.mark.parametrize(("viewport", "suffix"), VIEWPORTS)
33+
@pytest.mark.parametrize("dark_mode", (False, True), ids=("light", "dark"))
34+
def test_header_pointer_and_keyboard_focus_are_visually_distinct(
35+
page: Page,
36+
live_server,
37+
viewport: dict[str, int],
38+
suffix: str,
39+
dark_mode: bool,
40+
) -> None:
41+
page.set_viewport_size(viewport)
42+
response = page.goto(live_server.url)
43+
assert response is not None and response.status == 200
44+
if dark_mode:
45+
page.locator("body").evaluate("element => element.classList.add('dark', 'dark-mode')")
46+
47+
if suffix == "mobile":
48+
pointer_target = page.get_by_role("button", name="Explore")
49+
pointer_target.click()
50+
expect(pointer_target).to_have_attribute("aria-expanded", "true")
51+
keyboard_target = page.locator("#site-navigation-links").get_by_role(
52+
"link",
53+
name="Events",
54+
exact=True,
55+
)
56+
else:
57+
pointer_target = page.locator("#site-navigation-links").get_by_role(
58+
"link",
59+
name="Events",
60+
exact=True,
61+
)
62+
pointer_target.evaluate(
63+
"element => element.addEventListener('click', event => "
64+
"event.preventDefault(), {once: true})"
65+
)
66+
pointer_target.click()
67+
keyboard_target = page.locator("#site-navigation-links").get_by_role(
68+
"link",
69+
name="Courses",
70+
exact=True,
71+
)
72+
73+
expect(pointer_target).to_have_css("outline-width", "0px")
74+
pointer_style = focus_style(pointer_target)
75+
assert pointer_style["focused"] is True
76+
assert pointer_style["focusVisible"] is False
77+
assert pointer_style["outlineStyle"] == "none"
78+
assert pointer_style["outlineWidth"] == "0px"
79+
SCREENSHOTS.mkdir(parents=True, exist_ok=True)
80+
page.screenshot(
81+
path=SCREENSHOTS / f"header-pointer-focus-{'dark' if dark_mode else 'light'}-{suffix}.png"
82+
)
83+
84+
page.keyboard.press("Tab")
85+
expect(keyboard_target).to_be_focused()
86+
expect(keyboard_target).to_have_css("outline-width", "3px")
87+
keyboard_style = focus_style(keyboard_target)
88+
assert keyboard_style["focusVisible"] is True
89+
assert keyboard_style["outlineStyle"] == "solid"
90+
assert keyboard_style["outlineWidth"] == "3px"
91+
assert keyboard_style["outlineColor"] == keyboard_style["linkColor"]
92+
assert keyboard_style["outlineColor"] != "rgb(155, 77, 0)"
93+
94+
assert page.evaluate(
95+
"document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1"
96+
)
97+
page.screenshot(
98+
path=SCREENSHOTS / f"header-keyboard-focus-{'dark' if dark_mode else 'light'}-{suffix}.png"
99+
)
100+
101+
102+
@pytest.mark.core
103+
def test_skip_link_remains_keyboard_visible_without_outlining_main_content(
104+
page: Page,
105+
live_server,
106+
) -> None:
107+
page.set_viewport_size({"width": 390, "height": 844})
108+
response = page.goto(live_server.url)
109+
assert response is not None and response.status == 200
110+
111+
skip_link = page.locator(".skip-link")
112+
page.keyboard.press("Tab")
113+
expect(skip_link).to_be_focused()
114+
skip_style = focus_style(skip_link)
115+
assert skip_style["focusVisible"] is True
116+
assert skip_style["outlineStyle"] == "solid"
117+
118+
page.keyboard.press("Enter")
119+
main = page.locator("#main-content")
120+
expect(main).to_be_focused()
121+
main_style = focus_style(main)
122+
assert main_style["outlineStyle"] == "none"
123+
assert main_style["outlineWidth"] == "0px"

0 commit comments

Comments
 (0)