Toc keyboard navigation (#4497)

* Adds keyboard navigation to table of content links

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds table of contents scroll and item focus management to the current page or the first toc item

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Add space key capture to the toc expand/collapse arrows

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds escape key support for expand/collapse of toc categories on the category label

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds missing aria-role and aria-current attributes to toc affordances

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Changes the function of the space bar to navigate to a toc parent category item instead of only expand its children

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds arrow up/down key support to toc navigation

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds missing accessibility attribute handling to the toc

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Corrects arrow key handling of keyboard navigation of the toc

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds padding to allow for the keyboard focus rectangle

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Removes explicit tabindex values for the toc navigation

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Removes auto focus of toc navigation items on page load; refactors navigation panel scroll into view logic to account for the sticky version selector

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Removes keyboard focus trap on search field for the tab key

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Unifies navigation item focus state for top level items and sub navigation items

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Removes the expand/collapse navigation buttons from being tab focusable

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Corrects navigation item spacing to permit visible focus rectangles

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Implements full arrow navigation for the toc items according to the w3c tree view navigation pattern

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Adds reference link to the w3c documentation about the tree view navigation to the arrow key toc navigation javascript

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Account for the difference between the navigation panel height, and the viewport height when auto scrolling the toc item of the current page

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Corrects keyboard navigation focus rectangles from being slightly obscured on the bottom

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Corrects aria and role attributes according to the navigation tree pattern as opposed to the disclosure pattern

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Corrects Jekyll include parameter assignment syntax error

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Removes aria-expanded setting from the templates since it is unrealiable to determine without iterating through the entire tree to determine if one of its children of children is the current page

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Moves setting of aria-expanded attributes to a runtime behavior, and corrects arrow left/right behavior on navigation tree items

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Ensures unique navigation menu element ids; conditionally sets aira-owns and aria-current attributes

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

* Removes JavaScript that is unused following refactoring tree view navigation; adds comments explaining choices

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>

---------

Signed-off-by: Aaron Stephanus <taoist.futility@pm.me>
This commit is contained in:
astephanus 2023-07-17 11:11:42 -06:00 committed by GitHub
parent 72d120f327
commit 80237e2e68
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 304 additions and 52 deletions

View File

@ -1,4 +1,9 @@
<ul class="nav-list"> <ul
role="tree"
aria-expanded="{{ include.expanded | default: 'false' }}"
class="nav-list"
{%- if include.owned_tree_id -%}id="{{ include.owned_tree_id }}"{%- endif -%}
>
{%- assign titled_pages = include.pages {%- assign titled_pages = include.pages
| where_exp:"item", "item.title != nil" -%} | where_exp:"item", "item.title != nil" -%}
@ -60,28 +65,60 @@
{%- for node in pages_list -%} {%- for node in pages_list -%}
{%- if node.parent == nil -%} {%- if node.parent == nil -%}
{%- unless node.nav_exclude -%} {%- unless node.nav_exclude -%}
<li class="nav-list-item{% if page.collection == include.key and page.url == node.url or page.parent == node.title or page.grand_parent == node.title %} active{% endif %}"> {% assign nested_owned_tree_id = include.owned_tree_id | append: "_" | append: forloop.index | append: "_" | append: node.title | append: "_navitems" | replace: " ", "_" %}
<li role="none" class="nav-list-item{% if page.collection == include.key and page.url == node.url or page.parent == node.title or page.grand_parent == node.title %} active{% endif %}">
{%- if node.has_children -%} {%- if node.has_children -%}
<a href="#" class="nav-list-expander"><svg viewBox="0 0 24 24"><use xlink:href="#svg-arrow-right"></use></svg></a> <a
role="treeitem"
aria-owns="{{ nested_owned_tree_id }}"
{%- if page.url == node.url -%}aria-current="page"{%- endif -%}
href="#"
class="nav-list-expander"
><svg viewBox="0 0 24 24"><use xlink:href="#svg-arrow-right"></use></svg></a>
{%- endif -%} {%- endif -%}
<a href="{{ node.url | absolute_url }}" class="nav-list-link{% if page.url == node.url %} active{% endif %}">{{ node.title }}</a>
<a
role="treeitem"
{%- if node.has_children -%}aria-owns="{{ nested_owned_tree_id }}"{%- endif -%}
{%- if page.url == node.url -%}aria-current="page"{%- endif -%}
href="{{ node.url | absolute_url }}"
class="nav-list-link{% if page.url == node.url %} active{% endif %}"
>{{ node.title }}</a>
{%- if node.has_children -%} {%- if node.has_children -%}
{%- assign children_list = pages_list | where: "parent", node.title -%} {%- assign children_list = pages_list | where: "parent", node.title -%}
<ul class="nav-list "> <ul role="tree" class="nav-list" id="{{ nested_owned_tree_id }}">
{%- for child in children_list -%} {%- for child in children_list -%}
{%- unless child.nav_exclude -%} {%- unless child.nav_exclude -%}
<li class="nav-list-item {% if page.url == child.url or page.parent == child.title %} active{% endif %}"> <li role="none" class="nav-list-item {% if page.url == child.url or page.parent == child.title %} active{% endif %}">
{%- if child.has_children -%} {%- if child.has_children -%}
<a href="#" class="nav-list-expander"><svg viewBox="0 0 24 24"><use xlink:href="#svg-arrow-right"></use></svg></a> {% assign nested_nested_owned_tree_id = nested_owned_tree_id | append: "_" | append: forloop.index | append: "_" | append: child.title | append: "_navitems" | replace: " ", "_" %}
<a
role="treeitem"
aria-owns="{{ nested_nested_owned_tree_id }}"
{%- if page.url == node.url -%}aria-current="page"{%- endif -%}
href="#"
class="nav-list-expander"
><svg viewBox="0 0 24 24"><use xlink:href="#svg-arrow-right"></use></svg></a>
{%- endif -%} {%- endif -%}
<a href="{{ child.url | absolute_url }}" class="nav-list-link{% if page.url == child.url %} active{% endif %}">{{ child.title }}</a> <a
role="treeitem"
{%- if child.has_children -%}aria-owns="{{ nested_nested_owned_tree_id }}"{%- endif -%}
{%- if page.url == node.url -%}aria-current="page"{%- endif -%}
href="{{ child.url | absolute_url }}"
class="nav-list-link{% if page.url == child.url %} active{% endif %}"
>{{ child.title }}</a>
{%- if child.has_children -%} {%- if child.has_children -%}
{%- assign grand_children_list = pages_list | where: "parent", child.title | where: "grand_parent", node.title -%} {%- assign grand_children_list = pages_list | where: "parent", child.title | where: "grand_parent", node.title -%}
<ul class="nav-list"> <ul role="tree" class="nav-list" id="{{ nested_nested_owned_tree_id }}">
{%- for grand_child in grand_children_list -%} {%- for grand_child in grand_children_list -%}
{%- unless grand_child.nav_exclude -%} {%- unless grand_child.nav_exclude -%}
<li class="nav-list-item {% if page.url == grand_child.url %} active{% endif %}"> <li role="none" class="nav-list-item {% if page.url == grand_child.url %} active{% endif %}">
<a href="{{ grand_child.url | absolute_url }}" class="nav-list-link{% if page.url == grand_child.url %} active{% endif %}">{{ grand_child.title }}</a> <a
role="treeitem"
{%- if page.url == grand_child.url -%}aria-current="page"{%- endif -%}
href="{{ grand_child.url | absolute_url }}"
class="nav-list-link{% if page.url == grand_child.url %} active{% endif %}"
>{{ grand_child.title }}</a>
</li> </li>
{%- endunless -%} {%- endunless -%}
{%- endfor -%} {%- endfor -%}

View File

@ -66,7 +66,7 @@ layout: table_wrappers
| where_exp:"item", "item.nav_exclude != true" | where_exp:"item", "item.nav_exclude != true"
| size %} | size %}
{% if pages_top_size > 0 %} {% if pages_top_size > 0 %}
{% include nav.html pages=site.html_pages key=nil %} {% include nav.html pages=site.html_pages key=nil expanded=true %}
{% endif %} {% endif %}
{% if site.just_the_docs.collections %} {% if site.just_the_docs.collections %}
{% assign collections_size = site.just_the_docs.collections | size %} {% assign collections_size = site.just_the_docs.collections | size %}
@ -75,25 +75,49 @@ layout: table_wrappers
{% assign collection_value = collection_entry[1] %} {% assign collection_value = collection_entry[1] %}
{% assign collection = site[collection_key] %} {% assign collection = site[collection_key] %}
{% if collection_value.nav_exclude != true %} {% if collection_value.nav_exclude != true %}
{% assign owned_tree_id = collection_key | append: "_" | append: forloop.index | append: "_navitems" | replace: " ", "_" %}
{% if collections_size > 1 or pages_top_size > 0 %} {% if collections_size > 1 or pages_top_size > 0 %}
{% if collection_value.nav_fold == true %} {% if collection_value.nav_fold == true %}
<ul class="nav-list nav-category-list"> <ul
<li class="nav-list-item{% if page.collection == collection_key %} active{% endif %}"> role="tree"
{%- if collection.size > 0 -%} class="nav-list nav-category-list"
<a href="#" class="nav-list-expander"><svg viewBox="0 0 24 24"><use xlink:href="#svg-arrow-right"></use></svg></a> >
{%- endif -%} <li role="none" class="nav-list-item{% if page.collection == collection_key %} active{% endif %}">
{% assign collection_url_path = collection_key | append: "/index/" %} {% assign collection_url_path = collection_key | append: "/index/" %}
<div class="nav-category"><a href="{{ collection_url_path | relative_url }}">{{ collection_value.name }}</a></div> {% assign category_comparison_url = "/" | append: collection_url_path %}
{% include nav.html pages=collection key=collection_key %} {%- if collection.size > 0 -%}
<a
role="treeitem"
aria-owns="{{ owned_tree_id }}"
aria-current="{% if page.collection == collection_key %}page{% endif %}"
href="#"
class="nav-list-expander"
><svg viewBox="0 0 24 24"><use xlink:href="#svg-arrow-right"></use></svg></a>
{%- endif -%}
<a
role="treeitem"
aria-owns="{{ owned_tree_id }}"
aria-current="{% if page.collection == collection_key %}page{% endif %}"
class="nav-category{% if category_comparison_url == page.url %} active{% endif %}"
href="{{ collection_url_path | relative_url }}"
>{{ collection_value.name }}</a>
{% include nav.html pages=collection key=collection_key owned_tree_id=owned_tree_id %}
</li> </li>
</ul> </ul>
{% else %} {% else %}
{% assign collection_url_path = collection_key | append: "/index/" %} {% assign collection_url_path = collection_key | append: "/index/" %}
<div class="nav-category"><a href="{{ collection_url_path | relative_url }}">{{ collection_value.name }}</a></div> <a
{% include nav.html pages=collection key=collection_key %} role="treeitem"
aria-owns="{{ owned_tree_id }}"
aria-current="{% if page.collection == collection_key %}page{% endif %}"
class="nav-category{% if category_comparison_url == page.url %} active{% endif %}"
href="{{ collection_url_path | relative_url }}"
>{{ collection_value.name }}</a>
{% include nav.html pages=collection key=collection_key owned_tree_id=owned_tree_id %}
{% endif %} {% endif %}
{% else %} {% else %}
{% include nav.html pages=collection key=collection_key %} {% include nav.html pages=collection key=collection_key owned_tree_id=owned_tree_id %}
{% endif %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@ -111,8 +135,8 @@ layout: table_wrappers
{% assign docs_version = "latest" %} {% assign docs_version = "latest" %}
{% endif %} {% endif %}
<input type="text" id="search-input" class="search-input" <input type="text" id="search-input" class="search-input"
tabindex="0" placeholder="Search..." aria-label="Search {{ site.title }}" placeholder="Search..." aria-label="Search {{ site.title }}"
data-docs-version="{{ docs_version }}" autocomplete="off"> data-docs-version="{{ docs_version }}" autocomplete="off">
<div class="search-spinner"><i></i></div> <div class="search-spinner"><i></i></div>
<label for="search-input" class="search-label"><svg viewBox="0 0 24 24" class="search-icon"><use xlink:href="#svg-search"></use></svg></label> <label for="search-input" class="search-label"><svg viewBox="0 0 24 24" class="search-icon"><use xlink:href="#svg-search"></use></svg></label>
</div> </div>

View File

@ -54,10 +54,22 @@ code {
max-height: 100vh; max-height: 100vh;
overflow-x: hidden; overflow-x: hidden;
min-width: 14rem; min-width: 14rem;
padding-right: 1px;
padding-left: 1px;
padding-bottom: 1px;
}
nav#site-nav > .nav-list:nth-of-type(1) {
padding-top: 2px;
}
.nav-list {
margin-top: 1px;
} }
.nav-category { .nav-category {
text-align: start; text-align: start;
display: block;
} }
.main-content { .main-content {

View File

@ -1,27 +1,212 @@
let siteNav = document.querySelector('.site-nav');
const key = 'scroll';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const scroll = JSON.parse(sessionStorage.getItem(key)); const navParent = document.getElementById('site-nav');
if (!navParent) {
const currentDate = new Date(); return;
if (scroll !== null && currentDate.getTime() < scroll.expiry) {
siteNav.scrollTop = parseInt(scroll.value);
} }
else {
sessionStorage.removeItem(key); // The business logic on navigation items in layouts/default.html and _includes/nav.html
// is much too complex to reliably make this determination correctly without overly complicating
// something that is already overly complicated. So, this will make the corrections at runtime.
navParent.querySelectorAll('ul').forEach((element) => {
const hasNestedList = element.querySelector('ul');
if (hasNestedList) {
element.setAttribute('role', 'tree');
} else {
element.setAttribute('role', 'group');
}
});
/**
* This function configures the aria-expanded attributes on the navigation items.
* Liquid's limited features make this challenging to do cleanly at build time
* requiring searching forward through the tree to determine if something in the
* depth of the currently rendering tree node has a descendant that is active.
*/
function configureAriaAttributes() {
const setSubTreeAriaExpanded = (listItem, isExpanded) => {
const listItemAnchors = listItem.querySelectorAll('a');
listItemAnchors.forEach((element) => {
const parentLi = element.parentElement;
if (parentLi) {
const childUl = Array.from(parentLi.children).filter(element => element.tagName === 'UL')[0];
if (childUl) {
// If there is a child UL of the anchor element's parent LI then set the aria-expanded
// Otherwise delete the attribute, because there is no child UL to expand / collapse.
element.setAttribute('aria-expanded', isExpanded);
} else {
element.removeAttribute('aria-expanded');
}
}
});
};
const topLevelUls = Array.from(navParent.children).filter(element => element.tagName === 'UL');
topLevelUls.forEach((element) => {
const listItems = Array.from(element.children).filter(element => element.tagName === 'LI');
listItems.forEach((element) => {
const active = element.querySelector('a.active');
if (active) {
setSubTreeAriaExpanded(element, true);
} else {
setSubTreeAriaExpanded(element, false);
}
});
});
} }
});
// Give keyboard focus to the active navigation item, and ensure
window.addEventListener('beforeunload', () => { // it is scrolled into view.
const currentDate = new Date(); const activeNavItem = navParent.querySelector('a.active');
if (activeNavItem) {
// add the scroll value that expires after one day
const scroll = { configureAriaAttributes();
value: siteNav.scrollTop,
expiry: currentDate.getTime() + 24 * 60 * 60 * 1000, // The active navigation item needs to have the tabindex="0" wheras all of the other items
} // are excluded from the TAB order according.
activeNavItem.setAttribute('tabindex', '0');
sessionStorage.setItem(key, JSON.stringify(scroll)); navParent.querySelectorAll('a:not(.active)').forEach((element) => {
element.setAttribute('tabindex', '-1');
});
// If the active item is not in view, then scroll it into view.
const VERSION_WRAPPER_HEIGHT = 80;
const parentRect = navParent.getBoundingClientRect();
const activeRect = activeNavItem.getBoundingClientRect();
if (activeRect.top < (parentRect.top + VERSION_WRAPPER_HEIGHT)) {
const distanceToScroll = activeRect.top - parentRect.top - VERSION_WRAPPER_HEIGHT;
navParent.scrollTo(0, distanceToScroll);
} else if (activeRect.bottom > window.visualViewport.height) {
const distanceToScroll = activeRect.bottom - window.visualViewport.height + VERSION_WRAPPER_HEIGHT;
navParent.scrollTo(0, distanceToScroll);
}
}
navParent.addEventListener('keydown', (event) => {
const handleSpaceKey = () => {
//
// Space key functionality
// For reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/
//
const expandCollapse = (element) => {
event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation();
element.click();
};
if (event.target.classList.contains('nav-list-expander')) {
// If the event target is the expand/collapse arrow then toggle the state of the sub tree.
expandCollapse(event.target);
} else if (event.target.tagName === 'A') {
// If the event target is a link then follow the link.
event.target.click();
}
};
const handleArrowKey = () => {
//
// Arrow key navigation implementation.
// For reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/examples/treeview-navigation/
//
const currentlyFocusedNavItem = navParent.querySelector('a:focus');
if (!currentlyFocusedNavItem) {
// If no item is focused then do nothing.
return;
}
// Preventing the default action prevents jankiness in the default scrolling
// of the navigation panel, and is left to the browser to handle it when
// .focus() is invoked instead.
event.preventDefault();
// Get all of the navigation items that are visible, and that are NOT the expand/collapse arrow.
const allNavItems = Array.from(
navParent.querySelectorAll('a:not(.nav-list-expander)')
).filter(element => element.getBoundingClientRect().height > 0);
const currentlyFocusedNavItemIndex = allNavItems.indexOf(currentlyFocusedNavItem);
const toggleExpandCollapseState = () => {
const parentLi = currentlyFocusedNavItem.parentElement;
if (parentLi) {
const expander = Array.from(parentLi.children).find(element => element.classList.contains('nav-list-expander'));
if (expander) {
expander.click();
Array.from(parentLi.children).forEach((element) => {
const ariaExpanded = element.getAttribute('aria-expanded');
if (ariaExpanded === 'true') {
element.setAttribute('aria-expanded', 'false');
} else {
element.setAttribute('aria-expanded', 'true');
}
});
}
}
};
if (event.key === 'ArrowUp') {
if (currentlyFocusedNavItemIndex > 0) {
allNavItems[currentlyFocusedNavItemIndex - 1].focus();
}
} else if (event.key === 'ArrowDown') {
if (currentlyFocusedNavItemIndex < allNavItems.length - 1) {
allNavItems[currentlyFocusedNavItemIndex + 1].focus();
}
} else if (event.key === 'ArrowLeft') {
if (currentlyFocusedNavItem.getAttribute('aria-expanded') === 'true') {
toggleExpandCollapseState();
} else {
const parentLi = currentlyFocusedNavItem.parentElement;
if (parentLi) {
if (currentlyFocusedNavItemIndex > 0) {
// The parent of the target <a> is a <li> which is a child of a <ul> which is a child of a <li>.
// This <li> should have a <a> that is the parent to focus unless the current is the top.
const listWrapperLi = currentlyFocusedNavItem?.parentElement?.parentElement?.parentElement;
if (listWrapperLi) {
const parentListAnchor = listWrapperLi.querySelectorAll('a:not(.nav-list-expander)')[0];
if (parentListAnchor) {
parentListAnchor.focus();
}
}
}
}
}
} else if (event.key === 'ArrowRight') {
const ariaExpanded = currentlyFocusedNavItem.getAttribute('aria-expanded');
if (ariaExpanded === 'false' || ariaExpanded === null) {
toggleExpandCollapseState();
} else {
const parentLi = currentlyFocusedNavItem.parentElement;
if (parentLi) {
const childList = Array.from(parentLi.children).find(element => element.tagName === 'UL');
if (childList) {
const childListAnchor = childList.querySelectorAll('a:not(.nav-list-expander)')[0];
if (childListAnchor) {
childListAnchor.focus();
}
}
}
}
}
};
switch (event.key) {
case 'ArrowUp':
case 'ArrowDown':
case 'ArrowLeft':
case 'ArrowRight':
handleArrowKey();
break;
case ' ':
handleSpaceKey();
break;
}
});
}); });

View File

@ -45,12 +45,6 @@
e.preventDefault(); e.preventDefault();
navToHighlightedResult(); navToHighlightedResult();
break; break;
case 'Tab':
e.preventDefault();
highlightNextResult(!e.shiftKey);
break;
} }
}); });