diff --git a/docs/data/pages.ts b/docs/data/pages.ts index d8fe2a79ce4f1..97bf3da4366a4 100644 --- a/docs/data/pages.ts +++ b/docs/data/pages.ts @@ -491,6 +491,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' }, ], }, { @@ -500,6 +501,7 @@ const pages: MuiPage[] = [ { pathname: '/x/react-tree-view/rich-tree-view/items' }, { pathname: '/x/react-tree-view/rich-tree-view/selection' }, { pathname: '/x/react-tree-view/rich-tree-view/expansion' }, + { pathname: '/x/react-tree-view/rich-tree-view/focus' }, ], }, { diff --git a/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.js b/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.js new file mode 100644 index 0000000000000..b55bbc89843cf --- /dev/null +++ b/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.js @@ -0,0 +1,54 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; + +import { useTreeViewApiRef } from '@mui/x-tree-view/hooks/useTreeViewApiRef'; + +const MUI_X_PRODUCTS = [ + { + id: 'grid', + label: 'Data Grid', + children: [ + { id: 'grid-community', label: '@mui/x-data-grid' }, + { id: 'grid-pro', label: '@mui/x-data-grid-pro' }, + { id: 'grid-premium', label: '@mui/x-data-grid-premium' }, + ], + }, + { + id: 'pickers', + label: 'Date and Time Pickers', + children: [ + { id: 'pickers-community', label: '@mui/x-date-pickers' }, + { id: 'pickers-pro', label: '@mui/x-date-pickers-pro' }, + ], + }, + { + id: 'charts', + label: 'Charts', + children: [{ id: 'charts-community', label: '@mui/x-charts' }], + }, + { + id: 'tree-view', + label: 'Tree View', + children: [{ id: 'tree-view-community', label: '@mui/x-tree-view' }], + }, +]; + +export default function FocusedRichTreeView() { + const apiRef = useTreeViewApiRef(); + const handleButtonClick = (event) => { + apiRef.current?.focusNode(event, 'pickers'); + }; + + return ( + + + + + + + + + ); +} diff --git a/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.tsx b/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.tsx new file mode 100644 index 0000000000000..5d83b6cec1162 --- /dev/null +++ b/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import { RichTreeView } from '@mui/x-tree-view/RichTreeView'; +import { TreeViewBaseItem } from '@mui/x-tree-view/models'; +import { useTreeViewApiRef } from '@mui/x-tree-view/hooks/useTreeViewApiRef'; + +const MUI_X_PRODUCTS: TreeViewBaseItem[] = [ + { + id: 'grid', + label: 'Data Grid', + children: [ + { id: 'grid-community', label: '@mui/x-data-grid' }, + { id: 'grid-pro', label: '@mui/x-data-grid-pro' }, + { id: 'grid-premium', label: '@mui/x-data-grid-premium' }, + ], + }, + { + id: 'pickers', + label: 'Date and Time Pickers', + children: [ + { id: 'pickers-community', label: '@mui/x-date-pickers' }, + { id: 'pickers-pro', label: '@mui/x-date-pickers-pro' }, + ], + }, + { + id: 'charts', + label: 'Charts', + children: [{ id: 'charts-community', label: '@mui/x-charts' }], + }, + { + id: 'tree-view', + label: 'Tree View', + children: [{ id: 'tree-view-community', label: '@mui/x-tree-view' }], + }, +]; + +export default function FocusedRichTreeView() { + const apiRef = useTreeViewApiRef(); + const handleButtonClick = (event: React.SyntheticEvent) => { + apiRef.current?.focusNode(event, 'pickers'); + }; + + return ( + + + + + + + + + ); +} diff --git a/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.tsx.preview b/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.tsx.preview new file mode 100644 index 0000000000000..bc63fee6e7fc3 --- /dev/null +++ b/docs/data/tree-view/rich-tree-view/focus/FocusedRichTreeView.tsx.preview @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/docs/data/tree-view/rich-tree-view/focus/focus.md b/docs/data/tree-view/rich-tree-view/focus/focus.md new file mode 100644 index 0000000000000..1f201af613a9a --- /dev/null +++ b/docs/data/tree-view/rich-tree-view/focus/focus.md @@ -0,0 +1,36 @@ +--- +productId: x-tree-view +title: Rich Tree View - Focus +components: RichTreeView, TreeItem +packageName: '@mui/x-tree-view' +githubLabel: 'component: tree view' +waiAria: https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ +--- + +# Rich Tree View - Focus + +

Learn how to focus Tree View items.

+ +## Focus a specific node + +You can use the the `apiRef.focusNode` method to focus a specific node. +This methods receives two parameters: `event` and `nodeId`. + +:::success +To use the `apiRef` object, you need to initialize it using the `useTreeViewApiRef` hook as follows: + +```tsx +const apiRef = useTreeViewApiRef(); + +return ; +``` + +`apiRef` will be undefined during the first render and will then contain methods allowing you to imperatively interact with the Tree View. +::: + +:::info +This method only works with nodes that are currently visible. +Calling `apiRef.focusNode` on a node whose parent is collapsed will do nothing. +::: + +{{"demo": "FocusedRichTreeView.js"}} diff --git a/docs/data/tree-view/simple-tree-view/focus/FocusedSimpleTreeView.js b/docs/data/tree-view/simple-tree-view/focus/FocusedSimpleTreeView.js new file mode 100644 index 0000000000000..ae3dc438a6970 --- /dev/null +++ b/docs/data/tree-view/simple-tree-view/focus/FocusedSimpleTreeView.js @@ -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 = (event) => { + apiRef.current?.focusNode(event, 'pickers'); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/tree-view/simple-tree-view/focus/FocusedSimpleTreeView.tsx b/docs/data/tree-view/simple-tree-view/focus/FocusedSimpleTreeView.tsx new file mode 100644 index 0000000000000..4f2e6eac445de --- /dev/null +++ b/docs/data/tree-view/simple-tree-view/focus/FocusedSimpleTreeView.tsx @@ -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 = (event: React.SyntheticEvent) => { + apiRef.current?.focusNode(event, 'pickers'); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/docs/data/tree-view/simple-tree-view/focus/focus.md b/docs/data/tree-view/simple-tree-view/focus/focus.md new file mode 100644 index 0000000000000..9f85933666c05 --- /dev/null +++ b/docs/data/tree-view/simple-tree-view/focus/focus.md @@ -0,0 +1,36 @@ +--- +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 + +

Learn how to focus Tree View items.

+ +## Focus a specific node + +You can use the the `apiRef.focusNode` method to focus a specific node. +This methods receives two parameters: `event` and `nodeId`. + +:::success +To use the `apiRef` object, you need to initialize it using the `useTreeViewApiRef` hook as follows: + +```tsx +const apiRef = useTreeViewApiRef(); + +return {children}; +``` + +`apiRef` will be undefined during the first render and will then contain methods allowing you to imperatively interact with the Tree View. +::: + +:::info +This method only works with nodes that are currently visible. +Calling `apiRef.focusNode` on a node whose parent is collapsed will do nothing. +::: + +{{"demo": "FocusedSimpleTreeView.js"}} diff --git a/docs/pages/x/api/tree-view/rich-tree-view.json b/docs/pages/x/api/tree-view/rich-tree-view.json index 891044b6b1873..ab76e0f2953bf 100644 --- a/docs/pages/x/api/tree-view/rich-tree-view.json +++ b/docs/pages/x/api/tree-view/rich-tree-view.json @@ -1,5 +1,6 @@ { "props": { + "apiRef": { "type": { "name": "shape", "description": "{ current?: { focusNode: func } }" } }, "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, "defaultExpandedNodes": { "type": { "name": "arrayOf", "description": "Array<string>" }, @@ -128,6 +129,6 @@ "forwardsRefTo": "HTMLUListElement", "filename": "/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx", "inheritance": null, - "demos": "", + "demos": "", "cssComponent": false } diff --git a/docs/pages/x/api/tree-view/simple-tree-view.json b/docs/pages/x/api/tree-view/simple-tree-view.json index f24417dee5b0d..10a3eae04caab 100644 --- a/docs/pages/x/api/tree-view/simple-tree-view.json +++ b/docs/pages/x/api/tree-view/simple-tree-view.json @@ -1,5 +1,6 @@ { "props": { + "apiRef": { "type": { "name": "shape", "description": "{ current?: { focusNode: func } }" } }, "children": { "type": { "name": "node" } }, "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, "defaultExpandedNodes": { @@ -93,6 +94,6 @@ "forwardsRefTo": "HTMLUListElement", "filename": "/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx", "inheritance": null, - "demos": "", + "demos": "", "cssComponent": false } diff --git a/docs/pages/x/api/tree-view/tree-item.json b/docs/pages/x/api/tree-view/tree-item.json index 81a9ed2ce85bb..8b7bef6f16cc8 100644 --- a/docs/pages/x/api/tree-view/tree-item.json +++ b/docs/pages/x/api/tree-view/tree-item.json @@ -104,6 +104,6 @@ "forwardsRefTo": "HTMLLIElement", "filename": "/packages/x-tree-view/src/TreeItem/TreeItem.tsx", "inheritance": null, - "demos": "", + "demos": "", "cssComponent": false } diff --git a/docs/pages/x/api/tree-view/tree-view.json b/docs/pages/x/api/tree-view/tree-view.json index 5eb73fe0d4ad9..d15094b303d86 100644 --- a/docs/pages/x/api/tree-view/tree-view.json +++ b/docs/pages/x/api/tree-view/tree-view.json @@ -1,5 +1,6 @@ { "props": { + "apiRef": { "type": { "name": "shape", "description": "{ current?: { focusNode: func } }" } }, "children": { "type": { "name": "node" } }, "classes": { "type": { "name": "object" }, "additionalInfo": { "cssApi": true } }, "defaultExpandedNodes": { diff --git a/docs/pages/x/react-tree-view/rich-tree-view/focus.js b/docs/pages/x/react-tree-view/rich-tree-view/focus.js new file mode 100644 index 0000000000000..6b2b63c962650 --- /dev/null +++ b/docs/pages/x/react-tree-view/rich-tree-view/focus.js @@ -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/rich-tree-view/focus/focus.md?@mui/markdown'; + +export default function Page() { + return ; +} diff --git a/docs/pages/x/react-tree-view/simple-tree-view/focus.js b/docs/pages/x/react-tree-view/simple-tree-view/focus.js new file mode 100644 index 0000000000000..a545ed30a37ff --- /dev/null +++ b/docs/pages/x/react-tree-view/simple-tree-view/focus.js @@ -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 ; +} diff --git a/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json b/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json index aa886207beaa2..883829d9ab0d7 100644 --- a/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json +++ b/docs/translations/api-docs/tree-view/rich-tree-view/rich-tree-view.json @@ -1,6 +1,9 @@ { "componentDescription": "", "propDescriptions": { + "apiRef": { + "description": "The ref object that allows Tree View manipulation. Can be instantiated with useTreeViewApiRef()." + }, "classes": { "description": "Override or extend the styles applied to the component." }, "defaultExpandedNodes": { "description": "Expanded node ids. Used when the item's expansion is not controlled." diff --git a/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json b/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json index cda74e515ac52..2cb82f5e46440 100644 --- a/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json +++ b/docs/translations/api-docs/tree-view/simple-tree-view/simple-tree-view.json @@ -1,6 +1,9 @@ { "componentDescription": "", "propDescriptions": { + "apiRef": { + "description": "The ref object that allows Tree View manipulation. Can be instantiated with useTreeViewApiRef()." + }, "children": { "description": "The content of the component." }, "classes": { "description": "Override or extend the styles applied to the component." }, "defaultExpandedNodes": { diff --git a/docs/translations/api-docs/tree-view/tree-view/tree-view.json b/docs/translations/api-docs/tree-view/tree-view/tree-view.json index e07f766020c0b..739dc69511641 100644 --- a/docs/translations/api-docs/tree-view/tree-view/tree-view.json +++ b/docs/translations/api-docs/tree-view/tree-view/tree-view.json @@ -1,6 +1,9 @@ { "componentDescription": "This component has been deprecated in favor of the new `SimpleTreeView` component.\nYou can have a look at how to migrate to the new component in the v7 [migration guide](https://next.mui.com/x/migration/migration-tree-view-v6/#use-simpletreeview-instead-of-treeview)", "propDescriptions": { + "apiRef": { + "description": "The ref object that allows Tree View manipulation. Can be instantiated with useTreeViewApiRef()." + }, "children": { "description": "The content of the component." }, "classes": { "description": "Override or extend the styles applied to the component." }, "defaultExpandedNodes": { diff --git a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx index 6ae6d863ce945..c05701e690841 100644 --- a/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx +++ b/packages/x-tree-view/src/RichTreeView/RichTreeView.tsx @@ -146,6 +146,14 @@ RichTreeView.propTypes = { // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the TypeScript types and run "yarn proptypes" | // ---------------------------------------------------------------------- + /** + * The ref object that allows Tree View manipulation. Can be instantiated with `useTreeViewApiRef()`. + */ + apiRef: PropTypes.shape({ + current: PropTypes.shape({ + focusNode: PropTypes.func.isRequired, + }), + }), /** * Override or extend the styles applied to the component. */ diff --git a/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts b/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts index 21625d9d946ef..124821c8aaf15 100644 --- a/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts +++ b/packages/x-tree-view/src/RichTreeView/RichTreeView.types.ts @@ -7,9 +7,11 @@ import { DefaultTreeViewPluginParameters, DefaultTreeViewPluginSlotProps, DefaultTreeViewPluginSlots, + DefaultTreeViewPlugins, } from '../internals/plugins/defaultPlugins'; import { TreeItem, TreeItemProps } from '../TreeItem'; import { TreeViewItemId } from '../models'; +import { TreeViewPublicAPI } from '../internals/models'; interface RichTreeViewItemSlotOwnerState { nodeId: TreeViewItemId; @@ -35,6 +37,10 @@ export interface RichTreeViewSlotProps; } +export type RichTreeViewApiRef = React.MutableRefObject< + TreeViewPublicAPI | undefined +>; + export interface RichTreeViewPropsBase extends React.HTMLAttributes { className?: string; /** @@ -60,4 +66,8 @@ export interface RichTreeViewProps; + /** + * The ref object that allows Tree View manipulation. Can be instantiated with `useTreeViewApiRef()`. + */ + apiRef?: RichTreeViewApiRef; } diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx index b49c3f3f3d790..e0d149a6d5e71 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.test.tsx @@ -6,6 +6,8 @@ import Portal from '@mui/material/Portal'; import { SimpleTreeView, simpleTreeViewClasses as classes } from '@mui/x-tree-view/SimpleTreeView'; import { TreeItem } from '@mui/x-tree-view/TreeItem'; import { describeConformance } from 'test/utils/describeConformance'; +import { useTreeViewApiRef } from '../hooks'; +import { SimpleTreeViewApiRef } from './SimpleTreeView.types'; describe('', () => { const { render } = createRenderer(); @@ -531,6 +533,57 @@ describe('', () => { expect(onNodeFocus.lastCall.lastArg).to.equal('1'); }); + + it('should focus specific node using `apiRef`', () => { + let apiRef: SimpleTreeViewApiRef; + const onNodeFocus = spy(); + + function TestCase() { + apiRef = useTreeViewApiRef(); + return ( + + + + + + + ); + } + + const { getByRole } = render(); + + act(() => { + apiRef.current?.focusNode({} as React.SyntheticEvent, '2'); + }); + + expect(getByRole('tree')).toHaveFocus(); + expect(onNodeFocus.lastCall.lastArg).to.equal('2'); + }); + + it('should not focus node if parent is collapsed', () => { + let apiRef: SimpleTreeViewApiRef; + const onNodeFocus = spy(); + + function TestCase() { + apiRef = useTreeViewApiRef(); + return ( + + + + + + + ); + } + + const { getByRole } = render(); + + act(() => { + apiRef.current?.focusNode({} as React.SyntheticEvent, '1.1'); + }); + + expect(getByRole('tree')).not.toHaveFocus(); + }); }); describe('Accessibility', () => { diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx index 7ccff7c09ec61..7a00d05f19bda 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.tsx @@ -109,6 +109,14 @@ SimpleTreeView.propTypes = { // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the TypeScript types and run "yarn proptypes" | // ---------------------------------------------------------------------- + /** + * The ref object that allows Tree View manipulation. Can be instantiated with `useTreeViewApiRef()`. + */ + apiRef: PropTypes.shape({ + current: PropTypes.shape({ + focusNode: PropTypes.func.isRequired, + }), + }), /** * The content of the component. */ diff --git a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.types.ts b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.types.ts index c351aeb87e39d..b5be79437a156 100644 --- a/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.types.ts +++ b/packages/x-tree-view/src/SimpleTreeView/SimpleTreeView.types.ts @@ -7,7 +7,9 @@ import { SimpleTreeViewPluginParameters, SimpleTreeViewPluginSlotProps, SimpleTreeViewPluginSlots, + SimpleTreeViewPlugins, } from './SimpleTreeView.plugins'; +import { TreeViewPublicAPI } from '../internals/models'; export interface SimpleTreeViewSlots extends SimpleTreeViewPluginSlots { /** @@ -21,6 +23,10 @@ export interface SimpleTreeViewSlotProps extends SimpleTreeViewPluginSlotProps { root?: SlotComponentProps<'ul', {}, {}>; } +export type SimpleTreeViewApiRef = React.MutableRefObject< + TreeViewPublicAPI | undefined +>; + export interface SimpleTreeViewProps extends SimpleTreeViewPluginParameters, React.HTMLAttributes { @@ -45,4 +51,8 @@ export interface SimpleTreeViewProps * The system prop that allows defining system overrides as well as additional CSS styles. */ sx?: SxProps; + /** + * The ref object that allows Tree View manipulation. Can be instantiated with `useTreeViewApiRef()`. + */ + apiRef?: SimpleTreeViewApiRef; } diff --git a/packages/x-tree-view/src/TreeView/TreeView.tsx b/packages/x-tree-view/src/TreeView/TreeView.tsx index a17d55405f3ff..814b58c494ae6 100644 --- a/packages/x-tree-view/src/TreeView/TreeView.tsx +++ b/packages/x-tree-view/src/TreeView/TreeView.tsx @@ -86,6 +86,14 @@ TreeView.propTypes = { // | These PropTypes are generated from the TypeScript type definitions | // | To update them edit the TypeScript types and run "yarn proptypes" | // ---------------------------------------------------------------------- + /** + * The ref object that allows Tree View manipulation. Can be instantiated with `useTreeViewApiRef()`. + */ + apiRef: PropTypes.shape({ + current: PropTypes.shape({ + focusNode: PropTypes.func.isRequired, + }), + }), /** * The content of the component. */ diff --git a/packages/x-tree-view/src/hooks/index.ts b/packages/x-tree-view/src/hooks/index.ts new file mode 100644 index 0000000000000..e07d07a202b5a --- /dev/null +++ b/packages/x-tree-view/src/hooks/index.ts @@ -0,0 +1 @@ +export { useTreeViewApiRef } from './useTreeViewApiRef'; diff --git a/packages/x-tree-view/src/hooks/useTreeViewApiRef.tsx b/packages/x-tree-view/src/hooks/useTreeViewApiRef.tsx new file mode 100644 index 0000000000000..f003fe5f124bb --- /dev/null +++ b/packages/x-tree-view/src/hooks/useTreeViewApiRef.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { TreeViewAnyPluginSignature, TreeViewUsedPublicAPI } from '../internals/models'; + +/** + * Hook that instantiates a [[TreeViewApiRef]]. + */ +export const useTreeViewApiRef = < + T extends TreeViewAnyPluginSignature, + Api extends TreeViewUsedPublicAPI, +>() => React.useRef(undefined) as React.MutableRefObject; diff --git a/packages/x-tree-view/src/index.ts b/packages/x-tree-view/src/index.ts index d19660c797110..15599f35a6aba 100644 --- a/packages/x-tree-view/src/index.ts +++ b/packages/x-tree-view/src/index.ts @@ -6,3 +6,5 @@ export { unstable_resetCleanupTracking } from './internals/hooks/useInstanceEven export * from './models'; export * from './icons'; + +export * from './hooks'; diff --git a/packages/x-tree-view/src/internals/models/helpers.ts b/packages/x-tree-view/src/internals/models/helpers.ts index 127a5e29cb679..de88ea87b8f6f 100644 --- a/packages/x-tree-view/src/internals/models/helpers.ts +++ b/packages/x-tree-view/src/internals/models/helpers.ts @@ -35,6 +35,7 @@ export type ConvertPluginsIntoSignatures = export interface MergePlugins { state: MergePluginsProperty; instance: MergePluginsProperty; + publicAPI: MergePluginsProperty; params: MergePluginsProperty; defaultizedParams: MergePluginsProperty; dependantPlugins: MergePluginsProperty; diff --git a/packages/x-tree-view/src/internals/models/plugin.ts b/packages/x-tree-view/src/internals/models/plugin.ts index d735f430eb53b..9ffae62154798 100644 --- a/packages/x-tree-view/src/internals/models/plugin.ts +++ b/packages/x-tree-view/src/internals/models/plugin.ts @@ -8,6 +8,7 @@ import type { TreeItemProps } from '../../TreeItem'; export interface TreeViewPluginOptions { instance: TreeViewUsedInstance; + publicAPI: TreeViewUsedPublicAPI; params: TreeViewUsedDefaultizedParams; state: TreeViewUsedState; slots: TSignature['slots']; @@ -36,6 +37,7 @@ export type TreeViewPluginSignature< params?: {}; defaultizedParams?: {}; instance?: {}; + publicAPI?: {}; events?: { [key in keyof T['events']]: TreeViewEventLookupElement }; state?: {}; contextValue?: {}; @@ -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'] : {}; @@ -74,6 +77,7 @@ export type TreeViewAnyPluginSignature = { slots: any; slotProps: any; models: any; + publicAPI: any; }; type TreeViewUsedPlugins = [ @@ -97,6 +101,15 @@ export type TreeViewUsedInstance $$signature: TSignature; }; +export type TreeViewUsedPublicAPI = + TSignature['publicAPI'] & + MergePluginsProperty, 'publicAPI'> & { + /** + * Private property only defined in TypeScript to be able to access the plugin signature from the publicAPI object. + */ + $$signature: TSignature; + }; + type TreeViewUsedState = TSignature['state'] & MergePluginsProperty, 'state'>; diff --git a/packages/x-tree-view/src/internals/models/treeView.ts b/packages/x-tree-view/src/internals/models/treeView.ts index c7ee9a8d8c365..8a8e9ca221066 100644 --- a/packages/x-tree-view/src/internals/models/treeView.ts +++ b/packages/x-tree-view/src/internals/models/treeView.ts @@ -29,3 +29,6 @@ export interface TreeViewModel { export type TreeViewInstance = MergePluginsProperty; + +export type TreeViewPublicAPI = + MergePluginsProperty; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts index d37a7256a3d29..7dda6e8cd4b8d 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.ts @@ -3,12 +3,14 @@ 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'; +import { getActiveElement } from '../../utils/utils'; export const useTreeViewFocus: TreeViewPlugin = ({ instance, + publicAPI, params, state, setState, @@ -17,24 +19,57 @@ export const useTreeViewFocus: TreeViewPlugin = ({ }) => { const setFocusedNodeId = useEventCallback((nodeId: React.SetStateAction) => { const cleanNodeId = typeof nodeId === 'function' ? nodeId(state.focusedNodeId) : nodeId; - setState((prevState) => ({ ...prevState, focusedNodeId: cleanNodeId })); + if (state.focusedNodeId !== cleanNodeId) { + setState((prevState) => ({ ...prevState, focusedNodeId: cleanNodeId })); + } }); + const isTreeViewFocused = React.useCallback( + () => !!rootRef.current && rootRef.current === getActiveElement(ownerDocument(rootRef.current)), + [rootRef], + ); + const isNodeFocused = React.useCallback( - (nodeId: string) => state.focusedNodeId === nodeId, - [state.focusedNodeId], + (nodeId: string) => state.focusedNodeId === nodeId && isTreeViewFocused(), + [state.focusedNodeId, isTreeViewFocused], ); + const isNodeVisible = (nodeId: string) => { + const node = instance.getNode(nodeId); + return node && (node.parentId == null || instance.isNodeExpanded(node.parentId)); + }; + const focusNode = useEventCallback((event: React.SyntheticEvent, nodeId: string | null) => { - if (nodeId) { + // if we receive a nodeId, and it is visible, the focus will be set to it + if (nodeId && isNodeVisible(nodeId)) { + if (!isTreeViewFocused()) { + instance.focusRoot(); + } setFocusedNodeId(nodeId); - if (params.onNodeFocus) { params.onNodeFocus(event, nodeId); } } }); + const focusDefaultNode = useEventCallback((event: React.SyntheticEvent) => { + let nodeToFocusId: string | null | undefined; + if (Array.isArray(models.selectedNodes.value)) { + nodeToFocusId = models.selectedNodes.value.find(isNodeVisible); + } else if (models.selectedNodes.value != null && isNodeVisible(models.selectedNodes.value)) { + nodeToFocusId = models.selectedNodes.value; + } + + if (nodeToFocusId == null) { + nodeToFocusId = instance.getNavigableChildrenIds(null)[0]; + } + + setFocusedNodeId(nodeToFocusId); + if (params.onNodeFocus) { + params.onNodeFocus(event, nodeToFocusId); + } + }); + const focusRoot = useEventCallback(() => { rootRef.current?.focus({ preventScroll: true }); }); @@ -43,6 +78,11 @@ export const useTreeViewFocus: TreeViewPlugin = ({ isNodeFocused, focusNode, focusRoot, + focusDefaultNode, + }); + + populatePublicAPI(publicAPI, { + focusNode, }); useInstanceEventHandler(instance, 'removeNode', ({ id }) => { @@ -60,29 +100,9 @@ export const useTreeViewFocus: TreeViewPlugin = ({ const createHandleFocus = (otherHandlers: EventHandlers) => (event: React.FocusEvent) => { 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) => { - const node = instance.getNode(nodeId); - return node && (node.parentId == null || instance.isNodeExpanded(node.parentId)); - }; - - let nodeToFocusId: string | null | undefined; - if (Array.isArray(models.selectedNodes.value)) { - nodeToFocusId = models.selectedNodes.value.find(isNodeVisible); - } else if ( - models.selectedNodes.value != null && - isNodeVisible(models.selectedNodes.value) - ) { - nodeToFocusId = models.selectedNodes.value; - } - - if (nodeToFocusId == null) { - nodeToFocusId = instance.getNavigableChildrenIds(null)[0]; - } - - instance.focusNode(event, nodeToFocusId); + instance.focusDefaultNode(event); } }; diff --git a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts index e7a181d86c19a..2bb293715f34d 100644 --- a/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts +++ b/packages/x-tree-view/src/internals/plugins/useTreeViewFocus/useTreeViewFocus.types.ts @@ -8,8 +8,12 @@ import { UseTreeViewExpansionSignature } from '../useTreeViewExpansion'; export interface UseTreeViewFocusInstance { isNodeFocused: (nodeId: string) => boolean; focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void; + focusDefaultNode: (event: React.SyntheticEvent) => void; focusRoot: () => void; } +export interface UseTreeViewFocusPublicAPI { + focusNode: (event: React.SyntheticEvent, nodeId: string | null) => void; +} export interface UseTreeViewFocusParameters { /** @@ -31,6 +35,7 @@ export type UseTreeViewFocusSignature = TreeViewPluginSignature<{ params: UseTreeViewFocusParameters; defaultizedParams: UseTreeViewFocusDefaultizedParameters; instance: UseTreeViewFocusInstance; + publicAPI: UseTreeViewFocusPublicAPI; state: UseTreeViewFocusState; dependantPlugins: [ UseTreeViewIdSignature, diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts index 06d6d9e0ca648..3584f61115f8a 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.ts @@ -7,6 +7,7 @@ import { TreeViewPlugin, ConvertPluginsIntoSignatures, MergePluginsProperty, + TreeViewPublicAPI, } from '../models'; import { UseTreeViewDefaultizedParameters, @@ -18,6 +19,21 @@ import { useTreeViewModels } from './useTreeViewModels'; import { TreeViewContextValue } from '../TreeViewProvider'; import { TREE_VIEW_CORE_PLUGINS } from '../corePlugins'; +export function useTreeViewApiInitialization( + inputApiRef: React.MutableRefObject | undefined, +): T { + const fallbackPublicApiRef = React.useRef({}) as React.MutableRefObject; + + if (inputApiRef) { + if (inputApiRef.current == null) { + inputApiRef.current = {} as T; + } + return inputApiRef.current; + } + + return fallbackPublicApiRef.current; +} + export const useTreeView = []>( inParams: UseTreeViewParameters, ): UseTreeViewReturnValue> => { @@ -40,6 +56,9 @@ export const useTreeView = , ); const instance = instanceRef.current as TreeViewInstance; + + const publicAPI = useTreeViewApiInitialization>(inParams.apiRef); + const innerRootRef = React.useRef(null); const handleRootRef = useForkRef(innerRootRef, inParams.rootRef); @@ -68,6 +87,7 @@ export const useTreeView = [], > { + apiRef: + | React.MutableRefObject>> + | undefined; rootRef?: React.Ref | undefined; plugins: TPlugins; slots: MergePluginsProperty, 'slots'>; diff --git a/packages/x-tree-view/src/internals/useTreeView/useTreeView.utils.ts b/packages/x-tree-view/src/internals/useTreeView/useTreeView.utils.ts index 0215d05ab79bc..a960b0e3e6403 100644 --- a/packages/x-tree-view/src/internals/useTreeView/useTreeView.utils.ts +++ b/packages/x-tree-view/src/internals/useTreeView/useTreeView.utils.ts @@ -1,4 +1,9 @@ -import { TreeViewAnyPluginSignature, TreeViewInstance, TreeViewUsedInstance } from '../models'; +import { + TreeViewAnyPluginSignature, + TreeViewInstance, + TreeViewUsedInstance, + TreeViewUsedPublicAPI, +} from '../models'; import type { UseTreeViewExpansionSignature } from '../plugins/useTreeViewExpansion'; import type { UseTreeViewNodesSignature } from '../plugins/useTreeViewNodes'; @@ -71,3 +76,10 @@ export const populateInstance = ( ) => { Object.assign(instance, methods); }; + +export const populatePublicAPI = ( + publicAPI: TreeViewUsedPublicAPI, + methods: T['publicAPI'], +) => { + Object.assign(publicAPI, methods); +}; diff --git a/packages/x-tree-view/src/internals/utils/extractPluginParamsFromProps.ts b/packages/x-tree-view/src/internals/utils/extractPluginParamsFromProps.ts index 81200813402bb..6f0ae94843dbd 100644 --- a/packages/x-tree-view/src/internals/utils/extractPluginParamsFromProps.ts +++ b/packages/x-tree-view/src/internals/utils/extractPluginParamsFromProps.ts @@ -1,14 +1,25 @@ import * as React from 'react'; -import { ConvertPluginsIntoSignatures, MergePluginsProperty, TreeViewPlugin } from '../models'; +import { + ConvertPluginsIntoSignatures, + MergePluginsProperty, + TreeViewPlugin, + TreeViewPublicAPI, +} from '../models'; import { UseTreeViewBaseParameters } from '../useTreeView/useTreeView.types'; export const extractPluginParamsFromProps = < TPlugins extends readonly TreeViewPlugin[], TSlots extends MergePluginsProperty, TSlotProps extends MergePluginsProperty, - TProps extends { slots?: TSlots; slotProps?: TSlotProps }, + TProps extends { + slots?: TSlots; + slotProps?: TSlotProps; + apiRef?: React.MutableRefObject< + TreeViewPublicAPI> | undefined + >; + }, >({ - props: { slots, slotProps, ...props }, + props: { slots, slotProps, apiRef, ...props }, plugins, rootRef, }: { @@ -28,6 +39,7 @@ export const extractPluginParamsFromProps = < rootRef, slots: slots ?? {}, slotProps: slotProps ?? {}, + apiRef, } as UseTreeViewBaseParameters & PluginParams; const otherProps = {} as Omit; diff --git a/packages/x-tree-view/src/internals/utils/utils.ts b/packages/x-tree-view/src/internals/utils/utils.ts new file mode 100644 index 0000000000000..71dc45b2f144d --- /dev/null +++ b/packages/x-tree-view/src/internals/utils/utils.ts @@ -0,0 +1,13 @@ +export const getActiveElement = (root: Document | ShadowRoot = document): Element | null => { + const activeEl = root.activeElement; + + if (!activeEl) { + return null; + } + + if (activeEl.shadowRoot) { + return getActiveElement(activeEl.shadowRoot); + } + + return activeEl; +}; diff --git a/scripts/x-tree-view.exports.json b/scripts/x-tree-view.exports.json index 25189b09d2e2f..19663f9de2115 100644 --- a/scripts/x-tree-view.exports.json +++ b/scripts/x-tree-view.exports.json @@ -44,5 +44,6 @@ { "name": "TreeViewSlotProps", "kind": "Interface" }, { "name": "TreeViewSlots", "kind": "Interface" }, { "name": "unstable_resetCleanupTracking", "kind": "Variable" }, - { "name": "useTreeItemState", "kind": "Function" } + { "name": "useTreeItemState", "kind": "Function" }, + { "name": "useTreeViewApiRef", "kind": "Variable" } ]