Files
hextra/assets/js/flexsearch.js
Xin 88aa6098f0 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.
2026-02-14 20:06:35 +00:00

493 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Search functionality using FlexSearch.
// Change shortcut key to cmd+k on Mac, iPad or iPhone.
document.addEventListener("DOMContentLoaded", function () {
if (/iPad|iPhone|Macintosh/.test(navigator.userAgent)) {
// select the kbd element under the .hextra-search-wrapper class
const keys = document.querySelectorAll(".hextra-search-wrapper kbd");
keys.forEach(key => {
key.innerHTML = '<span class="hx:text-xs">⌘</span>K';
});
}
});
// Render the search data as JSON.
// {{ $searchDataFile := printf "%s.search-data.json" .Language.Lang }}
// {{ $searchData := resources.Get "json/search-data.json" | resources.ExecuteAsTemplate $searchDataFile . }}
// {{ if hugo.IsProduction }}
// {{ $searchData := $searchData | minify | fingerprint }}
// {{ end }}
// {{ $noResultsFound := (T "noResultsFound") | default "No results found." }}
(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) {
el.addEventListener('focus', init);
el.addEventListener('keyup', search);
el.addEventListener('keydown', handleKeyDown);
el.addEventListener('input', handleInputChange);
}
const shortcutElements = document.querySelectorAll('.hextra-search-wrapper kbd');
function setShortcutElementsOpacity(opacity) {
shortcutElements.forEach(el => {
el.style.opacity = opacity;
});
}
function handleInputChange(e) {
const opacity = e.target.value.length > 0 ? 0 : 100;
setShortcutElementsOpacity(opacity);
}
// Get the search wrapper, input, and results elements.
function getActiveSearchElement() {
const inputs = Array.from(document.querySelectorAll('.hextra-search-wrapper')).filter(el => el.clientHeight > 0);
if (inputs.length === 1) {
return {
wrapper: inputs[0],
inputElement: inputs[0].querySelector('.hextra-search-input'),
resultsElement: inputs[0].querySelector('.hextra-search-results')
};
}
return undefined;
}
const INPUTS = ['input', 'select', 'button', 'textarea']
// Focus the search input when pressing ctrl+k/cmd+k or /.
document.addEventListener('keydown', function (e) {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
const activeElement = document.activeElement;
const tagName = activeElement && activeElement.tagName;
if (
inputElement === activeElement ||
!tagName ||
INPUTS.includes(tagName) ||
(activeElement && activeElement.isContentEditable))
return;
if (
e.key === '/' ||
(e.key === 'k' &&
(e.metaKey /* for Mac */ || /* for non-Mac */ e.ctrlKey))
) {
e.preventDefault();
inputElement.focus();
} else if (e.key === 'Escape' && inputElement.value) {
inputElement.blur();
}
});
// Dismiss the search results when clicking outside the search box.
document.addEventListener('mousedown', function (e) {
const { inputElement, resultsElement } = getActiveSearchElement();
if (!inputElement || !resultsElement) return;
if (
e.target !== inputElement &&
e.target !== resultsElement &&
!resultsElement.contains(e.target)
) {
setShortcutElementsOpacity(100);
hideSearchResults();
}
});
// Get the currently active result and its index.
function getActiveResult() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return { result: undefined, index: -1 };
const result = resultsElement.querySelector('.hextra-search-active');
if (!result) return { result: undefined, index: -1 };
const index = parseInt(result.dataset.index, 10);
return { result, index };
}
// Set the active result by index.
function setActiveResult(index) {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
const { result: activeResult } = getActiveResult();
activeResult && activeResult.classList.remove('hextra-search-active');
const result = resultsElement.querySelector(`[data-index="${index}"]`);
if (result) {
result.classList.add('hextra-search-active');
result.focus();
}
}
// Get the number of search results from the DOM.
function getResultsLength() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return 0;
return resultsElement.dataset.count;
}
// Finish the search by hiding the results and clearing the input.
function finishSearch() {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
hideSearchResults();
inputElement.value = '';
inputElement.blur();
}
function hideSearchResults() {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
resultsElement.classList.add('hx:hidden');
}
// Handle keyboard events.
function handleKeyDown(e) {
const { inputElement } = getActiveSearchElement();
if (!inputElement) return;
const resultsLength = getResultsLength();
const { result: activeResult, index: activeIndex } = getActiveResult();
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
if (activeIndex > 0) setActiveResult(activeIndex - 1);
break;
case 'ArrowDown':
e.preventDefault();
if (activeIndex + 1 < resultsLength) setActiveResult(activeIndex + 1);
break;
case 'Enter':
e.preventDefault();
if (activeResult) {
activeResult.click();
}
finishSearch();
case 'Escape':
e.preventDefault();
hideSearchResults();
// Clear the input when pressing escape
inputElement.value = '';
inputElement.dispatchEvent(new Event('input'));
// Remove focus from the input
inputElement.blur();
break;
}
}
// Initializes the search.
function init(e) {
e.target.removeEventListener('focus', init);
if (!(window.pageIndex && window.sectionIndex)) {
preloadIndex();
}
}
/**
* Preloads the search index by fetching data and adding it to the FlexSearch index.
* @returns {Promise<void>} A promise that resolves when the index is preloaded.
*/
async function preloadIndex() {
const tokenize = '{{- site.Params.search.flexsearch.tokenize | default "forward" -}}';
// https://github.com/TryGhost/Ghost/pull/21148
const regex = new RegExp(
`[\u{4E00}-\u{9FFF}\u{3040}-\u{309F}\u{30A0}-\u{30FF}\u{AC00}-\u{D7A3}\u{3400}-\u{4DBF}\u{20000}-\u{2A6DF}\u{2A700}-\u{2B73F}\u{2B740}-\u{2B81F}\u{2B820}-\u{2CEAF}\u{2CEB0}-\u{2EBEF}\u{30000}-\u{3134F}\u{31350}-\u{323AF}\u{2EBF0}-\u{2EE5F}\u{F900}-\u{FAFF}\u{2F800}-\u{2FA1F}]|[0-9A-Za-zа\u00C0-\u017F\u0400-\u04FF\u0600-\u06FF\u0980-\u09FF\u1E00-\u1EFF\u0590-\u05FF]+`,
'mug'
);
const encode = (str) => { return ('' + str).toLowerCase().match(regex) ?? []; }
window.pageIndex = new FlexSearch.Document({
tokenize,
encode,
cache: 100,
document: {
id: 'id',
store: ['title', 'crumb'],
index: "content"
}
});
window.sectionIndex = new FlexSearch.Document({
tokenize,
encode,
cache: 100,
document: {
id: 'id',
store: ['title', 'content', 'url', 'display', 'crumb'],
index: "content",
tag: [{
field: "pageId"
}]
}
});
const resp = await fetch(searchDataURL);
const data = await resp.json();
let pageId = 0;
for (const route in data) {
let pageContent = '';
++pageId;
const urlParts = route.split('/').filter(x => x != "" && !x.startsWith('#'));
let crumb = '';
let searchUrl = '/';
for (let i = 0; i < urlParts.length; i++) {
const urlPart = urlParts[i];
searchUrl += urlPart + '/'
const crumbData = data[searchUrl];
if (!crumbData) {
console.warn('Excluded page', searchUrl, '- will not be included for search result breadcrumb for', route);
continue;
}
let title = data[searchUrl].title;
if (title == "_index") {
title = urlPart.split("-").map(x => x).join(" ");
}
crumb += title;
if (i < urlParts.length - 1) {
crumb += ' > ';
}
}
for (const heading in data[route].data) {
const [hash, text] = heading.split('#');
const url = route.trimEnd('/') + (hash ? '#' + hash : '');
const title = text || data[route].title;
const content = data[route].data[heading] || '';
const paragraphs = content.split('\n').filter(Boolean);
sectionIndex.add({
id: url,
url,
title,
crumb,
pageId: `page_${pageId}`,
content: title,
...(paragraphs[0] && { display: paragraphs[0] })
});
for (let i = 0; i < paragraphs.length; i++) {
sectionIndex.add({
id: `${url}_${i}`,
url,
title,
crumb,
pageId: `page_${pageId}`,
content: paragraphs[i]
});
}
pageContent += ` ${title} ${content}`;
}
window.pageIndex.add({
id: pageId,
title: data[route].title,
crumb,
content: pageContent
});
}
}
/**
* Performs a search based on the provided query and displays the results.
* @param {Event} e - The event object.
*/
function search(e) {
const query = e.target.value;
if (!e.target.value) {
hideSearchResults();
return;
}
const { resultsElement } = getActiveSearchElement();
while (resultsElement.firstChild) {
resultsElement.removeChild(resultsElement.firstChild);
}
resultsElement.classList.remove('hx:hidden');
// Configurable search limits with sensible defaults
const maxPageResults = parseInt('{{- site.Params.search.flexsearch.maxPageResults | default 20 -}}', 10);
const maxSectionResults = parseInt('{{- site.Params.search.flexsearch.maxSectionResults | default 10 -}}', 10);
const pageResults = window.pageIndex.search(query, maxPageResults, { enrich: true, suggest: true })[0]?.result || [];
const results = [];
const pageTitleMatches = {};
for (let i = 0; i < pageResults.length; i++) {
const result = pageResults[i];
pageTitleMatches[i] = 0;
const sectionResults = window.sectionIndex.search(query,
{ enrich: true, suggest: true, tag: { 'pageId': `page_${result.id}` } })[0]?.result || [];
let isFirstItemOfPage = true
const occurred = {}
const nResults = Math.min(sectionResults.length, maxSectionResults);
for (let j = 0; j < nResults; j++) {
const { doc } = sectionResults[j]
const isMatchingTitle = doc.display !== undefined
if (isMatchingTitle) {
pageTitleMatches[i]++
}
const { url, title } = doc
const content = doc.display || doc.content
if (occurred[url + '@' + content]) continue
occurred[url + '@' + content] = true
results.push({
_page_rk: i,
_section_rk: j,
route: url,
prefix: isFirstItemOfPage ? result.doc.crumb : undefined,
children: { title, content }
})
isFirstItemOfPage = false
}
}
const sortedResults = results
.sort((a, b) => {
// Sort by number of matches in the title.
if (a._page_rk === b._page_rk) {
return a._section_rk - b._section_rk
}
if (pageTitleMatches[a._page_rk] !== pageTitleMatches[b._page_rk]) {
return pageTitleMatches[b._page_rk] - pageTitleMatches[a._page_rk]
}
return a._page_rk - b._page_rk
})
.map(res => ({
id: `${res._page_rk}_${res._section_rk}`,
route: res.route,
prefix: res.prefix,
children: res.children
}));
displayResults(sortedResults, query);
}
/**
* Displays the search results on the page.
*
* @param {Array} results - The array of search results.
* @param {string} query - The search query.
*/
function displayResults(results, query) {
const { resultsElement } = getActiveSearchElement();
if (!resultsElement) return;
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;
}
// Append text with highlighted matches using safe text nodes.
function appendHighlightedText(container, text, query) {
if (!text) return;
if (!query) {
container.textContent = text;
return;
}
const escapedQuery = query.replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&');
if (!escapedQuery) {
container.textContent = text;
return;
}
const regex = new RegExp(escapedQuery, 'gi');
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
container.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
}
const span = document.createElement('span');
span.className = 'hextra-search-match';
span.textContent = match[0];
container.appendChild(span);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
container.appendChild(document.createTextNode(text.slice(lastIndex)));
}
}
function handleMouseMove(e) {
const target = e.target.closest('a');
if (target) {
const active = resultsElement.querySelector('a.hextra-search-active');
if (active) {
active.classList.remove('hextra-search-active');
}
target.classList.add('hextra-search-active');
}
}
const fragment = document.createDocumentFragment();
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.prefix) {
const prefix = document.createElement('div');
prefix.className = 'hextra-search-prefix';
prefix.textContent = result.prefix;
fragment.appendChild(prefix);
}
const li = document.createElement('li');
const link = document.createElement('a');
link.dataset.index = i;
link.href = result.route;
if (i === 0) {
link.classList.add('hextra-search-active');
}
const title = document.createElement('div');
title.className = 'hextra-search-title';
appendHighlightedText(title, result.children.title, query);
link.appendChild(title);
if (result.children.content) {
const excerpt = document.createElement('div');
excerpt.className = 'hextra-search-excerpt';
appendHighlightedText(excerpt, result.children.content, query);
link.appendChild(excerpt);
}
li.appendChild(link);
li.addEventListener('mousemove', handleMouseMove);
li.addEventListener('keydown', handleKeyDown);
link.addEventListener('click', finishSearch);
fragment.appendChild(li);
}
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 }}';
}
}
})();