Add a11y support for the tree-control (#36459)
* Add tree-control expand/collapse on click the expander button or by a custom logic * Upgrade WP components dependency to v19.8.5 to support indeterminate checkbox control * Add styles to fit the disign * Add tree-control expand/collapse on click the expander button or by a custom logic * Add a11y landmarks * Add keyboard interactions * Add changelog file * Fix linter errors * Resolving rebase conflicts * Add comment suggestions
This commit is contained in:
parent
3cee72119a
commit
7dd379773b
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: dev
|
||||
|
||||
Add a11y support for the Tree component
|
|
@ -23,9 +23,17 @@ export function useExpander( {
|
|||
}
|
||||
}, [ item, shouldItemBeExpanded ] );
|
||||
|
||||
function onExpand() {
|
||||
setExpanded( true );
|
||||
}
|
||||
|
||||
function onCollapse() {
|
||||
setExpanded( false );
|
||||
}
|
||||
|
||||
function onToggleExpand() {
|
||||
setExpanded( ( prev ) => ! prev );
|
||||
}
|
||||
|
||||
return { isExpanded, onToggleExpand };
|
||||
return { isExpanded, onExpand, onCollapse, onToggleExpand };
|
||||
}
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { LinkedTree } from '../types';
|
||||
|
||||
function getFirstChild(
|
||||
currentHeading: HTMLDivElement
|
||||
): HTMLLabelElement | null {
|
||||
const parentTreeItem = currentHeading?.closest< HTMLDivElement >(
|
||||
'.experimental-woocommerce-tree-item'
|
||||
);
|
||||
const firstSubTreeItem = parentTreeItem?.querySelector(
|
||||
'.experimental-woocommerce-tree > .experimental-woocommerce-tree-item'
|
||||
);
|
||||
const label = firstSubTreeItem?.querySelector< HTMLLabelElement >(
|
||||
'.experimental-woocommerce-tree-item__heading > .experimental-woocommerce-tree-item__label'
|
||||
);
|
||||
return label ?? null;
|
||||
}
|
||||
|
||||
function getFirstAncestor(
|
||||
currentHeading: HTMLDivElement
|
||||
): HTMLLabelElement | null {
|
||||
const parentTree = currentHeading?.closest< HTMLDivElement >(
|
||||
'.experimental-woocommerce-tree'
|
||||
);
|
||||
const grandParentTreeItem = parentTree?.closest< HTMLDivElement >(
|
||||
'.experimental-woocommerce-tree-item'
|
||||
);
|
||||
const label = grandParentTreeItem?.querySelector< HTMLLabelElement >(
|
||||
'.experimental-woocommerce-tree-item__heading > .experimental-woocommerce-tree-item__label'
|
||||
);
|
||||
return label ?? null;
|
||||
}
|
||||
|
||||
function getAllHeadings(
|
||||
currentHeading: HTMLDivElement
|
||||
): NodeListOf< HTMLDivElement > | undefined {
|
||||
const rootTree = currentHeading.closest< HTMLDivElement >(
|
||||
'.experimental-woocommerce-tree--level-1'
|
||||
);
|
||||
return rootTree?.querySelectorAll< HTMLDivElement >(
|
||||
'.experimental-woocommerce-tree-item > .experimental-woocommerce-tree-item__heading'
|
||||
);
|
||||
}
|
||||
|
||||
const step = {
|
||||
ArrowDown: 1,
|
||||
ArrowUp: -1,
|
||||
};
|
||||
|
||||
function getNextFocusableElement(
|
||||
currentHeading: HTMLDivElement,
|
||||
code: 'ArrowDown' | 'ArrowUp'
|
||||
): HTMLLabelElement | null {
|
||||
const headingsNodeList = getAllHeadings( currentHeading );
|
||||
if ( ! headingsNodeList ) return null;
|
||||
|
||||
let currentHeadingIndex = 0;
|
||||
for ( const heading of headingsNodeList.values() ) {
|
||||
if ( heading === currentHeading ) break;
|
||||
currentHeadingIndex++;
|
||||
}
|
||||
if (
|
||||
currentHeadingIndex < 0 ||
|
||||
currentHeadingIndex >= headingsNodeList.length
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const heading = headingsNodeList.item(
|
||||
currentHeadingIndex + ( step[ code ] ?? 0 )
|
||||
);
|
||||
return heading?.querySelector< HTMLLabelElement >(
|
||||
'.experimental-woocommerce-tree-item__label'
|
||||
);
|
||||
}
|
||||
|
||||
function getFirstFocusableElement(
|
||||
currentHeading: HTMLDivElement
|
||||
): HTMLLabelElement | null {
|
||||
const headingsNodeList = getAllHeadings( currentHeading );
|
||||
if ( ! headingsNodeList ) return null;
|
||||
return headingsNodeList
|
||||
.item( 0 )
|
||||
.querySelector< HTMLLabelElement >(
|
||||
'.experimental-woocommerce-tree-item__label'
|
||||
);
|
||||
}
|
||||
|
||||
function getLastFocusableElement(
|
||||
currentHeading: HTMLDivElement
|
||||
): HTMLLabelElement | null {
|
||||
const headingsNodeList = getAllHeadings( currentHeading );
|
||||
if ( ! headingsNodeList ) return null;
|
||||
return headingsNodeList
|
||||
.item( headingsNodeList.length - 1 )
|
||||
.querySelector< HTMLLabelElement >(
|
||||
'.experimental-woocommerce-tree-item__label'
|
||||
);
|
||||
}
|
||||
|
||||
export function useKeyboard( {
|
||||
item,
|
||||
isExpanded,
|
||||
onExpand,
|
||||
onCollapse,
|
||||
onToggleExpand,
|
||||
}: {
|
||||
item: LinkedTree;
|
||||
isExpanded: boolean;
|
||||
onExpand(): void;
|
||||
onCollapse(): void;
|
||||
onToggleExpand(): void;
|
||||
} ) {
|
||||
function onKeyDown( event: React.KeyboardEvent< HTMLDivElement > ) {
|
||||
if ( event.code === 'ArrowRight' ) {
|
||||
event.preventDefault();
|
||||
if ( item.children.length > 0 ) {
|
||||
if ( isExpanded ) {
|
||||
const element = getFirstChild( event.currentTarget );
|
||||
return element?.focus();
|
||||
}
|
||||
onExpand();
|
||||
}
|
||||
}
|
||||
|
||||
if ( event.code === 'ArrowLeft' ) {
|
||||
event.preventDefault();
|
||||
if ( ! isExpanded && item.parent ) {
|
||||
const element = getFirstAncestor( event.currentTarget );
|
||||
return element?.focus();
|
||||
}
|
||||
if ( item.children.length > 0 ) {
|
||||
onCollapse();
|
||||
}
|
||||
}
|
||||
|
||||
if ( event.code === 'Enter' ) {
|
||||
event.preventDefault();
|
||||
if ( item.children.length > 0 ) {
|
||||
onToggleExpand();
|
||||
}
|
||||
}
|
||||
|
||||
if ( event.code === 'ArrowDown' || event.code === 'ArrowUp' ) {
|
||||
event.preventDefault();
|
||||
const element = getNextFocusableElement(
|
||||
event.currentTarget,
|
||||
event.code
|
||||
);
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
if ( event.code === 'Home' ) {
|
||||
event.preventDefault();
|
||||
const element = getFirstFocusableElement( event.currentTarget );
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
if ( event.code === 'End' ) {
|
||||
event.preventDefault();
|
||||
const element = getLastFocusableElement( event.currentTarget );
|
||||
element?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return { onKeyDown };
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useInstanceId } from '@wordpress/compose';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -9,6 +10,7 @@ import React from 'react';
|
|||
import { TreeItemProps } from '../types';
|
||||
import { useExpander } from './use-expander';
|
||||
import { useHighlighter } from './use-highlighter';
|
||||
import { useKeyboard } from './use-keyboard';
|
||||
import { useSelection } from './use-selection';
|
||||
|
||||
export function useTreeItem( {
|
||||
|
@ -48,6 +50,15 @@ export function useTreeItem( {
|
|||
shouldItemBeHighlighted,
|
||||
} );
|
||||
|
||||
const subTreeId = `experimental-woocommerce-tree__group-${ useInstanceId(
|
||||
useTreeItem
|
||||
) }`;
|
||||
|
||||
const { onKeyDown } = useKeyboard( {
|
||||
...expander,
|
||||
item,
|
||||
} );
|
||||
|
||||
return {
|
||||
item,
|
||||
level: nextLevel,
|
||||
|
@ -57,17 +68,31 @@ export function useTreeItem( {
|
|||
getLabel,
|
||||
treeItemProps: {
|
||||
...props,
|
||||
role: 'none',
|
||||
},
|
||||
headingProps: {
|
||||
role: 'treeitem',
|
||||
'aria-selected': selection.checkedStatus !== 'unchecked',
|
||||
'aria-expanded': item.children.length
|
||||
? expander.isExpanded
|
||||
: undefined,
|
||||
'aria-owns':
|
||||
item.children.length && expander.isExpanded
|
||||
? subTreeId
|
||||
: undefined,
|
||||
style: {
|
||||
'--level': level,
|
||||
} as React.CSSProperties,
|
||||
onKeyDown,
|
||||
},
|
||||
treeProps: {
|
||||
id: subTreeId,
|
||||
items: item.children,
|
||||
level: nextLevel,
|
||||
multiple: selection.multiple,
|
||||
selected: selection.selected,
|
||||
role: 'group',
|
||||
'aria-label': item.data.label,
|
||||
getItemLabel: getLabel,
|
||||
shouldItemBeExpanded,
|
||||
shouldItemBeHighlighted,
|
||||
|
|
|
@ -11,6 +11,7 @@ export function useTree( {
|
|||
ref,
|
||||
items,
|
||||
level = 1,
|
||||
role = 'tree',
|
||||
multiple,
|
||||
selected,
|
||||
getItemLabel,
|
||||
|
@ -25,6 +26,7 @@ export function useTree( {
|
|||
items,
|
||||
treeProps: {
|
||||
...props,
|
||||
role,
|
||||
},
|
||||
treeItemProps: {
|
||||
level,
|
||||
|
|
Loading…
Reference in New Issue