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

[TreeView] Set focus on the focused Tree Item instead of the Tree View #12226

Merged
merged 25 commits into from Mar 19, 2024

Conversation

flaviendelangle
Copy link
Member

@flaviendelangle flaviendelangle commented Feb 27, 2024

See mui/material-ui#21695 as prior work on this topic.

Preview: https://deploy-preview-12226--material-ui-x.netlify.app/x/react-tree-view/#simple-tree-view

Fixes #9961
Fixes #9958
Fixes #10234


I did not explore if totally removing the focus from the state of the Tree View was doable, because the timing is tight before stable and this would only be an internal change 👍

Changelog

Breaking changes

  • The focus is now applied to the Tree Item root element instead of the Tree View root element.

    This change will allow new features that require the focus to be on the Tree Item,
    like the drag and drop reordering of items.
    It also solves several issues with focus management,
    like the inability to scroll to the focused item when a lot of items are rendered.

    This will mostly impact how you write tests to interact with the Tree View:

    For example, if you were writing a test with react-testing-library, here is what the changes could look like:

     it('test example on first item', () => {
    -  const { getByRole } = render(
    +  const { getAllByRole } = render(
         <SimpleTreeView>
           <TreeItem nodeId="one" />
           <TreeItem nodeId="two" />
        </SimpleTreeView>
       );
    
    -  const tree = getByRole('tree');
    +  const firstTreeItem = getAllByRole('treeitem')[0];
       act(() => {
    -    tree.focus();
    +    firstTreeItem.focus();
       });
    -  fireEvent.keyDown(tree, { key: 'ArrowDown' });
    +  fireEvent.keyDown(firstTreeItem, { key: 'ArrowDown' });
     })

@flaviendelangle flaviendelangle changed the title [TreeView] Set focus on the focused TreeItem instead of the Tree View [TreeView] Set focus on the focused Tree Item instead of the Tree View Feb 27, 2024
@flaviendelangle flaviendelangle self-assigned this Feb 27, 2024
@flaviendelangle flaviendelangle added breaking change component: tree view TreeView, TreeItem. This is the name of the generic UI component, not the React module! labels Feb 27, 2024
@mui-bot
Copy link

mui-bot commented Feb 27, 2024

@@ -0,0 +1,14 @@
// https://www.abeautifulsite.net/posts/finding-the-active-element-in-a-shadow-root/
export const getActiveElement = (root: Document | ShadowRoot = document): Element | null => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied from the pickers and the grid

@@ -7,8 +7,8 @@ export interface UseTreeViewSelectionInstance {
isNodeSelected: (nodeId: string) => boolean;
selectNode: (event: React.SyntheticEvent, nodeId: string, multiple?: boolean) => void;
selectRange: (event: React.SyntheticEvent, nodes: TreeViewItemRange, stacked?: boolean) => void;
rangeSelectToFirst: (event: React.KeyboardEvent<HTMLUListElement>, nodeId: string) => void;
rangeSelectToLast: (event: React.KeyboardEvent<HTMLUListElement>, nodeId: string) => void;
rangeSelectToFirst: (event: React.KeyboardEvent, nodeId: string) => void;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was always fired by the Tree View component
But it was not needed an UL specifically so I reduced the type precision

.filter((node) => node.parentId === nodeId)
.sort((a, b) => a.index - b.index)
.map((child) => child.id),
const getChildrenIds = React.useCallback(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useEventCallback is not compatible with methods fired in the render because they are always outdated (since the update is made in a useLayoutEffect.

This method is now needed to compute the tabbable node.

// If the tree is empty there will be no focused node
if (event.altKey || event.currentTarget !== event.target || state.focusedNodeId == null) {
return;
const ctrlPressed = event.ctrlKey || event.metaKey;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the changes below just replace state.focusedNodeId with nodeId

return node && (node.parentId == null || instance.isNodeExpanded(node.parentId));
};

let tabbableNodeId: string | null | undefined;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think this is worth memoizing?

expect(getByTestId('one')).toHaveVirtualFocus();
});

it('should focus the selected node if a node is selected before the tree receives focus', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced by should set tabIndex={0} on the selected item (multi select) and should set tabIndex={0} on the selected item

expect(getByTestId('parent')).toHaveVirtualFocus();
});

it('should focus on tree with scroll prevented', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't make sense anymore

expect(getByTestId('two')).toHaveVirtualFocus();
});

it('should be focused on tree focus', () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced by should not prevent programmatic focus

Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Feb 28, 2024
@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Feb 28, 2024
}
export interface UseTreeViewFocusPublicAPI {
focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@noraleonte with the introduction of focusDefaultNode, do we need to support null here?

isTreeViewFocused: () => boolean;
canNodeBeTabbed: (nodeId: string) => boolean;
focusNode: (event: React.SyntheticEvent, nodeId: string) => void;
focusDefaultNode: (event: React.SyntheticEvent | null) => void;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm adding null on the event so that when you remove the focused node, it can switch to another node (was already the case before) AND call onNodeFocus for this new node (was not the case before).

@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 6, 2024
Copy link

github-actions bot commented Mar 6, 2024

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 6, 2024
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 11, 2024
Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 11, 2024
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 12, 2024
Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 13, 2024
Copy link
Member

@LukasTy LukasTy left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, looks and works nicely! 👏

</SimpleTreeView>,
);
act(() => {
getByRole('tree').focus();
getByTestId('one').focus();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: It seems possible to avoid the usage of testId by keeping the getByRole.
We could add the explicit "name" field if we so desire extra stability.

Nitpick2: Is there a specific reason why we are using the methods de-structured from the render function instead of the root import of screen? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relates a lot to #12428

Nitpick: It seems possible to avoid the usage of testId by keeping the getByRole.
We could add the explicit "name" field if we so desire extra stability.

It's mostly for consistency across the tests, the vast majority uses testId.
I think we can align on a single approach in #12428 and use it everywhere while migrating all the tests to describeTreeView.

Nitpick2: Is there a specific reason why we are using the methods de-structured from the render function instead of the root import of screen?

screen is the advised way by the created or react-testing-library from what I remember, so no specific reason.
This topic will go away with #12428 if we create the right abstractions (which I think we should for readability).

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's mostly for consistency across the tests, the vast majority uses testId.

getByRole has ~2x more usages than getByTestId (and almost all of them are on TreeView tests). 🤔
IMHO, it would be best to use getByTestId when there is no other way to select elements. 🙈

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image
image

🤔 not sure I have the same results 😆

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(and all getByRole are for tree AFAIK)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I was referring to the global usage in the mui-x codebase, sorry for the confusion. 🙈

</SimpleTreeView>,
);
act(() => {
getByRole('tree').focus();
getByTestId('one').focus();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same question/nitpick regarding the usage of getByRole applies here and in the whole test file. 🤔

</SimpleTreeView>,
);

act(() => {
getByRole('tree').focus();
getByTestId('one').focus();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A friendly suggestion just like in the other test file to prefer using screen, getByRole and saving the result of getByRole in a variable to reuse instead of calling it multiple times.

packages/x-tree-view/src/TreeItem/TreeItem.test.tsx Outdated Show resolved Hide resolved
@github-actions github-actions bot added the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 14, 2024
Copy link

This pull request has conflicts, please resolve those before we can evaluate the pull request.

@github-actions github-actions bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged label Mar 18, 2024
Copy link
Contributor

@noraleonte noraleonte left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks and works great! Nice improvement! 🎉

@flaviendelangle flaviendelangle merged commit eeb9603 into mui:next Mar 19, 2024
17 checks passed
@flaviendelangle flaviendelangle deleted the focus-item branch March 19, 2024 12:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
breaking change component: tree view TreeView, TreeItem. This is the name of the generic UI component, not the React module!
Projects
None yet
6 participants