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:
Maikel David Pérez Gómez 2023-03-02 17:26:41 -03:00 committed by GitHub
parent 3cee72119a
commit 7dd379773b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 213 additions and 1 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Add a11y support for the Tree component

View File

@ -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 };
}

View File

@ -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 };
}

View File

@ -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,

View File

@ -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,