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:
2
assets/css/compiled/main.css
generated
2
assets/css/compiled/main.css
generated
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
.hextra-badge {
|
||||
@apply hx:inline-flex hx:items-center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
|
||||
span:target + &,
|
||||
:hover > &,
|
||||
&:focus {
|
||||
&:focus-visible {
|
||||
@apply hx:opacity-100;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
15
assets/js/core/task-list.js
Normal file
15
assets/js/core/task-list.js
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}';
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user