Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(TreeView): improved a11y experience #10220

Merged
merged 4 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/react-core/src/components/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export interface TreeViewProps {
* internal state.
*/
allExpanded?: boolean;
/** A text string that sets the accessible name of the tree view list. Either this or the aria-labelledby property must
* be passed in.
*/
'aria-label'?: string;
/** A space separated list of element id's that sets the accessible name of the tree view list. Either
* this or the aria-label property must be passed in.
*/
'aria-labelledby'?: string;
/** Class to add if not passed a parentItem property. */
className?: string;
/** Comparison function for determining active items. */
Expand All @@ -72,6 +80,11 @@ export interface TreeViewProps {
icon?: React.ReactNode;
/** ID of the tree view. */
id?: string;
/** Flag indicating whether multiple nodes can be selected in the tree view. This will also set the
* aria-multiselectable attribute on the tree view list which is required to be true when multiple selection is intended.
* Can only be applied to the root tree view list.
*/
isMultiSelectable?: boolean;
/** Callback for item checkbox selection. */
onCheck?: (event: React.ChangeEvent<HTMLInputElement>, item: TreeViewDataItem, parentItem: TreeViewDataItem) => void;
/** Callback for item selection. */
Expand Down Expand Up @@ -104,6 +117,7 @@ export const TreeView: React.FunctionComponent<TreeViewProps> = ({
defaultAllExpanded = false,
allExpanded,
icon,
isMultiSelectable = false,
expandedIcon,
parentItem,
onSelect,
Expand All @@ -115,10 +129,18 @@ export const TreeView: React.FunctionComponent<TreeViewProps> = ({
compareItems = (item, itemToCheck) => item.id === itemToCheck.id,
className,
useMemo,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
...props
}: TreeViewProps) => {
const treeViewList = (
<TreeViewList isNested={isNested} toolbar={toolbar}>
<TreeViewList
isNested={isNested}
toolbar={toolbar}
isMultiSelectable={isMultiSelectable}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
>
{data.map((item) => (
<TreeViewListItem
key={item.id?.toString() || item.name?.toString()}
Expand Down
25 changes: 24 additions & 1 deletion packages/react-core/src/components/TreeView/TreeViewList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,28 @@ export interface TreeViewListProps extends React.HTMLProps<HTMLUListElement> {
isNested?: boolean;
/** Toolbar to display above the tree view. */
toolbar?: React.ReactNode;
/** Flag indicating whether multiple nodes can be selected in the tree view. This will also set the
* aria-multiselectable attribute on the tree view list which is required to be true when multiple selection is intended.
* Can only be applied to the root tree view list.
*/
isMultiSelectable?: boolean;
/** A text string that sets the accessible name of the tree view list. Either this or the aria-labelledby property must
* be passed in.
*/
'aria-label'?: string;
/** A space separated list of element id's that sets the accessible name of the tree view list. Either
* this or the aria-label property must be passed in.
*/
'aria-labelledby'?: string;
}

export const TreeViewList: React.FunctionComponent<TreeViewListProps> = ({
isNested = false,
isMultiSelectable = false,
toolbar,
children,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
...props
}: TreeViewListProps) => (
<>
Expand All @@ -25,7 +41,14 @@ export const TreeViewList: React.FunctionComponent<TreeViewListProps> = ({
<Divider />
</React.Fragment>
)}
<ul className={css(`${styles.treeView}__list`)} role={isNested ? 'group' : 'tree'} {...props}>
<ul
className={css(`${styles.treeView}__list`)}
role={isNested ? 'group' : 'tree'}
aria-multiselectable={isNested ? undefined : isMultiSelectable}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
{...props}
>
{children}
</ul>
</>
Expand Down
20 changes: 13 additions & 7 deletions packages/react-core/src/components/TreeView/TreeViewListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ const TreeViewListItemBase: React.FunctionComponent<TreeViewListItemProps> = ({
</span>
</ToggleComponent>
);

const isCheckboxChecked = checkProps.checked === null ? false : checkProps.checked;
const renderCheck = (randomId: string) => (
<span className={css(styles.treeViewNodeCheck)}>
<input
Expand All @@ -151,7 +153,7 @@ const TreeViewListItemBase: React.FunctionComponent<TreeViewListItemProps> = ({
onClick={(evt) => evt.stopPropagation()}
ref={(elem) => elem && (elem.indeterminate = checkProps.checked === null)}
{...checkProps}
checked={checkProps.checked === null ? false : checkProps.checked}
checked={isCheckboxChecked}
id={randomId}
tabIndex={-1}
/>
Expand Down Expand Up @@ -194,13 +196,22 @@ const TreeViewListItemBase: React.FunctionComponent<TreeViewListItemProps> = ({
)}
</>
);

const isSelected =
(!children || isSelectable) &&
activeItems &&
activeItems.length > 0 &&
activeItems.some((item) => compareItems && item && compareItems(item, itemData));

return (
<li
id={id}
className={css(styles.treeViewListItem, internalIsExpanded && styles.modifiers.expanded)}
aria-expanded={internalIsExpanded}
role="treeitem"
tabIndex={-1}
{...(hasCheckbox && { 'aria-checked': isCheckboxChecked })}
{...(!hasCheckbox && { 'aria-selected': isSelected })}
>
<div className={css(styles.treeViewContent)}>
<GenerateId prefix={isSelectable ? 'selectable-id' : 'checkbox-id'}>
Expand All @@ -209,12 +220,7 @@ const TreeViewListItemBase: React.FunctionComponent<TreeViewListItemProps> = ({
className={css(
styles.treeViewNode,
children && (isSelectable || hasCheckbox) && styles.modifiers.selectable,
(!children || isSelectable) &&
activeItems &&
activeItems.length > 0 &&
activeItems.some((item) => compareItems && item && compareItems(item, itemData))
? styles.modifiers.current
: ''
isSelected && styles.modifiers.current
)}
onClick={(evt: React.MouseEvent) => {
if (!hasCheckbox) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
exports[`Matches snapshot by default 1`] = `
<DocumentFragment>
<ul
aria-multiselectable="false"
class="pf-v5-c-tree-view__list"
role="tree"
>
Expand All @@ -20,6 +21,7 @@ exports[`Matches snapshot when toolbar is passed 1`] = `
class="pf-v5-c-divider"
/>
<ul
aria-multiselectable="false"
class="pf-v5-c-tree-view__list"
role="tree"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ exports[`Matches snapshot with children 1`] = `
<DocumentFragment>
<li
aria-expanded="false"
aria-selected="false"
class="pf-v5-c-tree-view__list-item"
role="treeitem"
tabindex="-1"
Expand Down Expand Up @@ -56,6 +57,7 @@ exports[`Matches snapshot without children 1`] = `
<DocumentFragment>
<li
aria-expanded="false"
aria-selected="false"
class="pf-v5-c-tree-view__list-item"
role="treeitem"
tabindex="-1"
Expand Down
14 changes: 12 additions & 2 deletions packages/react-core/src/components/TreeView/examples/TreeView.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ import { FolderIcon, FolderOpenIcon, EllipsisVIcon, ClipboardIcon, HamburgerIcon

## Examples

### Default
### Single selectable

```ts file='./TreeViewDefault.tsx'
```ts file='./TreeViewSingleSelectable.tsx'

```

### Multiselectable

A tree view can be setup to allow multiple nodes to be selected. When a tree view is intended to allow multiple selection, the `isMultiSelectable` property must be passed.

```ts file='./TreeViewMultiselectable.tsx'

```

Expand All @@ -25,6 +33,8 @@ The `hasSelectableNodes` modifier will separate the expansion and selection beha

### With search

A search input can be used to filter tree view items. It is recommended that a tree view with more than 7 nodes includes a search input.

```ts file='./TreeViewWithSearch.tsx'

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,5 @@ export const TreeViewCompact: React.FunctionComponent = () => {
]
}
];
return <TreeView data={options} variant="compact" />;
return <TreeView aria-label="Tree View compact example" data={options} variant="compact" />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,5 @@ export const TreeViewCompactNoBackground: React.FunctionComponent = () => {
]
}
];
return <TreeView data={options} variant="compactNoBackground" />;
return <TreeView aria-label="Tree View compact no background example" data={options} variant="compactNoBackground" />;
};
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,5 @@ export const GuidesTreeView: React.FunctionComponent = () => {
children: [{ name: 'Application 5', id: 'example8-App5' }]
}
];
return <TreeView data={options} hasGuides={true} />;
return <TreeView aria-label="Tree View guides example" data={options} hasGuides={true} />;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import React from 'react';
import { TreeView, TreeViewDataItem } from '@patternfly/react-core';

export const TreeViewMultiselectable: React.FunctionComponent = () => {
const [activeItems, setActiveItems] = React.useState<TreeViewDataItem[]>([]);

const onSelect = (_event: React.MouseEvent, treeViewItem: TreeViewDataItem) => {
// Ignore folders for selection
if (treeViewItem && !treeViewItem.children) {
setActiveItems((prevActiveItems) => [...prevActiveItems, treeViewItem]);
}
};

const options = [
{
name: 'Application launcher',
id: 'multiselectExample-AppLaunch',
children: [
{
name: 'Application 1',
id: 'multiselectExample-App1',
children: [
{ name: 'Settings', id: 'multiselectExample-App1Settings' },
{ name: 'Current', id: 'multiselectExample-App1Current' }
]
},
{
name: 'Application 2',
id: 'multiselectExample-App2',
children: [
{ name: 'Settings', id: 'multiselectExample-App2Settings' },
{
name: 'Loader',
id: 'multiselectExample-App2Loader',
children: [
{ name: 'Loading App 1', id: 'multiselectExample-LoadApp1' },
{ name: 'Loading App 2', id: 'multiselectExample-LoadApp2' },
{ name: 'Loading App 3', id: 'multiselectExample-LoadApp3' }
]
}
]
}
],
defaultExpanded: true
},
{
name: 'Cost management',
id: 'multiselectExample-Cost',
children: [
{
name: 'Application 3',
id: 'multiselectExample-App3',
children: [
{ name: 'Settings', id: 'multiselectExample-App3Settings' },
{ name: 'Current', id: 'multiselectExample-App3Current' }
]
}
]
},
{
name: 'Sources',
id: 'multiselectExample-Sources',
children: [
{
name: 'Application 4',
id: 'multiselectExample-App4',
children: [{ name: 'Settings', id: 'multiselectExample-App4Settings' }]
}
]
},
{
name: 'Really really really long folder name that overflows the container it is in',
id: 'multiselectExample-Long',
children: [{ name: 'Application 5', id: 'multiselectExample-App5' }]
}
];
return (
<TreeView
aria-label="Tree View multiselectable example"
isMultiSelectable
data={options}
activeItems={activeItems}
onSelect={onSelect}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,13 @@ export const TreeViewSelectableNodes: React.FunctionComponent = () => {
children: [{ name: 'Application 5', id: 'SelNodesTreeView-App5' }]
}
];
return <TreeView hasSelectableNodes data={options} activeItems={activeItems} onSelect={onSelect} />;
return (
<TreeView
aria-label="Tree View separate selection and expansion example"
hasSelectableNodes
data={options}
activeItems={activeItems}
onSelect={onSelect}
/>
);
};
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import { TreeView, Button, TreeViewDataItem } from '@patternfly/react-core';

export const TreeViewDefault: React.FunctionComponent = () => {
export const TreeViewSingleSelectable: React.FunctionComponent = () => {
const [activeItems, setActiveItems] = React.useState<TreeViewDataItem[]>();
const [allExpanded, setAllExpanded] = React.useState<boolean>();

Expand Down Expand Up @@ -81,7 +81,13 @@ export const TreeViewDefault: React.FunctionComponent = () => {
{allExpanded && 'Collapse all'}
{!allExpanded && 'Expand all'}
</Button>
<TreeView data={options} activeItems={activeItems} onSelect={onSelect} allExpanded={allExpanded} />
<TreeView
aria-label="Tree View single selectable example"
data={options}
activeItems={activeItems}
onSelect={onSelect}
allExpanded={allExpanded}
/>
</React.Fragment>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,12 @@ export const TreeViewWithActionItems: React.FunctionComponent = () => {
children: [{ name: 'Application 5', id: 'example7-App5' }]
}
];
return <TreeView data={options} activeItems={activeItems} onSelect={onSelect} />;
return (
<TreeView
aria-label="Tree View with actions example"
data={options}
activeItems={activeItems}
onSelect={onSelect}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,13 @@ export const TreeViewBadges: React.FunctionComponent = () => {
}
];

return <TreeView data={options} activeItems={activeItems} onSelect={onSelect} hasBadges />;
return (
<TreeView
aria-label="Tree View with badges example"
data={options}
activeItems={activeItems}
onSelect={onSelect}
hasBadges
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -194,5 +194,5 @@ export const TreeViewWithCheckboxes: React.FunctionComponent = () => {
}
};
const mapped = options.map((item) => mapTree(item));
return <TreeView data={mapped} onCheck={onCheck} hasCheckboxes />;
return <TreeView aria-label="Tree View with checkboxes example" data={mapped} onCheck={onCheck} hasCheckboxes />;
};
Loading
Loading