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] Add public API and expose focus method #12143

Merged
merged 23 commits into from
Feb 28, 2024
Merged
Show file tree
Hide file tree
Changes from 8 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
1 change: 1 addition & 0 deletions docs/data/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,7 @@ const pages: MuiPage[] = [
{ pathname: '/x/react-tree-view/simple-tree-view/selection' },
{ pathname: '/x/react-tree-view/simple-tree-view/expansion' },
{ pathname: '/x/react-tree-view/simple-tree-view/customization' },
{ pathname: '/x/react-tree-view/simple-tree-view/focus' },
],
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView';
import { TreeItem } from '@mui/x-tree-view/TreeItem';
import { useTreeViewApiRef } from '@mui/x-tree-view/hooks/useTreeViewApiRef';

export default function FocusedSimpleTreeView() {
const apiRef = useTreeViewApiRef();
const handleButtonClick = (e) => {
apiRef.current?.focusNode(e, 'pickers');
};

return (
<Box sx={{ flexGrow: 1, maxWidth: 400 }}>
<Box sx={{ mb: 1 }}>
<Button onClick={handleButtonClick}>focus pickers node</Button>
</Box>
<Box sx={{ minHeight: 264, flexGrow: 1 }}>
<SimpleTreeView {...{ apiRef }}>
<TreeItem nodeId="grid" label="Data Grid">
<TreeItem nodeId="grid-community" label="@mui/x-data-grid" />
<TreeItem nodeId="grid-pro" label="@mui/x-data-grid-pro" />
<TreeItem nodeId="grid-premium" label="@mui/x-data-grid-premium" />
</TreeItem>
<TreeItem nodeId="pickers" label="Date and Time Pickers">
<TreeItem nodeId="pickers-community" label="@mui/x-date-pickers" />
<TreeItem nodeId="pickers-pro" label="@mui/x-date-pickers-pro" />
</TreeItem>
<TreeItem nodeId="charts" label="Charts">
<TreeItem nodeId="charts-community" label="@mui/x-charts" />
</TreeItem>
<TreeItem nodeId="tree-view" label="Tree View">
<TreeItem nodeId="tree-view-community" label="@mui/x-tree-view" />
</TreeItem>
</SimpleTreeView>
</Box>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import * as React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView';
import { TreeItem } from '@mui/x-tree-view/TreeItem';
import { useTreeViewApiRef } from '@mui/x-tree-view/hooks/useTreeViewApiRef';

export default function FocusedSimpleTreeView() {
const apiRef = useTreeViewApiRef();
const handleButtonClick = (e: React.SyntheticEvent) => {
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
apiRef.current?.focusNode(e, 'pickers');
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
};

return (
<Box sx={{ flexGrow: 1, maxWidth: 400 }}>
<Box sx={{ mb: 1 }}>
<Button onClick={handleButtonClick}>focus pickers node</Button>
</Box>
<Box sx={{ minHeight: 264, flexGrow: 1 }}>
<SimpleTreeView {...{ apiRef }}>
<TreeItem nodeId="grid" label="Data Grid">
<TreeItem nodeId="grid-community" label="@mui/x-data-grid" />
<TreeItem nodeId="grid-pro" label="@mui/x-data-grid-pro" />
<TreeItem nodeId="grid-premium" label="@mui/x-data-grid-premium" />
</TreeItem>
<TreeItem nodeId="pickers" label="Date and Time Pickers">
<TreeItem nodeId="pickers-community" label="@mui/x-date-pickers" />
<TreeItem nodeId="pickers-pro" label="@mui/x-date-pickers-pro" />
</TreeItem>
<TreeItem nodeId="charts" label="Charts">
<TreeItem nodeId="charts-community" label="@mui/x-charts" />
</TreeItem>
<TreeItem nodeId="tree-view" label="Tree View">
<TreeItem nodeId="tree-view-community" label="@mui/x-tree-view" />
</TreeItem>
</SimpleTreeView>
</Box>
</Box>
);
}
16 changes: 16 additions & 0 deletions docs/data/tree-view/simple-tree-view/focus/focus.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
productId: x-tree-view
title: Simple Tree View - Focus
components: SimpleTreeView, TreeItem
packageName: '@mui/x-tree-view'
githubLabel: 'component: tree view'
waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/
---

# Simple Tree View - Focus

<p class="description">Learn how to focus Tree View items.</p>

## Focus a specific node

{{"demo": "FocusedSimpleTreeView.js"}}
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
2 changes: 1 addition & 1 deletion docs/pages/x/api/tree-view/simple-tree-view.json
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,6 @@
"forwardsRefTo": "HTMLUListElement",
"filename": "/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx",
"inheritance": null,
"demos": "<ul><li><a href=\"/x/react-tree-view/getting-started/\">Tree View - Getting Started</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/customization/\">Simple Tree View - Customization</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/expansion/\">Simple Tree View - Expansion</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/items/\">Simple Tree View - Items</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/selection/\">Simple Tree View - Selection</a></li></ul>",
"demos": "<ul><li><a href=\"/x/react-tree-view/getting-started/\">Tree View - Getting Started</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/customization/\">Simple Tree View - Customization</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/expansion/\">Simple Tree View - Expansion</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/focus/\">Simple Tree View - Focus</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/items/\">Simple Tree View - Items</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/selection/\">Simple Tree View - Selection</a></li></ul>",
"cssComponent": false
}
2 changes: 1 addition & 1 deletion docs/pages/x/api/tree-view/tree-item.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,6 @@
"forwardsRefTo": "HTMLLIElement",
"filename": "/packages/x-tree-view/src/TreeItem/TreeItem.tsx",
"inheritance": null,
"demos": "<ul><li><a href=\"/x/react-tree-view/getting-started/\">Tree View - Getting Started</a></li>\n<li><a href=\"/x/react-tree-view/rich-tree-view/expansion/\">Rich Tree View - Expansion</a></li>\n<li><a href=\"/x/react-tree-view/rich-tree-view/items/\">Rich Tree View - Items</a></li>\n<li><a href=\"/x/react-tree-view/rich-tree-view/selection/\">Rich Tree View - Selection</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/customization/\">Simple Tree View - Customization</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/expansion/\">Simple Tree View - Expansion</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/items/\">Simple Tree View - Items</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/selection/\">Simple Tree View - Selection</a></li></ul>",
"demos": "<ul><li><a href=\"/x/react-tree-view/getting-started/\">Tree View - Getting Started</a></li>\n<li><a href=\"/x/react-tree-view/rich-tree-view/expansion/\">Rich Tree View - Expansion</a></li>\n<li><a href=\"/x/react-tree-view/rich-tree-view/items/\">Rich Tree View - Items</a></li>\n<li><a href=\"/x/react-tree-view/rich-tree-view/selection/\">Rich Tree View - Selection</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/customization/\">Simple Tree View - Customization</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/expansion/\">Simple Tree View - Expansion</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/focus/\">Simple Tree View - Focus</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/items/\">Simple Tree View - Items</a></li>\n<li><a href=\"/x/react-tree-view/simple-tree-view/selection/\">Simple Tree View - Selection</a></li></ul>",
"cssComponent": false
}
7 changes: 7 additions & 0 deletions docs/pages/x/react-tree-view/simple-tree-view/focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';
import MarkdownDocs from 'docs/src/modules/components/MarkdownDocs';
import * as pageProps from 'docsx/data/tree-view/simple-tree-view/focus/focus.md?@mui/markdown';

export default function Page() {
return <MarkdownDocs {...pageProps} />;
}
1 change: 1 addition & 0 deletions packages/x-tree-view/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useTreeViewApiRef } from './useTreeViewApiRef';
10 changes: 10 additions & 0 deletions packages/x-tree-view/src/hooks/useTreeViewApiRef.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import * as React from 'react';
import { TreeViewAnyPluginSignature, TreeViewUsedPublicAPI } from '../internals/models';

/**
* Hook that instantiate a [[TreeViewApiRef]].
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
*/
export const useTreeViewApiRef = <
T extends TreeViewAnyPluginSignature,
Api extends TreeViewUsedPublicAPI<T>,
>() => React.useRef({}) as React.MutableRefObject<Api>;
2 changes: 2 additions & 0 deletions packages/x-tree-view/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ export { unstable_resetCleanupTracking } from './internals/hooks/useInstanceEven

export * from './models';
export * from './icons';

export * from './hooks';
1 change: 1 addition & 0 deletions packages/x-tree-view/src/internals/models/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export type ConvertPluginsIntoSignatures<TPlugins extends readonly any[]> =
export interface MergePlugins<TPlugins extends readonly any[]> {
state: MergePluginsProperty<TPlugins, 'state'>;
instance: MergePluginsProperty<TPlugins, 'instance'>;
publicAPI: MergePluginsProperty<TPlugins, 'publicAPI'>;
params: MergePluginsProperty<TPlugins, 'params'>;
defaultizedParams: MergePluginsProperty<TPlugins, 'defaultizedParams'>;
dependantPlugins: MergePluginsProperty<TPlugins, 'dependantPlugins'>;
Expand Down
13 changes: 13 additions & 0 deletions packages/x-tree-view/src/internals/models/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { TreeItemProps } from '../../TreeItem';

export interface TreeViewPluginOptions<TSignature extends TreeViewAnyPluginSignature> {
instance: TreeViewUsedInstance<TSignature>;
publicAPI: TreeViewUsedPublicAPI<TSignature>;
params: TreeViewUsedDefaultizedParams<TSignature>;
state: TreeViewUsedState<TSignature>;
slots: TSignature['slots'];
Expand Down Expand Up @@ -36,6 +37,7 @@ export type TreeViewPluginSignature<
params?: {};
defaultizedParams?: {};
instance?: {};
publicAPI?: {};
events?: { [key in keyof T['events']]: TreeViewEventLookupElement };
state?: {};
contextValue?: {};
Expand All @@ -48,6 +50,7 @@ export type TreeViewPluginSignature<
params: T extends { params: {} } ? T['params'] : {};
defaultizedParams: T extends { defaultizedParams: {} } ? T['defaultizedParams'] : {};
instance: T extends { instance: {} } ? T['instance'] : {};
publicAPI: T extends { publicAPI: {} } ? T['publicAPI'] : {};
events: T extends { events: {} } ? T['events'] : {};
state: T extends { state: {} } ? T['state'] : {};
contextValue: T extends { contextValue: {} } ? T['contextValue'] : {};
Expand All @@ -74,6 +77,7 @@ export type TreeViewAnyPluginSignature = {
slots: any;
slotProps: any;
models: any;
publicAPI: any;
};

type TreeViewUsedPlugins<TSignature extends TreeViewAnyPluginSignature> = [
Expand All @@ -97,6 +101,15 @@ export type TreeViewUsedInstance<TSignature extends TreeViewAnyPluginSignature>
$$signature: TSignature;
};

export type TreeViewUsedPublicAPI<TSignature extends TreeViewAnyPluginSignature> =
TSignature['publicAPI'] &
MergePluginsProperty<TreeViewUsedPlugins<TSignature>, 'publicAPI'> & {
/**
* Private property only defined in TypeScript to be able to access the plugin signature from the publicAPI object.
*/
$$signature: TSignature;
};

type TreeViewUsedState<TSignature extends TreeViewAnyPluginSignature> = TSignature['state'] &
MergePluginsProperty<TreeViewUsedPlugins<TSignature>, 'state'>;

Expand Down
3 changes: 3 additions & 0 deletions packages/x-tree-view/src/internals/models/treeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ export interface TreeViewModel<TValue> {

export type TreeViewInstance<TSignatures extends readonly TreeViewAnyPluginSignature[]> =
MergePluginsProperty<TSignatures, 'instance'>;

export type TreeViewPublicAPI<TSignatures extends readonly TreeViewAnyPluginSignature[]> =
MergePluginsProperty<TSignatures, 'publicAPI'>;
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,43 @@ import useEventCallback from '@mui/utils/useEventCallback';
import { EventHandlers } from '@mui/base/utils';
import ownerDocument from '@mui/utils/ownerDocument';
import { TreeViewPlugin } from '../../models';
import { populateInstance } from '../../useTreeView/useTreeView.utils';
import { populateInstance, populatePublicAPI } from '../../useTreeView/useTreeView.utils';
import { UseTreeViewFocusSignature } from './useTreeViewFocus.types';
import { useInstanceEventHandler } from '../../hooks/useInstanceEventHandler';

export const useTreeViewFocus: TreeViewPlugin<UseTreeViewFocusSignature> = ({
instance,
publicAPI,
params,
state,
setState,
models,
rootRef,
}) => {
const focusedNodeId = state.focusedNodeId;
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
const setFocusedNodeId = useEventCallback((nodeId: React.SetStateAction<string | null>) => {
const cleanNodeId = typeof nodeId === 'function' ? nodeId(state.focusedNodeId) : nodeId;
setState((prevState) => ({ ...prevState, focusedNodeId: cleanNodeId }));
const cleanNodeId = typeof nodeId === 'function' ? nodeId(focusedNodeId) : nodeId;
if (focusedNodeId !== cleanNodeId) {
setState((prevState) => ({ ...prevState, focusedNodeId: cleanNodeId }));
}
});

const isTreeFocused = React.useCallback(
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
() => rootRef.current === document.activeElement,
[rootRef],
);

const isNodeFocused = React.useCallback(
(nodeId: string) => state.focusedNodeId === nodeId,
[state.focusedNodeId],
(nodeId: string) => focusedNodeId === nodeId && isTreeFocused(),
[focusedNodeId, isTreeFocused],
);

const focusNode = useEventCallback((event: React.SyntheticEvent, nodeId: string | null) => {
if (nodeId) {
if (!isTreeFocused()) {
instance.focusRoot();
}
setFocusedNodeId(nodeId);

if (params.onNodeFocus) {
params.onNodeFocus(event, nodeId);
}
Expand All @@ -45,6 +56,10 @@ export const useTreeViewFocus: TreeViewPlugin<UseTreeViewFocusSignature> = ({
focusRoot,
});

populatePublicAPI<UseTreeViewFocusSignature>(publicAPI, {
focusNode,
});

useInstanceEventHandler(instance, 'removeNode', ({ id }) => {
setFocusedNodeId((oldFocusedNodeId) => {
if (
Expand All @@ -60,7 +75,6 @@ export const useTreeViewFocus: TreeViewPlugin<UseTreeViewFocusSignature> = ({
const createHandleFocus =
(otherHandlers: EventHandlers) => (event: React.FocusEvent<HTMLUListElement>) => {
otherHandlers.onFocus?.(event);

// if the event bubbled (which is React specific) we don't want to steal focus
if (event.target === event.currentTarget) {
const isNodeVisible = (nodeId: string) => {
Expand All @@ -69,7 +83,9 @@ export const useTreeViewFocus: TreeViewPlugin<UseTreeViewFocusSignature> = ({
};

let nodeToFocusId: string | null | undefined;
if (Array.isArray(models.selectedNodes.value)) {
if (focusedNodeId) {
nodeToFocusId = focusedNodeId;
} else if (Array.isArray(models.selectedNodes.value)) {
nodeToFocusId = models.selectedNodes.value.find(isNodeVisible);
} else if (
models.selectedNodes.value != null &&
Expand All @@ -81,7 +97,6 @@ export const useTreeViewFocus: TreeViewPlugin<UseTreeViewFocusSignature> = ({
if (nodeToFocusId == null) {
nodeToFocusId = instance.getNavigableChildrenIds(null)[0];
}

instance.focusNode(event, nodeToFocusId);
}
};
Expand All @@ -92,7 +107,7 @@ export const useTreeViewFocus: TreeViewPlugin<UseTreeViewFocusSignature> = ({
setFocusedNodeId(null);
};

const focusedNode = instance.getNode(state.focusedNodeId!);
const focusedNode = instance.getNode(focusedNodeId!);
const activeDescendant = focusedNode
? instance.getTreeItemId(focusedNode.id, focusedNode.idAttribute)
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export interface UseTreeViewFocusInstance {
focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void;
focusRoot: () => void;
}
export interface UseTreeViewFocusPublicAPI {
focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void;
}

export interface UseTreeViewFocusParameters {
/**
Expand All @@ -31,6 +34,7 @@ export type UseTreeViewFocusSignature = TreeViewPluginSignature<{
params: UseTreeViewFocusParameters;
defaultizedParams: UseTreeViewFocusDefaultizedParameters;
instance: UseTreeViewFocusInstance;
publicAPI: UseTreeViewFocusPublicAPI;
state: UseTreeViewFocusState;
dependantPlugins: [
UseTreeViewIdSignature,
Expand Down
18 changes: 18 additions & 0 deletions packages/x-tree-view/src/internals/useTreeView/useTreeView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
TreeViewPlugin,
ConvertPluginsIntoSignatures,
MergePluginsProperty,
TreeViewPublicAPI,
} from '../models';
import {
UseTreeViewDefaultizedParameters,
Expand All @@ -18,6 +19,18 @@ import { useTreeViewModels } from './useTreeViewModels';
import { TreeViewContextValue } from '../TreeViewProvider';
import { TREE_VIEW_CORE_PLUGINS } from '../corePlugins';

export function useTreeViewApiInitialization<T>(
inputApiRef: React.MutableRefObject<T> | undefined,
): React.MutableRefObject<T> {
const fallbackPublicApiRef = React.useRef({}) as React.MutableRefObject<T>;

if (inputApiRef) {
return inputApiRef;
}

return fallbackPublicApiRef;
}

export const useTreeView = <Plugins extends readonly TreeViewPlugin<TreeViewAnyPluginSignature>[]>(
inParams: UseTreeViewParameters<Plugins>,
): UseTreeViewReturnValue<ConvertPluginsIntoSignatures<Plugins>> => {
Expand All @@ -40,6 +53,10 @@ export const useTreeView = <Plugins extends readonly TreeViewPlugin<TreeViewAnyP
{} as TreeViewInstance<Signatures>,
);
const instance = instanceRef.current as TreeViewInstance<Signatures>;

const publicAPIRef = useTreeViewApiInitialization<TreeViewPublicAPI<Signatures>>(inParams.apiRef);
noraleonte marked this conversation as resolved.
Show resolved Hide resolved
const publicAPI = publicAPIRef.current as TreeViewPublicAPI<Signatures>;

const innerRootRef = React.useRef(null);
const handleRootRef = useForkRef(innerRootRef, inParams.rootRef);

Expand Down Expand Up @@ -68,6 +85,7 @@ export const useTreeView = <Plugins extends readonly TreeViewPlugin<TreeViewAnyP
const pluginResponse =
plugin({
instance,
publicAPI,
params,
slots: params.slots,
slotProps: params.slotProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type UseTreeViewParameters<
export interface UseTreeViewBaseParameters<
TPlugins extends readonly TreeViewPlugin<TreeViewAnyPluginSignature>[],
> {
apiRef?: React.MutableRefObject<{}> | undefined;
rootRef?: React.Ref<HTMLUListElement> | undefined;
plugins: TPlugins;
slots: MergePluginsProperty<ConvertPluginsIntoSignatures<TPlugins>, 'slots'>;
Expand Down
Loading
Loading