refactor(a11y): comprehensive WCAG 2.2 AA accessibility improvements (#924)

* refactor(a11y): comprehensive WCAG 2.2 AA accessibility improvements

Add skip-to-content link, landmark regions, ARIA attributes, keyboard
navigation, focus styles, reduced-motion support, and i18n keys across
all layouts, partials, shortcodes, and JS components.

- Add skip nav link in baseof.html and id="content" on all <main> tags
- Fix 404 page lang/dir attributes and add <main> landmark
- Add aria-label to banner close, PDF iframe, search input/results
- Remove aria-hidden from back-to-top button
- Add aria-hidden to decorative external link icon
- Add role="tablist" to tabs, aria-expanded to filetree/dropdowns
- Wrap mermaid diagrams in role="img", asciinema in role="region"
- Change theme toggle <p> items to <button role="menuitem"> with
  full keyboard navigation (Arrow/Home/End/Escape)
- Add arrow-key keyboard navigation to tabs component
- Separate sidebar collapsible button from link for independent
  keyboard access with aria-expanded sync
- Sync aria-expanded on all dropdown toggles (theme, lang, navbar,
  hamburger, page context menu)
- Add aria-live search status announcements
- Add 13 new i18n keys, replace hardcoded aria-label strings
- Add prefers-reduced-motion CSS override and focus-visible base styles
- Add aria-label swap on code copy ("Copied!" feedback for AT)
- Add aria-current to active TOC links
- Wrap filetree in <ul> container for proper list semantics
- Add unique aria-label to blog "Read more" links
- Document accessibility guidelines in AGENTS.md

* feat(a11y): enhance focus styles and accessibility for various components

- Add focus-visible styles to badges, buttons, and links for improved keyboard navigation.
- Update breadcrumb, sidebar, and TOC components to include focus-visible outlines.
- Introduce new classes for focus states in the badge and tabs shortcodes.
- Ensure consistent focus styles across all interactive elements to meet WCAG 2.2 AA standards.

* feat(a11y): implement new focus-visible utilities and enhance accessibility styles

- Introduce new utility classes for focus-visible states to improve keyboard navigation.
- Update various components including badges, buttons, and search inputs to utilize new focus-visible styles.
- Refactor existing focus styles to ensure consistency and compliance with accessibility standards.
- Enhance breadcrumb, sidebar, and TOC components with updated focus-visible classes for better user experience.

* chore: add .gitattributes to collapse generated files in PR diffs

* fix: enhance accessibility and improve documentation

- Added alt attributes to images in multiple language documentation files for better accessibility.
- Updated the navbar title partial to remove unnecessary title attribute.
- Improved search input accessibility by adding autocomplete="off".
- Enhanced search partials in both navbar and sidebar with location context.
- Updated SVG icons in various components to include aria-hidden and focusable attributes for improved accessibility compliance.

* fix: improve giscus theme toggle functionality

- Updated the theme toggle options selector to use a data attribute for better specificity.
- Modified the event listener to use a setTimeout for the theme update, ensuring smoother transitions when the theme switcher is clicked.

* fix: resolve axe-core WCAG AA violations across docs pages

Add aria-labels to Hugo task list checkboxes, fix asciinema player
timer accessible names, make Jupyter output cells keyboard-focusable,
and add missing heading hierarchy in shortcodes docs for fa/ja/zh-cn.

* feat: integrate accessibility testing with Playwright and enhance CI workflow

- Added Playwright configuration for accessibility testing.
- Implemented accessibility tests using axe-core for all English pages.
- Created a GitHub Actions workflow to automate accessibility tests on pull requests.
- Updated package dependencies to include @axe-core/playwright and @playwright/test.
- Enhanced sidebar component with data attributes for improved accessibility styling.

* fix: update base URL and improve accessibility labels across multiple languages

- Changed the base URL in Playwright configuration and CI workflow from localhost:3000 to localhost:1313.
- Added accessibility labels for screen readers in various language files, enhancing user experience for visually impaired users.
- Updated the Asciinema script to dynamically set the playback time label for better accessibility compliance.

* refactor: reorganize accessibility tests and update test directory structure

- Moved accessibility tests from the e2e directory to a new tests directory for better organization.
- Updated the test directory path in Playwright configuration.
- Refactored the accessibility test implementation to improve code clarity and maintainability.

* chore: update .gitignore to include Playwright test output directories

- Added entries for 'playwright-report/' and 'test-results/' to the .gitignore file to prevent cluttering the repository with test artifacts.

* refactor: enhance accessibility and improve focus styles across components

- Removed unused utility for focus visibility in CSS and consolidated focus-visible styles for better maintainability.
- Updated various components to use `role` attributes for improved accessibility, including menu items and buttons.
- Enhanced theme toggle and language switch components with appropriate ARIA roles and attributes for better screen reader support.
- Improved the handling of focus states in the navigation and context menus to ensure a consistent user experience.

* chore: update dependencies and enhance accessibility features

- Updated the 'serve' package version in package.json and package-lock.json for improved performance.
- Removed unused 'xml2js' dependency to streamline the project.
- Enhanced the Playwright configuration to better manage the web server setup for testing.
- Improved accessibility in the language switcher and navigation menu by refining focus management and keyboard interactions.
- Updated the back-to-top button to manage tabindex for better accessibility compliance.

* feat: enhance mobile menu accessibility and keyboard interactions

- Added ARIA attributes to manage visibility of the sidebar on mobile devices.
- Implemented focus management for the sidebar when the menu is toggled.
- Introduced keyboard support to close the menu with the Escape key.
- Improved overall accessibility for the hamburger menu and sidebar interactions.

* fix: refine mobile menu keyboard interaction and enhance navbar accessibility

- Updated the Escape key functionality to close the menu only on mobile devices.
- Added a new ARIA attribute to the hamburger menu button for improved accessibility.
This commit is contained in:
Xin
2026-02-14 20:06:35 +00:00
committed by GitHub
parent 04803c4071
commit 88aa6098f0
97 changed files with 2603 additions and 238 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,3 @@
.hextra-archive-timeline {
border-left: 2px solid rgba(0, 0, 0, 0.15);
}
.dark .hextra-archive-timeline {
border-left-color: rgba(255, 255, 255, 0.15);
@apply hx:border-l-2 hx:border-black/15 hx:dark:border-white/15;
}

View File

@@ -1,3 +1,3 @@
.hextra-badge {
@apply hx:inline-flex hx:items-center;
}
}

View File

@@ -2,7 +2,7 @@
li {
@apply hx:mx-2.5 hx:wrap-break-word hx:rounded-md hx:contrast-more:border hx:text-gray-800 hx:contrast-more:border-transparent hx:dark:text-gray-300;
a {
@apply hx:focus-visible:outline-none hx:focus:outline-none hx:block hx:scroll-m-12 hx:px-2.5 hx:py-2;
@apply hx:focus-visible:outline-none hx:block hx:scroll-m-12 hx:px-2.5 hx:py-2;
}
.hextra-search-title {

View File

@@ -9,13 +9,13 @@
}
.hextra-sidebar-container {
li > div {
li > .hextra-sidebar-children {
@apply hx:h-0;
}
li.open > div {
li.open > .hextra-sidebar-children {
@apply hx:h-auto hx:pt-1;
}
li.open > a > span > svg > path {
li.open > .hextra-sidebar-item > .hextra-sidebar-collapsible-button > svg > path {
@apply hx:rotate-90;
}
}

View File

@@ -43,12 +43,37 @@ body {
@apply hx:outline-none hx:ring-2 hx:ring-primary-200 hx:ring-offset-1 hx:ring-offset-primary-300 hx:dark:ring-primary-800 hx:dark:ring-offset-primary-700;
}
@utility hextra-focus-visible {
@apply hx:focus-visible:outline-none hx:focus-visible:ring-2 hx:focus-visible:ring-primary-200 hx:focus-visible:ring-offset-1 hx:focus-visible:ring-offset-primary-300 hx:dark:focus-visible:ring-primary-800 hx:dark:focus-visible:ring-offset-primary-700;
}
@utility hextra-focus-visible-inset {
@apply hx:focus-visible:outline-none hx:focus-visible:ring-inset hx:focus-visible:ring-2 hx:focus-visible:ring-primary-200 hx:dark:focus-visible:ring-primary-800 hx:focus-visible:ring-offset-0;
}
@layer base {
abbr:where([title]) {
cursor: help;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@layer base {
:where(a, button, [role="tab"], [role="menuitem"], [role="menuitemradio"], input, select, textarea, [tabindex="0"]):not(
[class*="hextra-focus-visible"]
):focus-visible {
@apply hx:hextra-focus;
}
}
@import "./typography.css";
@import "./highlight.css";
@import "./components/cards.css";

View File

@@ -109,7 +109,7 @@
span:target + &,
:hover > &,
&:focus {
&:focus-visible {
@apply hx:opacity-100;
}

View File

@@ -6,17 +6,20 @@ document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("scroll", (e) => {
if (window.scrollY > 300) {
backToTop.classList.remove("hx:opacity-0");
backToTop.removeAttribute("tabindex");
} else {
backToTop.classList.add("hx:opacity-0");
backToTop.setAttribute("tabindex", "-1");
}
});
}
});
function scrollUp() {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.scroll({
top: 0,
left: 0,
behavior: "smooth",
behavior: prefersReducedMotion ? 'auto' : 'smooth',
});
}

View File

@@ -25,6 +25,27 @@ document.addEventListener('DOMContentLoaded', function () {
return svg;
}
// Make scrollable code blocks focusable for keyboard users.
const updateScrollableCodeBlocks = () => {
document.querySelectorAll('.hextra-code-block pre, .highlight pre').forEach(function (pre) {
if (pre.scrollWidth > pre.clientWidth) {
pre.setAttribute('tabindex', '0');
} else {
pre.removeAttribute('tabindex');
}
});
};
updateScrollableCodeBlocks();
let resizeRaf;
window.addEventListener('resize', () => {
if (resizeRaf) {
cancelAnimationFrame(resizeRaf);
}
resizeRaf = requestAnimationFrame(updateScrollableCodeBlocks);
});
document.querySelectorAll('.hextra-code-copy-btn').forEach(function (button) {
// Add copy and success icons
button.querySelector('.hextra-copy-icon')?.appendChild(getCopyIcon());
@@ -52,8 +73,12 @@ document.addEventListener('DOMContentLoaded', function () {
}
navigator.clipboard.writeText(code).then(function () {
button.classList.add('copied');
var originalLabel = button.getAttribute('aria-label');
var copiedLabel = button.dataset.copiedLabel || 'Copied!';
button.setAttribute('aria-label', copiedLabel);
setTimeout(function () {
button.classList.remove('copied');
button.setAttribute('aria-label', originalLabel);
}, 1000);
}).catch(function (err) {
console.error('Failed to copy text: ', err);

View File

@@ -7,7 +7,9 @@ document.addEventListener("DOMContentLoaded", function () {
Array.from(folder.children).forEach(function (el) {
el.dataset.state = el.dataset.state === "open" ? "closed" : "open";
});
folder.nextElementSibling.dataset.state = folder.nextElementSibling.dataset.state === "open" ? "closed" : "open";
var newState = folder.nextElementSibling.dataset.state === "open" ? "closed" : "open";
folder.nextElementSibling.dataset.state = newState;
folder.setAttribute('aria-expanded', newState === 'open' ? 'true' : 'false');
});
});
});

View File

@@ -1,25 +1,102 @@
(function () {
const languageSwitchers = document.querySelectorAll('.hextra-language-switcher');
const closeSwitcher = (switcher, focusSwitcher = false) => {
switcher.dataset.state = 'closed';
switcher.setAttribute('aria-expanded', 'false');
const optionsElement = switcher.nextElementSibling;
optionsElement.classList.add('hx:hidden');
if (focusSwitcher) {
switcher.focus();
}
};
const openSwitcher = (switcher, focusTarget = "none") => {
switcher.dataset.state = 'open';
switcher.setAttribute('aria-expanded', 'true');
const optionsElement = switcher.nextElementSibling;
if (optionsElement.classList.contains('hx:hidden')) {
toggleMenu(switcher);
} else {
resizeMenu(switcher);
}
if (focusTarget !== "none") {
const items = Array.from(optionsElement.querySelectorAll('[role="menuitem"]'));
if (items.length > 0) {
const target = focusTarget === "last" ? items[items.length - 1] : items[0];
target.focus();
}
}
};
languageSwitchers.forEach((switcher) => {
switcher.addEventListener('click', (e) => {
e.preventDefault();
switcher.dataset.state = switcher.dataset.state === 'open' ? 'closed' : 'open';
if (switcher.dataset.state === 'open') {
closeSwitcher(switcher);
} else {
openSwitcher(switcher);
}
});
toggleMenu(switcher);
switcher.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
openSwitcher(switcher, 'first');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
openSwitcher(switcher, 'last');
}
});
});
window.addEventListener("resize", () => languageSwitchers.forEach(resizeMenu))
document.querySelectorAll('.hextra-language-options[role=menu]').forEach((menu) => {
menu.addEventListener('keydown', (e) => {
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
if (items.length === 0) return;
// Dismiss language switcher when clicking outside
const currentIndex = items.indexOf(document.activeElement);
let newIndex;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
newIndex = (currentIndex + 1) % items.length;
items[newIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
newIndex = (currentIndex - 1 + items.length) % items.length;
items[newIndex].focus();
break;
case 'Home':
e.preventDefault();
items[0].focus();
break;
case 'End':
e.preventDefault();
items[items.length - 1].focus();
break;
case 'Escape': {
e.preventDefault();
const switcher = menu.previousElementSibling;
if (switcher) {
closeSwitcher(switcher, true);
}
break;
}
}
});
});
window.addEventListener("resize", () => languageSwitchers.forEach(resizeMenu));
// Dismiss language switcher when clicking outside.
document.addEventListener('click', (e) => {
if (e.target.closest('.hextra-language-switcher') === null) {
if (!e.target.closest('.hextra-language-switcher') && !e.target.closest('.hextra-language-options')) {
languageSwitchers.forEach((switcher) => {
switcher.dataset.state = 'closed';
const optionsElement = switcher.nextElementSibling;
optionsElement.classList.add('hx:hidden');
closeSwitcher(switcher);
});
}
});

View File

@@ -3,6 +3,24 @@
document.addEventListener('DOMContentLoaded', function () {
const menu = document.querySelector('.hextra-hamburger-menu');
const sidebarContainer = document.querySelector('.hextra-sidebar-container');
const mobileQuery = window.matchMedia('(max-width: 767px)');
function isMenuOpen() {
return menu.querySelector('svg').classList.contains('open');
}
// On mobile, the sidebar is off-screen so hide it from assistive tech
function syncAriaHidden() {
if (mobileQuery.matches) {
sidebarContainer.setAttribute('aria-hidden', isMenuOpen() ? 'false' : 'true');
} else {
sidebarContainer.removeAttribute('aria-hidden');
}
}
// Set initial state
syncAriaHidden();
mobileQuery.addEventListener('change', syncAriaHidden);
function toggleMenu() {
// Toggle the hamburger menu
@@ -15,6 +33,19 @@ document.addEventListener('DOMContentLoaded', function () {
// When the menu is open, we want to prevent the body from scrolling
document.body.classList.toggle('hx:overflow-hidden');
document.body.classList.toggle('hx:md:overflow-auto');
// Sync aria-expanded and aria-hidden
const isOpen = isMenuOpen();
menu.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
syncAriaHidden();
// Move focus into sidebar when opening, restore when closing
if (isOpen) {
const firstFocusable = sidebarContainer.querySelector('a, button, input, [tabindex="0"]');
if (firstFocusable) firstFocusable.focus();
} else {
menu.focus();
}
}
menu.addEventListener('click', (e) => {
@@ -22,6 +53,13 @@ document.addEventListener('DOMContentLoaded', function () {
toggleMenu();
});
// Close menu on Escape key (mobile only)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && mobileQuery.matches && isMenuOpen()) {
toggleMenu();
}
});
// Select all anchor tags in the sidebar container
const sidebarLinks = sidebarContainer.querySelectorAll('a');

View File

@@ -1,61 +1,125 @@
(function () {
const hiddenClass = "hx:hidden";
const dropdownToggles = document.querySelectorAll(".hextra-nav-menu-toggle");
const closeDropdown = (toggle, focusToggle = false) => {
toggle.dataset.state = "closed";
toggle.setAttribute("aria-expanded", "false");
const menuItemsElement = toggle.nextElementSibling;
menuItemsElement.classList.add(hiddenClass);
if (focusToggle) {
toggle.focus();
}
};
const openDropdown = (toggle, focusTarget = "none") => {
// Close all other dropdowns first.
dropdownToggles.forEach((otherToggle) => {
if (otherToggle !== toggle) {
closeDropdown(otherToggle);
}
});
toggle.dataset.state = "open";
toggle.setAttribute("aria-expanded", "true");
const menuItemsElement = toggle.nextElementSibling;
// Position dropdown centered with toggle.
menuItemsElement.style.position = "absolute";
menuItemsElement.style.top = "100%";
menuItemsElement.style.left = "50%";
menuItemsElement.style.transform = "translateX(-50%)";
menuItemsElement.style.zIndex = "1000";
menuItemsElement.classList.remove(hiddenClass);
if (focusTarget !== "none") {
const items = Array.from(menuItemsElement.querySelectorAll('[role="menuitem"]'));
if (items.length > 0) {
const target = focusTarget === "last" ? items[items.length - 1] : items[0];
target.focus();
}
}
};
dropdownToggles.forEach((toggle) => {
toggle.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
// Close all other dropdowns first
dropdownToggles.forEach((otherToggle) => {
if (otherToggle !== toggle) {
otherToggle.dataset.state = "closed";
const otherMenuItems = otherToggle.nextElementSibling;
otherMenuItems.classList.add(hiddenClass);
}
});
// Toggle current dropdown
// Toggle current dropdown.
const isOpen = toggle.dataset.state === "open";
toggle.dataset.state = isOpen ? "closed" : "open";
const menuItemsElement = toggle.nextElementSibling;
if (!isOpen) {
// Position dropdown centered with toggle
menuItemsElement.style.position = "absolute";
menuItemsElement.style.top = "100%";
menuItemsElement.style.left = "50%";
menuItemsElement.style.transform = "translateX(-50%)";
menuItemsElement.style.zIndex = "1000";
// Show dropdown
menuItemsElement.classList.remove(hiddenClass);
if (isOpen) {
closeDropdown(toggle);
} else {
// Hide dropdown
menuItemsElement.classList.add(hiddenClass);
openDropdown(toggle);
}
});
toggle.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
openDropdown(toggle, "first");
} else if (e.key === "ArrowUp") {
e.preventDefault();
openDropdown(toggle, "last");
}
});
});
// Dismiss dropdown when clicking outside
document.querySelectorAll(".hextra-nav-menu-items[role=menu]").forEach((menu) => {
menu.addEventListener("keydown", (e) => {
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
if (items.length === 0) return;
const currentIndex = items.indexOf(document.activeElement);
let newIndex;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
newIndex = (currentIndex + 1) % items.length;
items[newIndex].focus();
break;
case "ArrowUp":
e.preventDefault();
newIndex = (currentIndex - 1 + items.length) % items.length;
items[newIndex].focus();
break;
case "Home":
e.preventDefault();
items[0].focus();
break;
case "End":
e.preventDefault();
items[items.length - 1].focus();
break;
case "Escape": {
e.preventDefault();
const toggle = menu.previousElementSibling;
if (toggle) {
closeDropdown(toggle, true);
}
break;
}
}
});
});
// Dismiss dropdown when clicking outside.
document.addEventListener("click", (e) => {
if (e.target.closest(".hextra-nav-menu-toggle") === null) {
if (!e.target.closest(".hextra-nav-menu-toggle") && !e.target.closest(".hextra-nav-menu-items")) {
dropdownToggles.forEach((toggle) => {
toggle.dataset.state = "closed";
const menuItemsElement = toggle.nextElementSibling;
menuItemsElement.classList.add(hiddenClass);
closeDropdown(toggle);
});
}
});
// Close dropdowns on escape key
// Close dropdowns on escape key.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
dropdownToggles.forEach((toggle) => {
toggle.dataset.state = "closed";
const menuItemsElement = toggle.nextElementSibling;
menuItemsElement.classList.add(hiddenClass);
if (toggle.dataset.state === "open") {
closeDropdown(toggle, true);
}
});
}
});

View File

@@ -67,6 +67,7 @@ document.addEventListener('DOMContentLoaded', () => {
dropdownToggles.forEach(t => {
if (t !== toggle) {
t.dataset.state = 'closed';
t.setAttribute('aria-expanded', 'false');
const otherContainer = t.closest('.hextra-page-context-menu');
const otherMenu = otherContainer.querySelector('.hextra-page-context-menu-dropdown');
const otherChevron = t.querySelector('[data-chevron]');
@@ -79,6 +80,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Toggle current
toggle.dataset.state = isOpen ? 'closed' : 'open';
toggle.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
menu.classList.toggle('hx:hidden', isOpen);
// Rotate chevron icon
@@ -95,6 +97,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (isOutside) {
dropdownToggles.forEach(toggle => {
toggle.dataset.state = 'closed';
toggle.setAttribute('aria-expanded', 'false');
const container = toggle.closest('.hextra-page-context-menu');
const menu = container.querySelector('.hextra-page-context-menu-dropdown');
const chevron = toggle.querySelector('[data-chevron]');
@@ -106,6 +109,19 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
// Close dropdown on Escape key and return focus to toggle
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
dropdownToggles.forEach(toggle => {
if (toggle.dataset.state === 'open') {
const container = toggle.closest('.hextra-page-context-menu');
closeDropdown(container);
toggle.focus();
}
});
}
});
// Helper to close dropdown
const closeDropdown = (container) => {
if (!container) return;
@@ -117,6 +133,7 @@ document.addEventListener('DOMContentLoaded', () => {
const chevron = toggle.querySelector('[data-chevron]');
toggle.dataset.state = 'closed';
toggle.setAttribute('aria-expanded', 'false');
menu.classList.add('hx:hidden');
if (chevron) {
chevron.style.transform = '';

View File

@@ -8,9 +8,10 @@ function enableCollapsibles() {
buttons.forEach(function (button) {
button.addEventListener("click", function (e) {
e.preventDefault();
const list = button.parentElement.parentElement;
const list = button.closest('li');
if (list) {
list.classList.toggle("open")
list.classList.toggle("open");
button.setAttribute('aria-expanded', list.classList.contains('open') ? 'true' : 'false');
}
});
});

View File

@@ -7,14 +7,15 @@
tab.setAttribute('aria-selected', 'true');
tab.tabIndex = 0;
} else {
tab.removeAttribute('aria-selected');
tab.removeAttribute('tabindex');
tab.setAttribute('aria-selected', 'false');
tab.tabIndex = -1;
}
});
const panelsContainer = container.parentElement.nextElementSibling;
if (!panelsContainer) return;
Array.from(panelsContainer.children).forEach((panel, i) => {
panel.dataset.state = i === index ? 'selected' : '';
panel.setAttribute('aria-hidden', i === index ? 'false' : 'true');
if (i === index) {
panel.tabIndex = 0;
} else {
@@ -39,7 +40,7 @@
const index = Array.from(container.querySelectorAll('.hextra-tabs-toggle')).indexOf(
e.target
);
if (container.dataset.tabGroup) {
// Sync behavior: update all tab groups with the same name
const tabGroupValue = container.dataset.tabGroup;
@@ -53,5 +54,48 @@
updateGroup(container, index);
}
});
// Keyboard navigation for tabs
button.addEventListener('keydown', function (e) {
const container = button.parentElement;
const tabs = Array.from(container.querySelectorAll('.hextra-tabs-toggle'));
const currentIndex = tabs.indexOf(button);
let newIndex;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
newIndex = (currentIndex + 1) % tabs.length;
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
newIndex = (currentIndex - 1 + tabs.length) % tabs.length;
break;
case 'Home':
e.preventDefault();
newIndex = 0;
break;
case 'End':
e.preventDefault();
newIndex = tabs.length - 1;
break;
default:
return;
}
if (container.dataset.tabGroup) {
const tabGroupValue = container.dataset.tabGroup;
const key = encodeURIComponent(tabGroupValue);
document
.querySelectorAll('[data-tab-group="' + tabGroupValue + '"]')
.forEach((grp) => updateGroup(grp, newIndex));
localStorage.setItem('hextra-tab-' + key, newIndex.toString());
} else {
updateGroup(container, newIndex);
}
tabs[newIndex].focus();
});
});
})();

View File

@@ -0,0 +1,15 @@
document.addEventListener("DOMContentLoaded", function () {
// Hugo task lists render bare checkboxes; provide an accessible name.
document.querySelectorAll("main#content li > input[type='checkbox']").forEach(function (checkbox) {
if (checkbox.hasAttribute("aria-label") || checkbox.hasAttribute("aria-labelledby")) {
return;
}
var listItem = checkbox.closest("li");
if (!listItem) return;
var labelText = listItem.textContent.replace(/\s+/g, " ").trim();
if (labelText) {
checkbox.setAttribute("aria-label", labelText);
}
});
});

View File

@@ -4,12 +4,15 @@
const themes = ["light", "dark"];
const themeToggleButtons = document.querySelectorAll(".hextra-theme-toggle");
const themeToggleOptions = document.querySelectorAll(".hextra-theme-toggle-options p");
const themeToggleOptions = document.querySelectorAll(".hextra-theme-toggle-options button[role=menuitemradio]");
function applyTheme(theme) {
theme = themes.includes(theme) ? theme : "system";
themeToggleButtons.forEach((btn) => btn.parentElement.dataset.theme = theme );
themeToggleOptions.forEach((option) => {
option.setAttribute('aria-checked', option.dataset.item === theme ? 'true' : 'false');
});
localStorage.setItem("color-theme", theme);
}
@@ -36,7 +39,16 @@
toggler.addEventListener("click", function (e) {
e.preventDefault();
toggler.dataset.state = toggler.dataset.state === 'open' ? 'closed' : 'open';
toggleMenu(toggler);
const isOpen = toggler.dataset.state === 'open';
toggler.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
// Focus first menuitem when opening
if (isOpen) {
const firstItem = toggler.nextElementSibling.querySelector('button[role=menuitemradio]');
if (firstItem) firstItem.focus();
}
});
});
@@ -47,11 +59,50 @@
if (e.target.closest('.hextra-theme-toggle') === null) {
themeToggleButtons.forEach((toggler) => {
toggler.dataset.state = 'closed';
toggler.setAttribute('aria-expanded', 'false');
toggler.nextElementSibling.classList.add('hx:hidden');
});
}
});
// Keyboard navigation for the theme menu
document.querySelectorAll('.hextra-theme-toggle-options[role=menu]').forEach(function (menu) {
menu.addEventListener('keydown', function (e) {
const items = Array.from(menu.querySelectorAll('button[role=menuitemradio]'));
const currentIndex = items.indexOf(document.activeElement);
let newIndex;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
newIndex = (currentIndex + 1) % items.length;
items[newIndex].focus();
break;
case 'ArrowUp':
e.preventDefault();
newIndex = (currentIndex - 1 + items.length) % items.length;
items[newIndex].focus();
break;
case 'Home':
e.preventDefault();
items[0].focus();
break;
case 'End':
e.preventDefault();
items[items.length - 1].focus();
break;
case 'Escape':
e.preventDefault();
var toggler = menu.previousElementSibling;
toggler.dataset.state = 'closed';
toggler.setAttribute('aria-expanded', 'false');
menu.classList.add('hx:hidden');
toggler.focus();
break;
}
});
});
// Listen for system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
if (localStorage.getItem("color-theme") === "system") {

View File

@@ -47,10 +47,12 @@ document.addEventListener("DOMContentLoaded", function () {
// Remove active class from previous link
if (currentActiveLink) {
currentActiveLink.classList.remove("hextra-toc-active");
currentActiveLink.removeAttribute("aria-current");
}
// Add active class to current link
targetLink.classList.add("hextra-toc-active");
targetLink.setAttribute("aria-current", "location");
currentActiveLink = targetLink;
}
},
@@ -74,8 +76,10 @@ document.addEventListener("DOMContentLoaded", function () {
if (currentActiveLink) {
currentActiveLink.classList.remove("hextra-toc-active");
currentActiveLink.removeAttribute("aria-current");
}
targetLink.classList.add("hextra-toc-active");
targetLink.setAttribute("aria-current", "location");
currentActiveLink = targetLink;
// Re-enable observer after scroll settles

View File

@@ -21,6 +21,7 @@ document.addEventListener("DOMContentLoaded", function () {
(function () {
const searchDataURL = '{{ $searchData.RelPermalink }}';
const resultsFoundTemplate = '{{ (T "resultsFound") | default "%d results found" }}';
const inputElements = document.querySelectorAll('.hextra-search-input');
for (const el of inputElements) {
@@ -389,6 +390,12 @@ document.addEventListener("DOMContentLoaded", function () {
if (!results.length) {
resultsElement.innerHTML = `<span class="hextra-search-no-result">{{ $noResultsFound | safeHTML }}</span>`;
// Announce no results to screen readers
const wrapper = resultsElement.closest('.hextra-search-wrapper');
const statusEl = wrapper ? wrapper.querySelector('.hextra-search-status') : null;
if (statusEl) {
statusEl.textContent = '{{ $noResultsFound | safeHTML }}';
}
return;
}
@@ -472,5 +479,14 @@ document.addEventListener("DOMContentLoaded", function () {
}
resultsElement.appendChild(fragment);
resultsElement.dataset.count = results.length;
// Announce results count to screen readers
const wrapper = resultsElement.closest('.hextra-search-wrapper');
const statusEl = wrapper ? wrapper.querySelector('.hextra-search-status') : null;
if (statusEl) {
statusEl.textContent = results.length > 0
? resultsFoundTemplate.replace('%d', results.length.toString())
: '{{ $noResultsFound | safeHTML }}';
}
}
})();