@primer/react
Version:
An implementation of GitHub's Primer Design System using React
203 lines (189 loc) • 8.41 kB
JavaScript
import { useFocusZone } from '../hooks/useFocusZone.js';
import { getScrollContainer } from '../utils/scroll.js';
import { FocusKeys } from '@primer/behaviors';
function useRovingTabIndex({
containerRef
}) {
// TODO: Initialize focus to the aria-current item if it exists
useFocusZone({
containerRef,
bindKeys: FocusKeys.ArrowVertical | FocusKeys.ArrowHorizontal | FocusKeys.HomeAndEnd | FocusKeys.Backspace | FocusKeys.PageUpDown,
preventScroll: true,
getNextFocusable: (direction, from, event) => {
var _getNextFocusableElem;
if (!(from instanceof HTMLElement)) return;
return (_getNextFocusableElem = getNextFocusableElement(from, event)) !== null && _getNextFocusableElem !== void 0 ? _getNextFocusableElem : from;
},
focusInStrategy: () => {
var _containerRef$current, _containerRef$current2, _containerRef$current3;
const currentItem = (_containerRef$current = containerRef.current) === null || _containerRef$current === void 0 ? void 0 : _containerRef$current.querySelector('[aria-current]');
const firstItem = (_containerRef$current2 = containerRef.current) === null || _containerRef$current2 === void 0 ? void 0 : _containerRef$current2.querySelector('[role="treeitem"]');
// Focus the aria-current item if it exists
if (currentItem instanceof HTMLElement) {
return currentItem;
}
// Otherwise, focus the activeElement if it's a treeitem
if (document.activeElement instanceof HTMLElement && (_containerRef$current3 = containerRef.current) !== null && _containerRef$current3 !== void 0 && _containerRef$current3.contains(document.activeElement)) {
return document.activeElement;
}
// Otherwise, focus the first treeitem
return firstItem instanceof HTMLElement ? firstItem : undefined;
}
});
}
// DOM utilities used for focus management
function getNextFocusableElement(activeElement, event) {
const elementState = getElementState(activeElement);
// Reference: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/#keyboard-interaction-24
switch (`${elementState} ${event.key}`) {
case 'open ArrowRight':
// Focus first child node
return getFirstChildElement(activeElement);
case 'open ArrowLeft':
// Close node; don't change focus
return;
case 'closed ArrowRight':
// Open node; don't change focus
return;
case 'closed ArrowLeft':
// Focus parent element
return getParentElement(activeElement);
case 'end ArrowRight':
// Do nothing
return;
case 'end ArrowLeft':
// Focus parent element
return getParentElement(activeElement);
}
// ArrowUp, ArrowDown, Home, and End behavior are the same regarless of element state
switch (event.key) {
case 'ArrowUp':
// Focus previous visible element
return getVisibleElement(activeElement, 'previous');
case 'ArrowDown':
// Focus next visible element
return getVisibleElement(activeElement, 'next');
case 'Backspace':
return getParentElement(activeElement);
case 'Home':
// Focus first visible element
return getFirstElement(activeElement);
case 'End':
// Focus last visible element
return getLastElement(activeElement);
case 'PageUp':
return getPreviousPageElement(activeElement);
case 'PageDown':
return getNextPageElement(activeElement);
}
}
function getElementState(element) {
if (element.getAttribute('role') !== 'treeitem') {
throw new Error('Element is not a treeitem');
}
switch (element.getAttribute('aria-expanded')) {
case 'true':
return 'open';
case 'false':
return 'closed';
default:
return 'end';
}
}
function getVisibleElement(element, direction) {
const root = element.closest('[role=tree]');
if (!root) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, node => {
if (!(node instanceof HTMLElement)) return NodeFilter.FILTER_SKIP;
return node.getAttribute('role') === 'treeitem' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
});
let current = walker.firstChild();
while (current !== element) {
current = walker.nextNode();
}
let next = direction === 'next' ? walker.nextNode() : walker.previousNode();
// If next element is nested inside a collapsed subtree, continue iterating
while (next instanceof HTMLElement && (_next$parentElement = next.parentElement) !== null && _next$parentElement !== void 0 && _next$parentElement.closest('[role=treeitem][aria-expanded=false]')) {
var _next$parentElement;
next = direction === 'next' ? walker.nextNode() : walker.previousNode();
}
return next instanceof HTMLElement ? next : undefined;
}
function getFirstChildElement(element) {
const firstChild = element.querySelector('[role=treeitem]');
return firstChild instanceof HTMLElement ? firstChild : undefined;
}
function getParentElement(element) {
const group = element.closest('[role=group]');
const parent = group === null || group === void 0 ? void 0 : group.closest('[role=treeitem]');
return parent instanceof HTMLElement ? parent : undefined;
}
function getFirstElement(element) {
const root = element.closest('[role=tree]');
const first = root === null || root === void 0 ? void 0 : root.querySelector('[role=treeitem]');
return first instanceof HTMLElement ? first : undefined;
}
function getLastElement(element) {
const root = element.closest('[role=tree]');
const items = Array.from((root === null || root === void 0 ? void 0 : root.querySelectorAll('[role=treeitem]')) || []);
// If there are no items, return undefined
if (items.length === 0) return;
let index = items.length - 1;
let last = items[index];
// If last element is nested inside a collapsed subtree, continue iterating
while (index > 0 && last instanceof HTMLElement && (_last$parentElement = last.parentElement) !== null && _last$parentElement !== void 0 && _last$parentElement.closest('[role=treeitem][aria-expanded=false]')) {
var _last$parentElement;
index -= 1;
last = items[index];
}
return last instanceof HTMLElement ? last : undefined;
}
const defaultSize = {
height: 32
};
/**
* Determine the page size for the given tree based on an item in the tree. We
* estimate this size by trying to see how many items will fit in the given
* tree. If the tree is within a scroll container, we will use the height of
* that container. Otherwise, we'll use the current window height
*/
function getPageSize(root, item) {
var _item$getBoundingClie, _scrollContainer$clie;
const scrollContainer = getScrollContainer(root);
const {
height: itemHeight
} = (_item$getBoundingClie = item === null || item === void 0 ? void 0 : item.getBoundingClientRect()) !== null && _item$getBoundingClie !== void 0 ? _item$getBoundingClie : defaultSize;
const availableHeight = (_scrollContainer$clie = scrollContainer === null || scrollContainer === void 0 ? void 0 : scrollContainer.clientHeight) !== null && _scrollContainer$clie !== void 0 ? _scrollContainer$clie : window.innerHeight;
return Math.floor(availableHeight / itemHeight);
}
function getNextPageElement(element) {
const root = element.closest('[role="tree"]');
if (!root) {
return;
}
const items = Array.from(root.querySelectorAll('[role="treeitem"]'));
if (items.length === 0) {
return;
}
const itemLabel = items[0].firstElementChild;
const pageSize = getPageSize(root, itemLabel);
const page = Math.floor(items.indexOf(element) / pageSize);
const offset = items.indexOf(element) - pageSize * page;
return items[Math.min(items.length - 1, (page + 1) * pageSize + offset)];
}
function getPreviousPageElement(element) {
const root = element.closest('[role="tree"]');
if (!root) {
return;
}
const items = Array.from(root.querySelectorAll('[role="treeitem"]'));
if (items.length === 0) {
return;
}
const itemLabel = items[0].firstElementChild;
const pageSize = getPageSize(root, itemLabel);
const page = Math.floor(items.indexOf(element) / pageSize);
const offset = items.indexOf(element) - pageSize * page;
return items[Math.max(0, (page - 1) * pageSize + offset)];
}
export { getElementState, getFirstChildElement, getFirstElement, getLastElement, getNextFocusableElement, getParentElement, getVisibleElement, useRovingTabIndex };