-
-
-
-
-
+
+
+
+ two
+
+
+
+
+
+ three
+
+
+
+
+
+
+
+
+
+
@@ -2252,10 +2388,13 @@ exports[` snapshot should match snapshot with secondLine 1`] =
shape="isPilled"
size={50}
>
-
+
snapshot should match snapshot with secondLine 1`] =
role="treeitem"
tabIndex={0}
>
-
-
-
-
-
-
+
+
+
-
-
-
-
-
+
+
+
-
- secondLine
-
-
-
-
-
-
-
-
-
+
+ secondLine
+
+
+
+
+
+
+
+
+
+
@@ -2401,10 +2545,13 @@ exports[` snapshot should match snapshot with style 1`] = `
}
}
>
-
+
snapshot should match snapshot with style 1`] = `
}
tabIndex={0}
>
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+ type="body-primary"
+ >
+
+
+
+
+
+
+
+
+
@@ -2516,10 +2668,13 @@ exports[` snapshot should match snapshot with teamColor 1`] = `
shape="isPilled"
size={50}
>
-
+
snapshot should match snapshot with teamColor 1`] = `
role="treeitem"
tabIndex={0}
>
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
+ type="body-primary"
+ >
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Tree/Tree.constants.ts b/src/components/Tree/Tree.constants.ts
index 7b1024c90..68dbb7550 100644
--- a/src/components/Tree/Tree.constants.ts
+++ b/src/components/Tree/Tree.constants.ts
@@ -7,6 +7,7 @@ const DEFAULTS = {
SELECTABLE_NODES: 'leafOnly' as const,
IS_REQUIRED: false,
NODE_ID_PREFIX: 'md-tree-node',
+ SHOULD_NODE_FOCUS_BE_INSET: true,
};
const STYLE = {
diff --git a/src/components/Tree/Tree.hooks.tsx b/src/components/Tree/Tree.hooks.tsx
index c6bae687e..41a5f484b 100644
--- a/src/components/Tree/Tree.hooks.tsx
+++ b/src/components/Tree/Tree.hooks.tsx
@@ -9,13 +9,18 @@ import { NODE_ID_ATTRIBUTE_NAME, NODE_ID_DATA_NAME } from '../TreeNodeBase/TreeN
/**
* Handle DOM changes for virtual tree
*
+ * This hook manage 2 use cases:
+ * 1) When Virtual Tree removes active node, the hook adds a cloned node to not lose focus.
+ * 2) When the active node added back to the DOM, the hook invoke focus on the element and trigger
+ * key event if it happened on the cloned element.
+ *
* @param props
* @internal
*/
export const useVirtualTreeNavigation = ({
virtualTreeConnector,
treeRef,
- activeNodeId,
+ activeNodeIdRef,
}: UseVirtualTreeNavigationProps): void => {
// Handle DOM changes for virtual tree
useEffect(() => {
@@ -36,12 +41,13 @@ export const useVirtualTreeNavigation = ({
);
// Filter element by active node id
- const getByNodeId = (node: HTMLElement) => node.dataset[NODE_ID_DATA_NAME] === activeNodeId;
+ const getByNodeId = (node: HTMLElement) =>
+ node.dataset[NODE_ID_DATA_NAME] === activeNodeIdRef.current;
// Mutation observer to handle the focus change
const mutationHandler: MutationCallback = (mutationList) => {
for (const mutation of mutationList) {
- // Handle removed active node
+ // Active node moved out of view and removed
const removed = Array.from(mutation.removedNodes).find(getByNodeId) as HTMLElement;
if (removed) {
// Clone the active node and make some modifications
@@ -58,11 +64,11 @@ export const useVirtualTreeNavigation = ({
// add focus and keydown event listeners
clonedNode.addEventListener('focus', () => {
- virtualTreeConnector.scrollToNode(activeNodeId);
+ virtualTreeConnector.scrollToNode(activeNodeIdRef.current);
});
clonedNode.addEventListener('keydown', (evt) => {
if (TREE_NAVIGATION_KEYS.includes(evt.key) || (evt.key === 'Tab' && !evt.shiftKey)) {
- virtualTreeConnector.scrollToNode(activeNodeId);
+ virtualTreeConnector.scrollToNode(activeNodeIdRef.current);
keyDownEvent = new KeyboardEvent('keydown', evt);
evt.stopPropagation();
evt.preventDefault();
@@ -71,7 +77,7 @@ export const useVirtualTreeNavigation = ({
return;
}
- // Handle adding back the focused node
+ // Active node moved back into the view and it added back to the DOM
const added = Array.from(mutation.addedNodes).find(getByNodeId) as HTMLElement;
if (added) {
cleanUp();
@@ -103,5 +109,5 @@ export const useVirtualTreeNavigation = ({
observer.disconnect();
cleanUp();
};
- }, [virtualTreeConnector, activeNodeId, treeRef.current]);
+ }, [virtualTreeConnector, treeRef.current]);
};
diff --git a/src/components/Tree/Tree.test.tsx b/src/components/Tree/Tree.test.tsx
index e7686b007..7a51bb86d 100644
--- a/src/components/Tree/Tree.test.tsx
+++ b/src/components/Tree/Tree.test.tsx
@@ -485,9 +485,9 @@ describe('', () => {
node.remove();
await waitFor(() => {
- const clonedNode = getByText('1');
+ const clonedNode = getByTestId('1');
expect(clonedNode).toHaveFocus();
- // get a dedicated clas name
+ // get a dedicated class name
expect(clonedNode).toHaveClass(TREE_CONSTANTS.STYLE.clonedVirtualTreeNode);
// nodeid unsetted
expect(clonedNode.dataset.nodeid).toBe(undefined);
@@ -533,7 +533,7 @@ describe('', () => {
expect(scrollToNode).toHaveBeenNthCalledWith(1, '1');
});
- it('should not call scrollToNode when cloned node exists and user press Shift+Tab', async () => {
+ it('should call scrollToNode when the focus moves back to the tree and the active node is not in the DOM', async () => {
expect.assertions(2);
const scrollToNode = jest.fn();
const { getByText } = await renderTreeAndRemoveNode({
@@ -548,6 +548,30 @@ describe('', () => {
expect(scrollToNode).toHaveBeenCalledTimes(0);
});
+ it.each`
+ keyPressed
+ ${'ArrowUp'}
+ ${'ArrowDown'}
+ ${'ArrowLeft'}
+ ${'ArrowRight'}
+ `(
+ 'should call scrollToNode when active node is not in the DOM and $kepPressed key pressed',
+ async ({ keyPressed }) => {
+ expect.assertions(2);
+ const scrollToNode = jest.fn();
+ const { getByText } = await renderTreeAndRemoveNode({
+ scrollToNode,
+ setNodeOpen: jest.fn(),
+ });
+
+ await userEvent.keyboard(`{${keyPressed}}`);
+
+ // Only adding back Node 1 will remove the cloned node
+ expect(getByText('1')).not.toBeNull();
+ expect(scrollToNode).toHaveBeenCalledTimes(1);
+ }
+ );
+
it('should remove the cloned node when the real node added back', async () => {
expect.assertions(5);
const scrollToNode = jest.fn();
diff --git a/src/components/Tree/Tree.tsx b/src/components/Tree/Tree.tsx
index 8e1586e7c..436185d1f 100644
--- a/src/components/Tree/Tree.tsx
+++ b/src/components/Tree/Tree.tsx
@@ -14,11 +14,11 @@ import { TreeIdNodeMap, Props, TreeContextValue, TreeNodeId, TreeRefObject } fro
import './Tree.style.scss';
import {
convertNestedTree2MappedTree,
+ isActiveNodeInDOM,
getFistActiveNode,
getNextActiveNode,
getNodeDOMId,
getTreeRootId,
- isActiveNodeInDOM,
migrateTreeState,
toggleTreeNodeRecord,
TreeContext,
@@ -35,7 +35,7 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
id,
style,
children,
- shouldNodeFocusBeInset,
+ shouldNodeFocusBeInset = DEFAULTS.SHOULD_NODE_FOCUS_BE_INSET,
treeStructure,
isRenderedFlat = DEFAULTS.IS_RENDERED_FLAT,
excludeTreeRoot = DEFAULTS.EXCLUDE_TREE_ROOT,
@@ -60,10 +60,11 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
isRequired,
});
const [tree, setTree] = useState(convertNestedTree2MappedTree(treeStructure));
- const [activeNodeId, setActiveNodeId] = useState(
+ const [activeNode, setActiveNode] = useState(
getFistActiveNode(tree, excludeTreeRoot)
);
const [isFocusWithin, setIsFocusWithin] = useState(false);
+ const activeNodeIdRef = useRef(activeNode);
const previousTree = usePrevious(tree);
@@ -72,7 +73,7 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
migrateTreeState(previousTree, newTree);
setTree(newTree);
// Find the closest node to the last active node in the new tree
- let newActiveNodeId = activeNodeId;
+ let newActiveNodeId = activeNode;
while (newActiveNodeId) {
if (newTree.has(newActiveNodeId) && !newTree.get(newActiveNodeId).isHidden) break;
newActiveNodeId = previousTree.get(newActiveNodeId)?.parent;
@@ -86,17 +87,35 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
const isVirtualTree = virtualTreeConnector !== undefined;
+ const scrollToVTreeNode = useCallback(
+ (nodeId: TreeNodeId) => {
+ if (isVirtualTree && virtualTreeConnector && !isActiveNodeInDOM(treeRef, nodeId)) {
+ virtualTreeConnector.scrollToNode?.(nodeId);
+ }
+ },
+ [isVirtualTree, virtualTreeConnector]
+ );
+
// Handle DOM changes for virtual tree
- useVirtualTreeNavigation({ virtualTreeConnector, treeRef: treeRef, activeNodeId });
+ useVirtualTreeNavigation({ virtualTreeConnector, treeRef: treeRef, activeNodeIdRef });
+
+ const setActiveNodeId = useCallback(
+ (newNodeId) => {
+ if (activeNodeIdRef.current !== newNodeId) {
+ activeNodeIdRef.current = newNodeId;
+ setActiveNode(newNodeId);
+ scrollToVTreeNode(newNodeId);
+ }
+ },
+ [isVirtualTree, setActiveNode]
+ );
const toggleTreeNode = useCallback(
async (id: TreeNodeId, isOpen?: boolean): Promise => {
const newOpenState = isOpen !== undefined ? isOpen : !tree.get(id).isOpen;
if (isVirtualTree) {
- if (!isActiveNodeInDOM(treeRef, activeNodeId)) {
- virtualTreeConnector.scrollToNode?.(id);
- }
+ scrollToVTreeNode(id);
await virtualTreeConnector.setNodeOpen?.(id, newOpenState);
}
@@ -115,10 +134,10 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
const parent = node.parent && tree.get(node.parent);
const isRoot = parent === undefined;
-
// tabindex depends on the treeNodeBase params as well
const nodeProps = {
id: getNodeDOMId(node.id),
+
'aria-setsize': isRoot ? 1 : parent.children.length,
'aria-level': node.level + (excludeTreeRoot ? 0 : 1),
'aria-posinset': node.index + 1,
@@ -132,16 +151,16 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
if (!node.isLeaf) {
nodeProps['aria-expanded'] = (!!node.isOpen).toString();
}
+
+ const contentId = `${getNodeDOMId(node.id)}-content`;
+ const nodeContentProps = { id: contentId };
const groupProps = {
role: 'group',
'aria-owns': node.children.map(getNodeDOMId).join(' '),
- 'aria-labelledby': getNodeDOMId(node.id),
+ 'aria-labelledby': contentId,
};
- return {
- nodeProps,
- groupProps,
- };
+ return { nodeProps, nodeContentProps, groupProps };
},
[tree]
);
@@ -152,15 +171,15 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
getNodeDetails,
isRenderedFlat,
shouldNodeFocusBeInset,
- activeNodeId,
+ activeNodeId: activeNode,
setActiveNodeId,
toggleTreeNode,
selectableNodes,
itemSelection,
- isFocusWithin: isFocusWithin,
+ isFocusWithin,
}),
[
- activeNodeId,
+ activeNode,
getNodeAriaProps,
getNodeDetails,
isRenderedFlat,
@@ -186,7 +205,12 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
);
const { focusWithinProps } = useFocusWithin({
- onFocusWithinChange: setIsFocusWithin,
+ onFocusWithinChange: (val) => {
+ setIsFocusWithin(val);
+ if (val) {
+ scrollToVTreeNode(activeNode);
+ }
+ },
});
const { keyboardProps } = useKeyboard({
@@ -198,14 +222,8 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
case 'ArrowRight':
case 'ArrowLeft': {
evt.preventDefault();
- if (activeNodeId) {
- const next = getNextActiveNode(
- tree,
- activeNodeId,
- key,
- excludeTreeRoot,
- toggleTreeNode
- );
+ if (activeNode) {
+ const next = getNextActiveNode(tree, activeNode, key, excludeTreeRoot, toggleTreeNode);
setActiveNodeId(next);
}
break;
@@ -213,11 +231,11 @@ const Tree = forwardRef((props: Props, ref: ForwardedRef) => {
case 'Space': // Space
if (
selectionMode !== 'none' &&
- activeNodeId &&
- (selectableNodes === 'any' || tree.get(activeNodeId)?.isLeaf)
+ activeNode &&
+ (selectableNodes === 'any' || tree.get(activeNode)?.isLeaf)
) {
evt.preventDefault();
- itemSelection.toggle(activeNodeId);
+ itemSelection.toggle(activeNode);
}
break;
diff --git a/src/components/Tree/Tree.types.ts b/src/components/Tree/Tree.types.ts
index 4da6fd2db..234d17d16 100644
--- a/src/components/Tree/Tree.types.ts
+++ b/src/components/Tree/Tree.types.ts
@@ -204,9 +204,12 @@ export interface Props
*/
export interface UseVirtualTreeNavigationProps extends Pick {
/**
- * The active node id in the tree.
+ * Reference to the active node id in the tree.
+ *
+ * @remarks This prevent to destroy and re-create MutationObserver every time when active node
+ * changes. Also, this solves the problem of missed mutations.
*/
- activeNodeId: TreeNodeId;
+ activeNodeIdRef: MutableRefObject;
/**
* The reference of the tree DOM element.
*/
@@ -221,6 +224,10 @@ export interface NodeAriaProps {
* attributes to re-build the semantic structure of the tree.
*/
nodeProps: Partial>;
+ /**
+ * Additional attributes for the tree node's content.
+ */
+ nodeContentProps: Partial>;
/**
* Additional attributes for the node connection group.
*
diff --git a/src/components/Tree/Tree.utils.test.tsx b/src/components/Tree/Tree.utils.test.tsx
index 24d7b894f..2eb9c9f0d 100644
--- a/src/components/Tree/Tree.utils.test.tsx
+++ b/src/components/Tree/Tree.utils.test.tsx
@@ -21,7 +21,6 @@ import {
} from './Tree.types';
import { createTreeNode as tNode } from './test.utils';
import { renderHook } from '@testing-library/react-hooks';
-import tree from './Tree';
const createSingleLevelTree = () =>
// prettier-ignore
diff --git a/src/components/TreeNodeBase/TreeNodeBase.constants.ts b/src/components/TreeNodeBase/TreeNodeBase.constants.ts
index ad94226b5..580923ad4 100644
--- a/src/components/TreeNodeBase/TreeNodeBase.constants.ts
+++ b/src/components/TreeNodeBase/TreeNodeBase.constants.ts
@@ -24,6 +24,7 @@ const DEFAULTS = {
const STYLE = {
wrapper: `${CLASS_PREFIX}-wrapper`,
contextMenuWrapper: `${CLASS_PREFIX}-context-menu-wrapper`,
+ content: `${CLASS_PREFIX}-content`,
group: `${CLASS_PREFIX}-group`,
};
diff --git a/src/components/TreeNodeBase/TreeNodeBase.style.scss b/src/components/TreeNodeBase/TreeNodeBase.style.scss
index beb8e3d73..112830310 100644
--- a/src/components/TreeNodeBase/TreeNodeBase.style.scss
+++ b/src/components/TreeNodeBase/TreeNodeBase.style.scss
@@ -1,8 +1,5 @@
.md-tree-node-base-wrapper {
width: 100%;
- display: flex;
- justify-content: space-between;
- align-items: center;
font-size: 1rem;
line-height: 1.5rem;
color: var(--mds-color-theme-text-primary-normal);
@@ -26,40 +23,6 @@
color: var(--mds-color-theme-text-primary-normal);
}
- & > div[data-position='start'] {
- width: fit-content;
- margin-right: 0.75rem;
- flex-shrink: 0;
- }
-
- &[data-size='32'] > div[data-position='start'] {
- margin-right: 0.5rem;
- }
-
- & > div[data-position='fill'],
- *[data-position='middle'] {
- width: 100%;
-
- // trim any type of text inside
- &,
- p,
- span {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- }
-
- & > div[data-position='end'] {
- width: auto;
- margin-left: 0.75rem;
- flex-shrink: 0;
-
- & > button {
- margin: 0;
- }
- }
-
&[data-size='32'] {
height: 2rem;
}
@@ -104,6 +67,48 @@
}
}
+ .md-tree-node-base-content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+
+ & > div[data-position='start'] {
+ width: fit-content;
+ margin-right: 0.75rem;
+ flex-shrink: 0;
+ }
+
+ &[data-size='32'] > div[data-position='start'] {
+ margin-right: 0.5rem;
+ }
+
+ & > div[data-position='fill'],
+ *[data-position='middle'] {
+ width: 100%;
+
+ // trim any type of text inside
+ &,
+ p,
+ span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+ }
+
+ & > div[data-position='end'] {
+ width: auto;
+ margin-left: 0.75rem;
+ flex-shrink: 0;
+
+ & > button {
+ margin: 0;
+ }
+ }
+ }
+
.md-tree-node-base-group {
position: absolute;
}
diff --git a/src/components/TreeNodeBase/TreeNodeBase.test.tsx b/src/components/TreeNodeBase/TreeNodeBase.test.tsx
index 7255cf85a..3b0d4374c 100644
--- a/src/components/TreeNodeBase/TreeNodeBase.test.tsx
+++ b/src/components/TreeNodeBase/TreeNodeBase.test.tsx
@@ -273,7 +273,7 @@ describe('TreeNodeBase', () => {
expect(container.find('div[role="group"]').props()).toEqual({
'aria-owns': 'md-tree-node-1 md-tree-node-2',
className: 'md-tree-node-base-group',
- 'aria-labelledby': 'md-tree-node-root',
+ 'aria-labelledby': 'md-tree-node-root-content',
role: 'group',
});
});
diff --git a/src/components/TreeNodeBase/TreeNodeBase.test.tsx.snap b/src/components/TreeNodeBase/TreeNodeBase.test.tsx.snap
index 01b5d7adb..eabf29fb7 100644
--- a/src/components/TreeNodeBase/TreeNodeBase.test.tsx.snap
+++ b/src/components/TreeNodeBase/TreeNodeBase.test.tsx.snap
@@ -31,7 +31,11 @@ exports[`TreeNodeBase snapshot should match snapshot 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -70,7 +74,11 @@ exports[`TreeNodeBase snapshot should match snapshot with className 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -110,7 +118,11 @@ exports[`TreeNodeBase snapshot should match snapshot with id 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -149,7 +161,11 @@ exports[`TreeNodeBase snapshot should match snapshot with isSelected 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -189,7 +205,11 @@ exports[`TreeNodeBase snapshot should match snapshot with lang 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -228,7 +248,11 @@ exports[`TreeNodeBase snapshot should match snapshot with shape 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -267,7 +291,11 @@ exports[`TreeNodeBase snapshot should match snapshot with size 1`] = `
onTouchStart={[Function]}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -315,7 +343,11 @@ exports[`TreeNodeBase snapshot should match snapshot with style 1`] = `
}
tabIndex={0}
>
- Test
+
+ Test
+
@@ -363,10 +395,13 @@ exports[`TreeNodeBase snapshot should not render the content of the hidden nodes
key="root"
nodeId="root"
>
-
+
- root
+
+ root
+
-
+
- root
+ root
+
+
-
+
- 1
+
+ 1
+
@@ -603,10 +659,13 @@ exports[`TreeNodeBase snapshot should render grouping element when the tree is f
key="2"
nodeId="2"
>
-
+
- 2
+
+ 2
+
diff --git a/src/components/TreeNodeBase/TreeNodeBase.tsx b/src/components/TreeNodeBase/TreeNodeBase.tsx
index 9ab8d282f..08e1f9eac 100644
--- a/src/components/TreeNodeBase/TreeNodeBase.tsx
+++ b/src/components/TreeNodeBase/TreeNodeBase.tsx
@@ -190,7 +190,7 @@ const TreeNodeBase = (props: Props, providedRef: TreeNodeBaseRefOrCallbackRef):
return null;
}
- const { nodeProps, groupProps } = treeContext?.getNodeAriaProps(nodeId);
+ const { nodeProps, nodeContentProps, groupProps } = treeContext?.getNodeAriaProps(nodeId);
const isSelected =
treeContext?.itemSelection.selectionMode !== 'none'
? treeContext?.itemSelection.isSelected(nodeId)
@@ -217,7 +217,13 @@ const TreeNodeBase = (props: Props, providedRef: TreeNodeBaseRefOrCallbackRef):
{...nodeProps}
{...rest}
>
- {content}
+ {/*
+ Unfortunately, we do need a wrapper around the content, because the aria-labelledby of the group element will
+ get the text from this and all the child nodes
+ */}
+