Skip to content

Commit

Permalink
feat: tree component
Browse files Browse the repository at this point in the history
  • Loading branch information
maxinteger committed Aug 22, 2024
1 parent b97cee6 commit b51b782
Show file tree
Hide file tree
Showing 34 changed files with 4,383 additions and 140 deletions.
8 changes: 3 additions & 5 deletions src/components/ListItemBase/ListItemBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,11 @@ import { useOverlay } from '@react-aria/overlays';
import { useListContext } from '../List/List.utils';
import ButtonSimple from '../ButtonSimple';
import Text from '../Text';
import {
getKeyboardFocusableElements,
getListItemBaseTabIndex,
useDidUpdateEffect,
} from './ListItemBase.utils';
import { getListItemBaseTabIndex } from './ListItemBase.utils';
import { useMutationObservable } from '../../hooks/useMutationObservable';
import { usePrevious } from '../../hooks/usePrevious';
import { getKeyboardFocusableElements } from '../../utils/navigation';
import { useDidUpdateEffect } from '../../hooks/useDidUpdateEffect';

type RefOrCallbackRef = RefObject<HTMLLIElement> | ((instance: HTMLLIElement) => void);

Expand Down
6 changes: 0 additions & 6 deletions src/components/ListItemBase/ListItemBase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,6 @@ export interface Props extends PressEvents, ContextMenu {
*/
role?: string;

/**
* Determines wether the focus for this item should be inset or outset
* @default false
*/
shouldItemFocusBeInset?: boolean;

/**
* Indicates wether this item is currently focusable
*/
Expand Down
81 changes: 1 addition & 80 deletions src/components/ListItemBase/ListItemBase.utils.test.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,4 @@
import { renderHook } from '@testing-library/react-hooks';
import {
getKeyboardFocusableElements,
getListItemBaseTabIndex,
useDidUpdateEffect,
} from './ListItemBase.utils';

describe('getKeyboardFocusableElements', () => {
const createRootNodeRef = (content = '') => {
const root = document.createElement('div');
root.innerHTML = content;
return root;
};

it('should return with empty array when no child of the root node', () => {
expect(getKeyboardFocusableElements(createRootNodeRef())).toEqual([]);
});

it('should return with focusable tags for only elements which can be focused with tab', () => {
const ids = getKeyboardFocusableElements(
createRootNodeRef(`
<a id='1'/>
<a href="#" id='2'/>
<button id='3'/>
<input id='4'/>
<textarea id='5'></textarea>
<select id='6'></select>
<details id='7'></details>
<div tabindex='0' id='8'>
<div tabindex='-1' id='9'>
<div id='10'></div>
`)
).map((n) => n.id);

expect(ids).toEqual(['2', '3', '4', '5', '6', '7', '8']);
});

it('should return with focusable tags for any elements which can be focused', () => {
const ids = getKeyboardFocusableElements(
createRootNodeRef(`
<a id='1'/>
<a href="#" id='2'/>
<button id='3'/>
<input id='4'/>
<textarea id='5'></textarea>
<select id='6'></select>
<details id='7'></details>
<div tabindex='0' id='8'>
<div tabindex='-1' id='9'>
<div id='10'></div>
`),
false
).map((n) => n.id);

expect(ids).toEqual(['2', '3', '4', '5', '6', '7', '8', '9']);
});
it('should return filter out disabled and aria hidden nodes', () => {
const ids = getKeyboardFocusableElements(
createRootNodeRef(`
<button disabled id='1' />
<button aria-hidden='true' id='2' />
<button aria-hidden='false' id='3'/>
`)
).map((n) => n.id);

expect(ids).toEqual(['3']);
});
});

describe('useDidUpdateEffect', () => {
it('does not run the effect on initial render', async () => {
const effect = jest.fn();
const { rerender } = renderHook(({ input, effect }) => useDidUpdateEffect(effect, input), {
initialProps: { effect, input: ['one'] },
});
expect(effect).not.toBeCalled();
rerender({ effect, input: ['two'] });
expect(effect).toBeCalled();
});
});
import { getListItemBaseTabIndex } from './ListItemBase.utils';

describe('getListItemBaseTabIndex', () => {
const listContextWellDefined = { currentFocus: 1 };
Expand Down
36 changes: 0 additions & 36 deletions src/components/ListItemBase/ListItemBase.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,5 @@
import { DependencyList, EffectCallback, useEffect, useRef } from 'react';
import { ListContextValue } from '../List/List.types';

/**
* Returns all focusable child elements as an Element Array
* @param root - root node to search in
* @param tabOnly - whether only tabbable children should be returned or all
* children that can be focused. Element with 0 tabindex can be tabbed to,
* while elements with any tabindex value can be manually focused
*/
export function getKeyboardFocusableElements<T extends HTMLElement>(
root: T,
tabOnly = true
): Element[] {
const focusableNodes = 'a[href], button, input, textarea, select, details,'.concat(
tabOnly ? '[tabindex]:not([tabindex="-1"]' : '[tabindex]:not([tabindex=""]'
);

return Array.from(root.querySelectorAll(focusableNodes)).filter(
(el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true'
);
}
/**
* Same as useEffect but ignores first render.
* @param fn - use effect callback
* @param inputs dependencies
*/
export const useDidUpdateEffect = (fn: EffectCallback, inputs: DependencyList): void => {
const didMountRef = useRef(false);

useEffect(() => {
if (didMountRef.current) {
return fn();
}
didMountRef.current = true;
}, inputs);
};

/**
* Returns the intended tabIndex for the ListItemBase
*/
Expand Down
12 changes: 12 additions & 0 deletions src/components/Tree/Tree.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const CLASS_PREFIX = 'md-tree';

const DEFAULTS = {
IS_RENDERED_FLAT: true,
EXCLUDE_TREE_ROOT: true,
};

const STYLE = {
wrapper: `${CLASS_PREFIX}-wrapper`,
};

export { CLASS_PREFIX, DEFAULTS, STYLE };
30 changes: 30 additions & 0 deletions src/components/Tree/Tree.stories.args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { commonStyles } from '../../storybook/helper.stories.argtypes';

export default {
...commonStyles,
shouldNodeFocusBeInset: {
description:
'Determines whether the focus around list-items should be inset or outset. This is needed for virtualized lists',
control: { type: 'boolean' },
table: {
type: {
summary: 'boolean',
},
defaultValue: {
summary: 'false',
},
},
},
excludeTreeRoot: {
description: 'Determines if the tree root should be excluded from the tree navigation.',
control: { type: 'boolean' },
table: {
type: {
summary: 'boolean',
},
defaultValue: {
summary: 'true',
},
},
},
};
133 changes: 133 additions & 0 deletions src/components/Tree/Tree.stories.docs.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
The `<Tree />` component implements the basic tree navigation based on the WAI-ARIA specification,
see [WCAG Tree Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/) for more details.

`<Tree />` does not render the tree nodes automatically. It makes it more flexible to customize the tree nodes and
make it possible to use it with Virtualized trees.

Tree nodes must be wrapped in a `<TreeNodeBase />` component to ensure proper keyboard navigation and focus management.

Any node inside the `<Tree />` can access to the tree context using the `useTreeContext` hook.

## Nested VS Flat tree

There is 2 ways to represent the tree structure in the DOM:

### 1) Nested elements

Simple static trees usually represented as nested elements. It's easy to understand and implement and
most of the semantics are implicitly set. But hard to render it automatically especially in virtualized trees.

```html
<ul role="tree" aria-labelledby="tree_label">
<li role="treeitem" aria-expanded="false" aria-selected="false">
<span> Level 1 </span>
<ul role="group">
<li role="treeitem" aria-selected="false">Level 2.1</li>
<li role="treeitem" aria-selected="false">Level 2.2</li>
<li role="treeitem" aria-selected="false">Level 2.3</li>
<li role="treeitem" aria-expanded="false" aria-selected="false">
<span> Level 2.4 </span>
<ul role="group">
<li role="treeitem" aria-selected="false">Level 3.1</li>
<li role="treeitem" aria-selected="false">Level 3.2</li>
</ul>
</li>
<li role="treeitem" aria-selected="false">Level 2.5</li>
</ul>
</li>
</ul>

```

### 2) Flat elements

Flat tree structure is more flexible and can be used with virtualized trees. It requires more explicit semantics.

```html
<h3 id="tree_label_2">Tree with flat DOM</h3>
<ul role="tree" aria-labelledby="tree_label_2">
<li
role="treeitem"
aria-level="1"
aria-expanded="true"
aria-selected="false"
class="level-1"
>
<span> Level 1 </span>
<div role="group" aria-owns="level-2.1 level-2.2 level-2.3 level-2.4 level-2.5"></div>
</li>
<li
id="level-2.1"
aria-setsize="5"
aria-posinset="1"
role="treeitem"
aria-level="2"
class="level-2"
>
<span> Level 2.1</span>
</li>
<li
id="level-2.2"
aria-setsize="5"
aria-posinset="2"
role="treeitem"
aria-level="2"
class="level-2"
>
<span> Level 2.2</span>
</li>
<li
id="level-2.3"
aria-setsize="5"
aria-posinset="3"
role="treeitem"
aria-level="2"
class="level-2"
>
<span> Level 2.3</span>
</li>
<li
id="level-2.4"
aria-setsize="5"
aria-posinset="4"
role="treeitem"
aria-level="2"
class="level-2"
aria-expanded="true"

>
Level 2.4
<div role="group" aria-owns="level-3.1 level-3.2"></div>
</li>
<li
id="level-3.1"
aria-setsize="2"
aria-posinset="1"
role="treeitem"
aria-level="3"
class="level-3"
>
<span> Level 3.1</span>
</li>
<li
id="level-3.2"
aria-setsize="2"
aria-posinset="2"
role="treeitem"
aria-level="3"
class="level-3"
>
<span> Level 3.2</span>
</li>
<li
id="level-2.5"
aria-setsize="5"
aria-posinset="5"
role="treeitem"
aria-level="2"
class="level-2"
>
<span> Level 2.5</span>
</li>
</ul>
```
Loading

0 comments on commit b51b782

Please sign in to comment.