From c626b503d41e07234f74b98326ccabc2b01b7df9 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 23 Nov 2022 10:49:36 +0000 Subject: [PATCH 1/4] chore: initial Tree & TreeItem implementation --- .../react-tree/etc/react-tree.api.md | 55 ++++++----- .../react-components/react-tree/package.json | 5 + .../react-tree/src/TreeBranch.ts | 1 - .../react-tree/src/TreeItem.ts | 1 + .../react-tree/src/TreeLeaf.ts | 1 - .../src/components/Tree/Tree.test.tsx | 3 +- .../react-tree/src/components/Tree/Tree.tsx | 12 ++- .../src/components/Tree/Tree.types.ts | 48 +++++++++- .../Tree/__snapshots__/Tree.test.tsx.snap | 6 +- .../src/components/Tree/renderTree.tsx | 12 ++- .../react-tree/src/components/Tree/useTree.ts | 93 ++++++++++++++++++- .../components/Tree/useTreeContextValues.ts | 26 ++++++ .../src/components/Tree/useTreeStyles.ts | 14 +-- .../components/TreeBranch/TreeBranch.test.tsx | 16 ---- .../src/components/TreeBranch/TreeBranch.tsx | 18 ---- .../components/TreeBranch/TreeBranch.types.ts | 12 --- .../__snapshots__/TreeBranch.test.tsx.snap | 11 --- .../src/components/TreeBranch/index.ts | 5 - .../TreeBranch/renderTreeBranch.tsx | 9 -- .../components/TreeBranch/useTreeBranch.ts | 24 ----- .../TreeBranch/useTreeBranchStyles.ts | 12 --- .../src/components/TreeItem/TreeItem.test.tsx | 23 +++++ .../src/components/TreeItem/TreeItem.tsx | 18 ++++ .../src/components/TreeItem/TreeItem.types.ts | 30 ++++++ .../__snapshots__/TreeItem.test.tsx.snap | 14 +++ .../src/components/TreeItem/index.ts | 5 + .../components/TreeItem/renderTreeItem.tsx | 12 +++ .../src/components/TreeItem/useTreeItem.ts | 89 ++++++++++++++++++ .../components/TreeItem/useTreeItemStyles.ts | 15 +++ .../src/components/TreeLeaf/TreeLeaf.test.tsx | 16 ---- .../src/components/TreeLeaf/TreeLeaf.tsx | 18 ---- .../src/components/TreeLeaf/TreeLeaf.types.ts | 9 -- .../__snapshots__/TreeLeaf.test.tsx.snap | 11 --- .../src/components/TreeLeaf/index.ts | 5 - .../components/TreeLeaf/renderTreeLeaf.tsx | 9 -- .../src/components/TreeLeaf/useTreeLeaf.ts | 24 ----- .../components/TreeLeaf/useTreeLeafStyles.ts | 21 ----- .../react-tree/src/contexts/index.ts | 1 + .../react-tree/src/contexts/treeContext.ts | 32 +++++++ .../react-components/react-tree/src/index.ts | 7 +- .../TreeControllingOpenAndClose.stories.tsx | 27 ++++++ .../stories/Tree/TreeDefault.stories.tsx | 22 ++++- .../react-tree/stories/Tree/index.stories.tsx | 1 + .../TreeBranch/TreeBranchBestPractices.md | 5 - .../TreeBranch/TreeBranchDefault.stories.tsx | 4 - .../TreeBranch/TreeBranchDescription.md | 0 .../stories/TreeBranch/index.stories.tsx | 18 ---- .../stories/TreeLeaf/TreeLeafBestPractices.md | 5 - .../TreeLeaf/TreeLeafDefault.stories.tsx | 4 - .../stories/TreeLeaf/TreeLeafDescription.md | 0 .../stories/TreeLeaf/index.stories.tsx | 18 ---- .../react-tree/tsconfig.spec.json | 10 +- 52 files changed, 524 insertions(+), 333 deletions(-) delete mode 100644 packages/react-components/react-tree/src/TreeBranch.ts create mode 100644 packages/react-components/react-tree/src/TreeItem.ts delete mode 100644 packages/react-components/react-tree/src/TreeLeaf.ts create mode 100644 packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.test.tsx delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.tsx delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.types.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/__snapshots__/TreeBranch.test.tsx.snap delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/index.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/renderTreeBranch.tsx delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/useTreeBranch.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeBranch/useTreeBranchStyles.ts create mode 100644 packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx create mode 100644 packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx create mode 100644 packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts create mode 100644 packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap create mode 100644 packages/react-components/react-tree/src/components/TreeItem/index.ts create mode 100644 packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx create mode 100644 packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts create mode 100644 packages/react-components/react-tree/src/components/TreeItem/useTreeItemStyles.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.test.tsx delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.tsx delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.types.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/__snapshots__/TreeLeaf.test.tsx.snap delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/index.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/renderTreeLeaf.tsx delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeaf.ts delete mode 100644 packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeafStyles.ts create mode 100644 packages/react-components/react-tree/src/contexts/index.ts create mode 100644 packages/react-components/react-tree/src/contexts/treeContext.ts create mode 100644 packages/react-components/react-tree/stories/Tree/TreeControllingOpenAndClose.stories.tsx delete mode 100644 packages/react-components/react-tree/stories/TreeBranch/TreeBranchBestPractices.md delete mode 100644 packages/react-components/react-tree/stories/TreeBranch/TreeBranchDefault.stories.tsx delete mode 100644 packages/react-components/react-tree/stories/TreeBranch/TreeBranchDescription.md delete mode 100644 packages/react-components/react-tree/stories/TreeBranch/index.stories.tsx delete mode 100644 packages/react-components/react-tree/stories/TreeLeaf/TreeLeafBestPractices.md delete mode 100644 packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDefault.stories.tsx delete mode 100644 packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDescription.md delete mode 100644 packages/react-components/react-tree/stories/TreeLeaf/index.stories.tsx diff --git a/packages/react-components/react-tree/etc/react-tree.api.md b/packages/react-components/react-tree/etc/react-tree.api.md index b35c29f663c5e..1c0e54bed770b 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -4,54 +4,60 @@ ```ts +import type { ARIAButtonElement } from '@fluentui/react-aria'; +import type { ARIAButtonSlotProps } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; // @public export const Tree: ForwardRefComponent; -// @public -export const TreeBranch: ForwardRefComponent; - // @public (undocumented) -export const treeBranchClassNames: SlotClassNames; - -// @public (undocumented) -export type TreeBranchProps = ComponentProps & {}; +export const treeClassNames: SlotClassNames; // @public (undocumented) -export type TreeBranchSlots = { - root: Slot<'div'>; +export type TreeContextValue = { + openTrees: string[]; + level: number; + treeRef: React_2.RefObject; + subtreeRef: React_2.RefObject; + isSubtree: boolean; + requestOpenChange(data: TreeOpenChangeData): void; }; // @public -export type TreeBranchState = ComponentState; +export const TreeItem: ForwardRefComponent; // @public (undocumented) -export const treeClassNames: SlotClassNames; +export const treeItemClassNames: SlotClassNames; // @public -export const TreeLeaf: ForwardRefComponent; - -// @public (undocumented) -export const treeLeafClassNames: SlotClassNames; +export type TreeItemProps = ComponentProps; // @public (undocumented) -export type TreeLeafProps = ComponentProps & {}; +export type TreeItemSlots = { + root: NonNullable>>; +}; -// @public (undocumented) -export type TreeLeafSlots = { - root: Slot<'div'>; +// @public +export type TreeItemState = ComponentState & { + open: boolean; + isLeaf: boolean; }; // @public (undocumented) -export type TreeLeafState = ComponentState; +export type TreeProps = ComponentProps & { + openSubtrees?: string | string[]; + defaultOpenSubtrees?: string | string[]; + onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; +}; // @public (undocumented) -export type TreeProps = ComponentProps & {}; +export const TreeProvider: React_2.Provider; // @public (undocumented) export type TreeSlots = { @@ -59,7 +65,12 @@ export type TreeSlots = { }; // @public -export type TreeState = ComponentState; +export type TreeState = ComponentState & Pick & { + isOpen: boolean; +}; + +// @public (undocumented) +export const useTreeContext_unstable: () => TreeContextValue; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-tree/package.json b/packages/react-components/react-tree/package.json index 41073c4278517..dc8180b39912a 100644 --- a/packages/react-components/react-tree/package.json +++ b/packages/react-components/react-tree/package.json @@ -31,6 +31,11 @@ "@fluentui/scripts": "^1.0.0" }, "dependencies": { + "@fluentui/react-context-selector": "^9.1.2", + "@fluentui/react-shared-contexts": "^9.1.1", + "@fluentui/react-aria": "^9.3.2", + "@fluentui/react-tabster": "^9.3.1", + "@fluentui/keyboard-keys": "^9.0.1", "@fluentui/react-theme": "^9.1.2", "@fluentui/react-utilities": "^9.2.2", "@griffel/react": "^1.4.2", diff --git a/packages/react-components/react-tree/src/TreeBranch.ts b/packages/react-components/react-tree/src/TreeBranch.ts deleted file mode 100644 index a97fd86a504ba..0000000000000 --- a/packages/react-components/react-tree/src/TreeBranch.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './components/TreeBranch/index'; diff --git a/packages/react-components/react-tree/src/TreeItem.ts b/packages/react-components/react-tree/src/TreeItem.ts new file mode 100644 index 0000000000000..017ab04d8956b --- /dev/null +++ b/packages/react-components/react-tree/src/TreeItem.ts @@ -0,0 +1 @@ +export * from './components/TreeItem/index'; diff --git a/packages/react-components/react-tree/src/TreeLeaf.ts b/packages/react-components/react-tree/src/TreeLeaf.ts deleted file mode 100644 index 02826ef47af59..0000000000000 --- a/packages/react-components/react-tree/src/TreeLeaf.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './components/TreeLeaf/index'; diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx b/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx index d72d4ff4a7e47..0d01a78d0ee37 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx +++ b/packages/react-components/react-tree/src/components/Tree/Tree.test.tsx @@ -7,10 +7,11 @@ describe('Tree', () => { isConformant({ Component: Tree, displayName: 'Tree', + disabledTests: ['consistent-callback-args'], }); it('renders a default state', () => { - const result = render(Default Tree); + const result = render(); expect(result.container).toMatchSnapshot(); }); }); diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.tsx b/packages/react-components/react-tree/src/components/Tree/Tree.tsx index d056c9a3d12c0..77673c412fcb0 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.tsx +++ b/packages/react-components/react-tree/src/components/Tree/Tree.tsx @@ -4,15 +4,21 @@ import { renderTree_unstable } from './renderTree'; import { useTreeStyles_unstable } from './useTreeStyles'; import type { TreeProps } from './Tree.types'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { useTreeContextValues_unstable } from './useTreeContextValues'; /** - * A tree component provides a hierarchical list + * A tree view widget presents a hierarchical list. + * Any item in the hierarchy may have child items, + * and items that have children may be expanded or collapsed to show or hide the children. + * For example, in a file system navigator that uses a tree view to display folders and files, + * an item representing a folder can be expanded to reveal the contents of the folder, + * which may be files, folders, or both. */ export const Tree: ForwardRefComponent = React.forwardRef((props, ref) => { const state = useTree_unstable(props, ref); - useTreeStyles_unstable(state); - return renderTree_unstable(state); + const contextValues = useTreeContextValues_unstable(state); + return renderTree_unstable(state, contextValues); }); Tree.displayName = 'Tree'; diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts index a67aee8b7f77f..c750b47dc54be 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts +++ b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts @@ -1,12 +1,56 @@ +import * as React from 'react'; import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { TreeItemElement } from '../TreeItem/TreeItem.types'; +import { TreeContextValue } from '../../contexts/treeContext'; export type TreeSlots = { root: Slot<'div'>; }; -export type TreeProps = ComponentProps & {}; +export type TreeOpenChangeData = { open: boolean; id: string } & ( + | { + event: React.MouseEvent; + type: 'click'; + } + | { + event: React.KeyboardEvent; + type: 'arrowRight' | 'arrowLeft'; + } +); + +export type TreeOpenChangeEvent = TreeOpenChangeData['event']; + +export type TreeContextValues = { + tree: TreeContextValue; +}; + +export type TreeProps = ComponentProps & { + /** + * Controls the state of the open subtrees. + * These property is ignored for subtrees. + */ + openSubtrees?: string | string[]; + /** + * Default value for the uncontrolled state of open subtrees. + * These property is ignored for subtrees. + */ + defaultOpenSubtrees?: string | string[]; + /** + * Callback fired when the component changes value from open state. + * These property is ignored for subtrees. + * + * @param event - a React's Synthetic event + * @param data - A data object with relevant information, + * such as open value and type of interaction that created the event + * and the id of the subtree that is being opened/closed + */ + onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; +}; /** * State used in rendering Tree */ -export type TreeState = ComponentState; +export type TreeState = ComponentState & + Pick & { + isOpen: boolean; + }; diff --git a/packages/react-components/react-tree/src/components/Tree/__snapshots__/Tree.test.tsx.snap b/packages/react-components/react-tree/src/components/Tree/__snapshots__/Tree.test.tsx.snap index fcc033a896e88..5bfda40814567 100644 --- a/packages/react-components/react-tree/src/components/Tree/__snapshots__/Tree.test.tsx.snap +++ b/packages/react-components/react-tree/src/components/Tree/__snapshots__/Tree.test.tsx.snap @@ -4,8 +4,8 @@ exports[`Tree renders a default state 1`] = `
- Default Tree -
+ data-tabster="{\\"mover\\":{\\"cyclic\\":false,\\"direction\\":1,\\"tabbable\\":true}}" + role="tree" + />
`; diff --git a/packages/react-components/react-tree/src/components/Tree/renderTree.tsx b/packages/react-components/react-tree/src/components/Tree/renderTree.tsx index 7ae8187ba06fc..afcf59e3f8186 100644 --- a/packages/react-components/react-tree/src/components/Tree/renderTree.tsx +++ b/packages/react-components/react-tree/src/components/Tree/renderTree.tsx @@ -1,9 +1,15 @@ import * as React from 'react'; import { getSlots } from '@fluentui/react-utilities'; -import type { TreeState, TreeSlots } from './Tree.types'; +import type { TreeState, TreeSlots, TreeContextValues } from './Tree.types'; +import { TreeProvider } from '../../contexts'; -export const renderTree_unstable = (state: TreeState) => { +export const renderTree_unstable = (state: TreeState, contextValues: TreeContextValues) => { + const { isOpen } = state; const { slots, slotProps } = getSlots(state); - return ; + return ( + + {isOpen && {slotProps.root.children}} + + ); }; diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index debd64978dfb6..7fb1091c2280e 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -1,6 +1,13 @@ import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { TreeProps, TreeState } from './Tree.types'; +import { + getNativeElementProps, + useControllableState, + useEventCallback, + useMergedRefs, +} from '@fluentui/react-utilities'; +import type { TreeOpenChangeData, TreeProps, TreeState } from './Tree.types'; +import { useArrowNavigationGroup } from '@fluentui/react-tabster'; +import { useTreeContext_unstable } from '../../contexts/treeContext'; /** * Create the state required to render Tree. @@ -12,13 +19,91 @@ import type { TreeProps, TreeState } from './Tree.types'; * @param ref - reference to root HTMLElement of Tree */ export const useTree_unstable = (props: TreeProps, ref: React.Ref): TreeState => { + const { + isSubtree, + level: parentLevel, + openTrees: rootOpenTrees, + requestOpenChange: rootRequestOpenChange, + treeRef: parentTreeRef, + } = useTreeContext_unstable(); + warnIfNecessary(props, isSubtree); + const { openSubtrees: stateOpenTrees, defaultOpenSubtrees: defaultOpenTrees, onOpenChange, ...rest } = props; + const arrowNavigationProps = useArrowNavigationGroup({ + tabbable: true, + axis: 'vertical', + }); + const [localOpenTrees, setOpenTrees] = useControllableState({ + state: React.useMemo(() => normalizeOpenTreesOrUndefined(stateOpenTrees), [stateOpenTrees]), + defaultState: () => normalizeOpenTrees(defaultOpenTrees), + initialState: [], + }); + const localRequestOpenChange = useEventCallback((data: TreeOpenChangeData) => { + onOpenChange?.(data.event, data); + if (!data.event.isDefaultPrevented()) { + setOpenTrees(updateOpenTrees(data, openTrees)); + } + }); + const openTrees = isSubtree ? rootOpenTrees : localOpenTrees; + const requestOpenChange = isSubtree ? rootRequestOpenChange : localRequestOpenChange; + const isOpen = React.useMemo(() => !isSubtree || props.id === undefined || openTrees.includes(props.id), [ + props.id, + openTrees, + isSubtree, + ]); + const treeRef = React.useRef(null); + const subtreeRef = React.useRef(null); + return { components: { root: 'div', }, + isOpen, + treeRef: isSubtree ? parentTreeRef : treeRef, + subtreeRef, + level: parentLevel + 1, root: getNativeElementProps('div', { - ref, - ...props, + ref: useMergedRefs(ref, isSubtree ? subtreeRef : treeRef), + role: isSubtree ? 'group' : 'tree', + ...rest, + ...(isSubtree ? undefined : arrowNavigationProps), }), + openTrees, + requestOpenChange, }; }; + +function warnIfNecessary(props: Pick, isSubtree: boolean) { + if (process.env.NODE_ENV === 'development') { + if (isSubtree) { + if (!props.id) { + // eslint-disable-next-line no-console + console.warn('as sub Tree must have an id to be referred by a TreeItem'); + } + } else if (!props['aria-label'] && !props['aria-labelledby']) { + // eslint-disable-next-line no-console + console.warn('Tree must have either a `aria-label` or `aria-labelledby` property defined'); + } + } +} + +function normalizeOpenTrees(openItems?: string | string[]) { + if (!openItems) { + return []; + } + return Array.isArray(openItems) ? openItems : [openItems]; +} + +function normalizeOpenTreesOrUndefined(openItems?: string | string[]) { + if (!openItems) { + return undefined; + } + return normalizeOpenTrees(openItems); +} + +function updateOpenTrees(data: TreeOpenChangeData, previousOpenTrees: string[]) { + if (data.open) { + return previousOpenTrees.includes(data.id) ? previousOpenTrees : [...previousOpenTrees, data.id]; + } + const nextOpenItems = previousOpenTrees.filter(value => value !== data.id); + return nextOpenItems.length === previousOpenTrees.length ? previousOpenTrees : nextOpenItems; +} diff --git a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts new file mode 100644 index 0000000000000..7c939e9e58229 --- /dev/null +++ b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts @@ -0,0 +1,26 @@ +import * as React from 'react'; +import type { TreeContextValue } from '../../contexts'; +import type { TreeContextValues, TreeState } from './Tree.types'; + +export function useTreeContextValues_unstable(state: TreeState): TreeContextValues { + const { openTrees, requestOpenChange, level, treeRef, subtreeRef } = state; + + /** + * This context is created with "@fluentui/react-context-selector", + * there is no sense to memoize it + */ + const tree: TreeContextValue = React.useMemo( + () => ({ + openTrees, + requestOpenChange, + level, + treeRef, + subtreeRef, + isSubtree: true, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [openTrees, requestOpenChange, level], + ); + + return { tree }; +} diff --git a/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts b/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts index 640380271dba4..60eb775afd9fa 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts @@ -1,4 +1,4 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; +import { mergeClasses } from '@griffel/react'; import type { TreeSlots, TreeState } from './Tree.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -6,18 +6,8 @@ export const treeClassNames: SlotClassNames = { root: 'fui-Tree', }; -const useRootStyles = makeStyles({ - root: { - display: 'block', - alignItems: 'center', - position: 'relative', - }, -}); - export const useTreeStyles_unstable = (state: TreeState): TreeState => { - const rootStyles = useRootStyles(); - - state.root.className = mergeClasses(treeClassNames.root, rootStyles.root, state.root.className); + state.root.className = mergeClasses(treeClassNames.root, state.root.className); return state; }; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.test.tsx b/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.test.tsx deleted file mode 100644 index cb4d448cb80de..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import { TreeBranch } from './TreeBranch'; -import { isConformant } from '../../testing/isConformant'; - -describe('TreeBranch', () => { - isConformant({ - Component: TreeBranch, - displayName: 'TreeBranch', - }); - - it('renders a default state', () => { - const result = render(Default TreeBranch); - expect(result.container).toMatchSnapshot(); - }); -}); diff --git a/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.tsx b/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.tsx deleted file mode 100644 index b6062e5174f91..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { useTreeBranch_unstable } from './useTreeBranch'; -import { renderTreeBranch_unstable } from './renderTreeBranch'; -import { useTreeBranchStyles_unstable } from './useTreeBranchStyles'; -import type { TreeBranchProps } from './TreeBranch.types'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; - -/** - * A tree branch component allows you to create more branches in a tree component - */ -export const TreeBranch: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useTreeBranch_unstable(props, ref); - - useTreeBranchStyles_unstable(state); - return renderTreeBranch_unstable(state); -}); - -TreeBranch.displayName = 'TreeBranch'; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.types.ts b/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.types.ts deleted file mode 100644 index 476ad60cece5a..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/TreeBranch.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; - -export type TreeBranchSlots = { - root: Slot<'div'>; -}; - -export type TreeBranchProps = ComponentProps & {}; - -/** - * State used in rendering TreeBranch - */ -export type TreeBranchState = ComponentState; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/__snapshots__/TreeBranch.test.tsx.snap b/packages/react-components/react-tree/src/components/TreeBranch/__snapshots__/TreeBranch.test.tsx.snap deleted file mode 100644 index 87dc5d70e1d84..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/__snapshots__/TreeBranch.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TreeBranch renders a default state 1`] = ` -
-
- Default TreeBranch -
-
-`; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/index.ts b/packages/react-components/react-tree/src/components/TreeBranch/index.ts deleted file mode 100644 index 06463af34a56e..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './TreeBranch'; -export * from './TreeBranch.types'; -export * from './renderTreeBranch'; -export * from './useTreeBranch'; -export * from './useTreeBranchStyles'; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/renderTreeBranch.tsx b/packages/react-components/react-tree/src/components/TreeBranch/renderTreeBranch.tsx deleted file mode 100644 index 3348b23084f64..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/renderTreeBranch.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import * as React from 'react'; -import { getSlots } from '@fluentui/react-utilities'; -import type { TreeBranchState, TreeBranchSlots } from './TreeBranch.types'; - -export const renderTreeBranch_unstable = (state: TreeBranchState) => { - const { slots, slotProps } = getSlots(state); - - return ; -}; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/useTreeBranch.ts b/packages/react-components/react-tree/src/components/TreeBranch/useTreeBranch.ts deleted file mode 100644 index 5e0555e94281c..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/useTreeBranch.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { TreeBranchProps, TreeBranchState } from './TreeBranch.types'; - -/** - * Create the state required to render TreeBranch. - * - * The returned state can be modified with hooks such as useTreeBranchStyles_unstable, - * before being passed to renderTreeBranch_unstable. - * - * @param props - props from this instance of TreeBranch - * @param ref - reference to root HTMLElement of TreeBranch - */ -export const useTreeBranch_unstable = (props: TreeBranchProps, ref: React.Ref): TreeBranchState => { - return { - components: { - root: 'div', - }, - root: getNativeElementProps('div', { - ref, - ...props, - }), - }; -}; diff --git a/packages/react-components/react-tree/src/components/TreeBranch/useTreeBranchStyles.ts b/packages/react-components/react-tree/src/components/TreeBranch/useTreeBranchStyles.ts deleted file mode 100644 index 4c9e5eb6de86a..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeBranch/useTreeBranchStyles.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { mergeClasses } from '@griffel/react'; -import type { TreeBranchSlots, TreeBranchState } from './TreeBranch.types'; -import type { SlotClassNames } from '@fluentui/react-utilities'; - -export const treeBranchClassNames: SlotClassNames = { - root: 'fui-TreeBranch', -}; - -export const useTreeBranchStyles_unstable = (state: TreeBranchState): TreeBranchState => { - state.root.className = mergeClasses(treeBranchClassNames.root, state.root.className); - return state; -}; diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx new file mode 100644 index 0000000000000..b3ff9049811c1 --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; +import { TreeItem } from './TreeItem'; +import { isConformant } from '../../testing/isConformant'; +import { TreeItemProps } from './index'; +import { resetIdsForTests } from '@fluentui/react-utilities'; + +describe('TreeItem', () => { + beforeEach(() => { + resetIdsForTests(); + }); + isConformant({ + Component: TreeItem, + displayName: 'TreeItem', + }); + + // TODO add more tests here, and create visual regression tests in /apps/vr-tests + + it('renders a default state', () => { + const result = render(Default TreeItem); + expect(result.container).toMatchSnapshot(); + }); +}); diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx new file mode 100644 index 0000000000000..53d527258abcb --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.tsx @@ -0,0 +1,18 @@ +import * as React from 'react'; +import { useTreeItem_unstable } from './useTreeItem'; +import { renderTreeItem_unstable } from './renderTreeItem'; +import { useTreeItemStyles_unstable } from './useTreeItemStyles'; +import type { TreeItemProps } from './TreeItem.types'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; + +/** + * TreeItem component - Represents a single node on the Tree + */ +export const TreeItem: ForwardRefComponent = React.forwardRef((props, ref) => { + const state = useTreeItem_unstable(props, ref); + + useTreeItemStyles_unstable(state); + return renderTreeItem_unstable(state); +}); + +TreeItem.displayName = 'TreeItem'; diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts new file mode 100644 index 0000000000000..9c8ab67383df0 --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts @@ -0,0 +1,30 @@ +import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; +import type { ARIAButtonElement, ARIAButtonElementIntersection, ARIAButtonSlotProps } from '@fluentui/react-aria'; + +export type TreeItemElement = ARIAButtonElement<'div' | 'a'>; + +/** @internal */ +export type TreeItemElementIntersection = ARIAButtonElementIntersection<'div' | 'a'>; + +export type TreeItemSlots = { + /** + * TreeItem root wraps around `props.content` + */ + root: NonNullable>>; +}; + +/** + * TreeItem Props + */ +export type TreeItemProps = ComponentProps; + +/** + * State used in rendering TreeItem + */ +export type TreeItemState = ComponentState & { + open: boolean; + /** + * indicates if this TreeItem is a leaf (has no subtree) + */ + isLeaf: boolean; +}; diff --git a/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap b/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap new file mode 100644 index 0000000000000..5a041001ae09e --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TreeItem renders a default state 1`] = ` +
+
+ Default TreeItem +
+
+`; diff --git a/packages/react-components/react-tree/src/components/TreeItem/index.ts b/packages/react-components/react-tree/src/components/TreeItem/index.ts new file mode 100644 index 0000000000000..782392551a2c7 --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/index.ts @@ -0,0 +1,5 @@ +export * from './TreeItem'; +export * from './TreeItem.types'; +export * from './renderTreeItem'; +export * from './useTreeItem'; +export * from './useTreeItemStyles'; diff --git a/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx b/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx new file mode 100644 index 0000000000000..542de9bcab404 --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { getSlots } from '@fluentui/react-utilities'; +import type { TreeItemState, TreeItemSlots } from './TreeItem.types'; + +/** + * Render the final JSX of TreeItem + */ +export const renderTreeItem_unstable = (state: TreeItemState) => { + const { slots, slotProps } = getSlots(state); + + return ; +}; diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts new file mode 100644 index 0000000000000..36ffa5b5bbf09 --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts @@ -0,0 +1,89 @@ +import * as React from 'react'; +import { getNativeElementProps, useEventCallback } from '@fluentui/react-utilities'; +import type { TreeItemElement, TreeItemElementIntersection, TreeItemProps, TreeItemState } from './TreeItem.types'; +import { useARIAButtonProps } from '@fluentui/react-aria'; +import { useFocusFinders } from '@fluentui/react-tabster'; +import { ArrowRight, ArrowLeft } from '@fluentui/keyboard-keys'; +import { useTreeContext_unstable } from '../../contexts/treeContext'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +/** + * Create the state required to render TreeItem. + * + * The returned state can be modified with hooks such as useTreeItemStyles_unstable, + * before being passed to renderTreeItem_unstable. + * + * @param props - props from this instance of TreeItem + * @param ref - reference to root HTMLElement of TreeItem + */ +export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref): TreeItemState => { + const { 'aria-owns': ariaOwns, as = 'div', onKeyDown, ...rest } = props; + + const { openTrees, requestOpenChange, level, treeRef, subtreeRef } = useTreeContext_unstable(); + const { findFirstFocusable } = useFocusFinders(); + const { targetDocument } = useFluent_unstable(); + + const isBranch = typeof ariaOwns === 'string'; + const open = React.useMemo(() => isBranch && openTrees.includes(ariaOwns!), [ariaOwns, openTrees, isBranch]); + + const handleClick = useEventCallback((event: React.MouseEvent) => { + if (isBranch) { + requestOpenChange({ event, open: !open, type: 'click', id: ariaOwns! }); + } + }); + const handleArrowRight = (event: React.KeyboardEvent) => { + if (open && isBranch) { + // find first focusable on the subtree and focus on it + const subtree = targetDocument?.getElementById(ariaOwns!); + if (subtree) { + subtree && findFirstFocusable(subtree)?.focus(); + } + } + if (isBranch && !open) { + requestOpenChange({ event, open: true, type: 'arrowRight', id: ariaOwns! }); + } + }; + const handleArrowLeft = (event: React.KeyboardEvent) => { + if ((!isBranch || !open) && subtreeRef.current && subtreeRef.current.id) { + treeRef.current?.querySelector(`[aria-owns="${subtreeRef.current.id}"]`)?.focus(); + } + if (isBranch && open) { + requestOpenChange({ event, open: false, type: 'arrowLeft', id: ariaOwns! }); + } + }; + const handleKeyDown = useEventCallback((event: React.KeyboardEvent) => { + onKeyDown?.(event); + if (event.isDefaultPrevented()) { + return; + } + switch (event.code) { + case ArrowRight: { + return handleArrowRight(event); + } + case ArrowLeft: { + return handleArrowLeft(event); + } + } + }); + return { + components: { + root: 'div', + }, + open, + isLeaf: !isBranch, + root: getNativeElementProps( + as, + useARIAButtonProps(as, { + ...rest, + // casting here is required to convert union to intersection + ref: ref as React.Ref, + 'aria-owns': ariaOwns, + 'aria-level': level, + // FIXME: tabster fails to navigate when aria-expanded is true + // 'aria-expanded': isBranch ? isOpen : undefined, + role: 'treeitem', + onClick: handleClick, + onKeyDown: handleKeyDown, + }), + ), + }; +}; diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItemStyles.ts b/packages/react-components/react-tree/src/components/TreeItem/useTreeItemStyles.ts new file mode 100644 index 0000000000000..55236f4b10ee4 --- /dev/null +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItemStyles.ts @@ -0,0 +1,15 @@ +import { mergeClasses } from '@griffel/react'; +import type { TreeItemSlots, TreeItemState } from './TreeItem.types'; +import type { SlotClassNames } from '@fluentui/react-utilities'; + +export const treeItemClassNames: SlotClassNames = { + root: 'fui-TreeItem', +}; + +/** + * Apply styling to the TreeItem slots based on the state + */ +export const useTreeItemStyles_unstable = (state: TreeItemState): TreeItemState => { + state.root.className = mergeClasses(treeItemClassNames.root, state.root.className); + return state; +}; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.test.tsx b/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.test.tsx deleted file mode 100644 index 8c5275448d9d6..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import { TreeLeaf } from './TreeLeaf'; -import { isConformant } from '../../testing/isConformant'; - -describe('TreeLeaf', () => { - isConformant({ - Component: TreeLeaf, - displayName: 'TreeLeaf', - }); - - it('renders a default state', () => { - const result = render(Default TreeLeaf); - expect(result.container).toMatchSnapshot(); - }); -}); diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.tsx b/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.tsx deleted file mode 100644 index 610c9609142dd..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { useTreeLeaf_unstable } from './useTreeLeaf'; -import { renderTreeLeaf_unstable } from './renderTreeLeaf'; -import { useTreeLeafStyles_unstable } from './useTreeLeafStyles'; -import type { TreeLeafProps } from './TreeLeaf.types'; -import type { ForwardRefComponent } from '@fluentui/react-utilities'; - -/** - * A tree leaf component are the items in the tree component and its branches - */ -export const TreeLeaf: ForwardRefComponent = React.forwardRef((props, ref) => { - const state = useTreeLeaf_unstable(props, ref); - - useTreeLeafStyles_unstable(state); - return renderTreeLeaf_unstable(state); -}); - -TreeLeaf.displayName = 'TreeLeaf'; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.types.ts b/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.types.ts deleted file mode 100644 index 4e5bdc127d239..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/TreeLeaf.types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities'; - -export type TreeLeafSlots = { - root: Slot<'div'>; -}; - -export type TreeLeafProps = ComponentProps & {}; - -export type TreeLeafState = ComponentState; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/__snapshots__/TreeLeaf.test.tsx.snap b/packages/react-components/react-tree/src/components/TreeLeaf/__snapshots__/TreeLeaf.test.tsx.snap deleted file mode 100644 index 4cfffcd0d648e..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/__snapshots__/TreeLeaf.test.tsx.snap +++ /dev/null @@ -1,11 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`TreeLeaf renders a default state 1`] = ` -
-
- Default TreeLeaf -
-
-`; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/index.ts b/packages/react-components/react-tree/src/components/TreeLeaf/index.ts deleted file mode 100644 index 6d17d11b94f88..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './TreeLeaf'; -export * from './TreeLeaf.types'; -export * from './renderTreeLeaf'; -export * from './useTreeLeaf'; -export * from './useTreeLeafStyles'; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/renderTreeLeaf.tsx b/packages/react-components/react-tree/src/components/TreeLeaf/renderTreeLeaf.tsx deleted file mode 100644 index 8ca755c8ef7f8..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/renderTreeLeaf.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import * as React from 'react'; -import { getSlots } from '@fluentui/react-utilities'; -import type { TreeLeafState, TreeLeafSlots } from './TreeLeaf.types'; - -export const renderTreeLeaf_unstable = (state: TreeLeafState) => { - const { slots, slotProps } = getSlots(state); - - return ; -}; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeaf.ts b/packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeaf.ts deleted file mode 100644 index 4948fda2a329e..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeaf.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { getNativeElementProps } from '@fluentui/react-utilities'; -import type { TreeLeafProps, TreeLeafState } from './TreeLeaf.types'; - -/** - * Create the state required to render TreeLeaf. - * - * The returned state can be modified with hooks such as useTreeLeafStyles_unstable, - * before being passed to renderTreeLeaf_unstable. - * - * @param props - props from this instance of TreeLeaf - * @param ref - reference to root HTMLElement of TreeLeaf - */ -export const useTreeLeaf_unstable = (props: TreeLeafProps, ref: React.Ref): TreeLeafState => { - return { - components: { - root: 'div', - }, - root: getNativeElementProps('div', { - ref, - ...props, - }), - }; -}; diff --git a/packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeafStyles.ts b/packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeafStyles.ts deleted file mode 100644 index 434b6929b5be7..0000000000000 --- a/packages/react-components/react-tree/src/components/TreeLeaf/useTreeLeafStyles.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { makeStyles, mergeClasses } from '@griffel/react'; -import type { TreeLeafSlots, TreeLeafState } from './TreeLeaf.types'; -import type { SlotClassNames } from '@fluentui/react-utilities'; - -export const treeLeafClassNames: SlotClassNames = { - root: 'fui-TreeLeaf', -}; - -/** - * Styles for the root slot - */ -const useStyles = makeStyles({ - root: {}, -}); - -export const useTreeLeafStyles_unstable = (state: TreeLeafState): TreeLeafState => { - const styles = useStyles(); - state.root.className = mergeClasses(treeLeafClassNames.root, styles.root, state.root.className); - - return state; -}; diff --git a/packages/react-components/react-tree/src/contexts/index.ts b/packages/react-components/react-tree/src/contexts/index.ts new file mode 100644 index 0000000000000..4393133d51513 --- /dev/null +++ b/packages/react-components/react-tree/src/contexts/index.ts @@ -0,0 +1 @@ +export * from './treeContext'; diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts new file mode 100644 index 0000000000000..5070f892fab75 --- /dev/null +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -0,0 +1,32 @@ +import { TreeOpenChangeData } from '../Tree'; +import * as React from 'react'; + +export type TreeContextValue = { + openTrees: string[]; + level: number; + treeRef: React.RefObject; + subtreeRef: React.RefObject; + isSubtree: boolean; + /** + * Requests dialog main component to update it's internal open state + */ + requestOpenChange(data: TreeOpenChangeData): void; +}; + +const defaultContextValue: TreeContextValue = { + level: 0, + isSubtree: false, + treeRef: React.createRef(), + subtreeRef: React.createRef(), + openTrees: [], + requestOpenChange() { + /* noop */ + }, +}; + +export const TreeContext: React.Context = React.createContext< + TreeContextValue | undefined +>(undefined); + +export const { Provider: TreeProvider } = TreeContext; +export const useTreeContext_unstable = (): TreeContextValue => React.useContext(TreeContext) ?? defaultContextValue; diff --git a/packages/react-components/react-tree/src/index.ts b/packages/react-components/react-tree/src/index.ts index e60de76ce33b3..50487eaadfc15 100644 --- a/packages/react-components/react-tree/src/index.ts +++ b/packages/react-components/react-tree/src/index.ts @@ -1,8 +1,7 @@ export { Tree, treeClassNames } from './Tree'; export type { TreeProps, TreeState, TreeSlots } from './Tree'; -export { TreeBranch, treeBranchClassNames } from './TreeBranch'; -export type { TreeBranchProps, TreeBranchState, TreeBranchSlots } from './TreeBranch'; +export { TreeItem, treeItemClassNames } from './TreeItem'; +export type { TreeItemProps, TreeItemState, TreeItemSlots } from './TreeItem'; -export { TreeLeaf, treeLeafClassNames } from './TreeLeaf'; -export type { TreeLeafProps, TreeLeafState, TreeLeafSlots } from './TreeLeaf'; +export type { TreeContextValue, useTreeContext_unstable, TreeProvider } from './contexts'; diff --git a/packages/react-components/react-tree/stories/Tree/TreeControllingOpenAndClose.stories.tsx b/packages/react-components/react-tree/stories/Tree/TreeControllingOpenAndClose.stories.tsx new file mode 100644 index 0000000000000..3d0c656193095 --- /dev/null +++ b/packages/react-components/react-tree/stories/Tree/TreeControllingOpenAndClose.stories.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import { Tree, TreeItem } from '@fluentui/react-tree'; +import { TreeOpenChangeData, TreeOpenChangeEvent } from '../../src/Tree'; + +export const ControllingOpenAndClose = () => { + const [openSubtrees, setOpenSubtrees] = React.useState([]); + const handleOpenChange = (event: TreeOpenChangeEvent, data: TreeOpenChangeData) => { + setOpenSubtrees(curr => (data.open ? [...curr, data.id] : curr.filter(id => id !== data.id))); + }; + return ( + + level 1, item 1 + + level 2, item 1 + level 2, item 2 + level 2, item 3 + + level 1, item 2 + + level 2, item 1 + + level 3, item 1 + + + + ); +}; diff --git a/packages/react-components/react-tree/stories/Tree/TreeDefault.stories.tsx b/packages/react-components/react-tree/stories/Tree/TreeDefault.stories.tsx index e114e005b5a88..b33478f1b3f0e 100644 --- a/packages/react-components/react-tree/stories/Tree/TreeDefault.stories.tsx +++ b/packages/react-components/react-tree/stories/Tree/TreeDefault.stories.tsx @@ -1,4 +1,22 @@ import * as React from 'react'; -import { Tree, TreeProps } from '@fluentui/react-tree'; +import { Tree, TreeItem } from '@fluentui/react-tree'; -export const Default = (props: Partial) => ; +export const Default = () => { + return ( + + level 1, item 1 + + level 2, item 1 + level 2, item 2 + level 2, item 3 + + level 1, item 2 + + level 2, item 1 + + level 3, item 1 + + + + ); +}; diff --git a/packages/react-components/react-tree/stories/Tree/index.stories.tsx b/packages/react-components/react-tree/stories/Tree/index.stories.tsx index b69ab0b47d79a..af4e97c4dfa3a 100644 --- a/packages/react-components/react-tree/stories/Tree/index.stories.tsx +++ b/packages/react-components/react-tree/stories/Tree/index.stories.tsx @@ -4,6 +4,7 @@ import descriptionMd from './TreeDescription.md'; import bestPracticesMd from './TreeBestPractices.md'; export { Default } from './TreeDefault.stories'; +export { ControllingOpenAndClose } from './TreeControllingOpenAndClose.stories'; export default { title: 'Preview Components/Tree', diff --git a/packages/react-components/react-tree/stories/TreeBranch/TreeBranchBestPractices.md b/packages/react-components/react-tree/stories/TreeBranch/TreeBranchBestPractices.md deleted file mode 100644 index 08ff8ddeeb5f8..0000000000000 --- a/packages/react-components/react-tree/stories/TreeBranch/TreeBranchBestPractices.md +++ /dev/null @@ -1,5 +0,0 @@ -## Best practices - -### Do - -### Don't diff --git a/packages/react-components/react-tree/stories/TreeBranch/TreeBranchDefault.stories.tsx b/packages/react-components/react-tree/stories/TreeBranch/TreeBranchDefault.stories.tsx deleted file mode 100644 index 44a9adf616849..0000000000000 --- a/packages/react-components/react-tree/stories/TreeBranch/TreeBranchDefault.stories.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import { TreeBranch, TreeBranchProps } from '@fluentui/react-tree'; - -export const Default = (props: Partial) => ; diff --git a/packages/react-components/react-tree/stories/TreeBranch/TreeBranchDescription.md b/packages/react-components/react-tree/stories/TreeBranch/TreeBranchDescription.md deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/react-components/react-tree/stories/TreeBranch/index.stories.tsx b/packages/react-components/react-tree/stories/TreeBranch/index.stories.tsx deleted file mode 100644 index 8d63b4b44a73c..0000000000000 --- a/packages/react-components/react-tree/stories/TreeBranch/index.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TreeBranch } from '@fluentui/react-tree'; - -import descriptionMd from './TreeBranchDescription.md'; -import bestPracticesMd from './TreeBranchBestPractices.md'; - -export { Default } from './TreeBranchDefault.stories'; - -export default { - title: 'Preview Components/TreeBranch', - component: TreeBranch, - parameters: { - docs: { - description: { - component: [descriptionMd, bestPracticesMd].join('\n'), - }, - }, - }, -}; diff --git a/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafBestPractices.md b/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafBestPractices.md deleted file mode 100644 index 08ff8ddeeb5f8..0000000000000 --- a/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafBestPractices.md +++ /dev/null @@ -1,5 +0,0 @@ -## Best practices - -### Do - -### Don't diff --git a/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDefault.stories.tsx b/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDefault.stories.tsx deleted file mode 100644 index 58f608d72e318..0000000000000 --- a/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDefault.stories.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import * as React from 'react'; -import { TreeLeaf, TreeLeafProps } from '@fluentui/react-tree'; - -export const Default = (props: Partial) => ; diff --git a/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDescription.md b/packages/react-components/react-tree/stories/TreeLeaf/TreeLeafDescription.md deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/packages/react-components/react-tree/stories/TreeLeaf/index.stories.tsx b/packages/react-components/react-tree/stories/TreeLeaf/index.stories.tsx deleted file mode 100644 index 5f2ce8a9b525b..0000000000000 --- a/packages/react-components/react-tree/stories/TreeLeaf/index.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { TreeLeaf } from '@fluentui/react-tree'; - -import descriptionMd from './TreeLeafDescription.md'; -import bestPracticesMd from './TreeLeafBestPractices.md'; - -export { Default } from './TreeLeafDefault.stories'; - -export default { - title: 'Preview Components/TreeLeaf', - component: TreeLeaf, - parameters: { - docs: { - description: { - component: [descriptionMd, bestPracticesMd].join('\n'), - }, - }, - }, -}; diff --git a/packages/react-components/react-tree/tsconfig.spec.json b/packages/react-components/react-tree/tsconfig.spec.json index 469fcba4d7ba7..911456fe4b4d9 100644 --- a/packages/react-components/react-tree/tsconfig.spec.json +++ b/packages/react-components/react-tree/tsconfig.spec.json @@ -5,5 +5,13 @@ "outDir": "dist", "types": ["jest", "node"] }, - "include": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.test.ts", "**/*.test.tsx", "**/*.d.ts"] + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.d.ts", + "./src/testing/**/*.ts", + "./src/testing/**/*.tsx" + ] } From f9ec167455c929852c4d6667a916aac1893684ae Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 23 Nov 2022 13:45:46 +0000 Subject: [PATCH 2/4] chore: convert context to use context selector --- .../react-tree/etc/react-tree.api.md | 5 +++-- .../react-tree/src/components/Tree/useTree.ts | 12 +++++------- .../src/components/TreeItem/useTreeItem.ts | 7 +++++-- .../react-tree/src/contexts/treeContext.ts | 10 ++++++---- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/react-components/react-tree/etc/react-tree.api.md b/packages/react-components/react-tree/etc/react-tree.api.md index 1c0e54bed770b..e50fe90e02619 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -8,6 +8,7 @@ import type { ARIAButtonElement } from '@fluentui/react-aria'; import type { ARIAButtonSlotProps } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; +import { ContextSelector } from '@fluentui/react-context-selector'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; @@ -57,7 +58,7 @@ export type TreeProps = ComponentProps & { }; // @public (undocumented) -export const TreeProvider: React_2.Provider; +export const TreeProvider: React_2.Provider & React_2.FC>; // @public (undocumented) export type TreeSlots = { @@ -70,7 +71,7 @@ export type TreeState = ComponentState & Pick TreeContextValue; +export const useTreeContext_unstable: (selector: ContextSelector) => T; // (No @packageDocumentation comment for this package) diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index 7fb1091c2280e..d6530cf1c11d8 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -19,13 +19,11 @@ import { useTreeContext_unstable } from '../../contexts/treeContext'; * @param ref - reference to root HTMLElement of Tree */ export const useTree_unstable = (props: TreeProps, ref: React.Ref): TreeState => { - const { - isSubtree, - level: parentLevel, - openTrees: rootOpenTrees, - requestOpenChange: rootRequestOpenChange, - treeRef: parentTreeRef, - } = useTreeContext_unstable(); + const isSubtree = useTreeContext_unstable(ctx => ctx.isSubtree); + const parentLevel = useTreeContext_unstable(ctx => ctx.level); + const rootOpenTrees = useTreeContext_unstable(ctx => ctx.openTrees); + const rootRequestOpenChange = useTreeContext_unstable(ctx => ctx.requestOpenChange); + const parentTreeRef = useTreeContext_unstable(ctx => ctx.treeRef); warnIfNecessary(props, isSubtree); const { openSubtrees: stateOpenTrees, defaultOpenSubtrees: defaultOpenTrees, onOpenChange, ...rest } = props; const arrowNavigationProps = useArrowNavigationGroup({ diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts index 36ffa5b5bbf09..926d3ab7b1abd 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts @@ -18,12 +18,15 @@ import { useFluent_unstable } from '@fluentui/react-shared-contexts'; export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref): TreeItemState => { const { 'aria-owns': ariaOwns, as = 'div', onKeyDown, ...rest } = props; - const { openTrees, requestOpenChange, level, treeRef, subtreeRef } = useTreeContext_unstable(); + const requestOpenChange = useTreeContext_unstable(ctx => ctx.requestOpenChange); + const level = useTreeContext_unstable(ctx => ctx.level); + const treeRef = useTreeContext_unstable(ctx => ctx.treeRef); + const subtreeRef = useTreeContext_unstable(ctx => ctx.subtreeRef); const { findFirstFocusable } = useFocusFinders(); const { targetDocument } = useFluent_unstable(); const isBranch = typeof ariaOwns === 'string'; - const open = React.useMemo(() => isBranch && openTrees.includes(ariaOwns!), [ariaOwns, openTrees, isBranch]); + const open = useTreeContext_unstable(ctx => isBranch && ctx.openTrees.includes(ariaOwns!)); const handleClick = useEventCallback((event: React.MouseEvent) => { if (isBranch) { diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts index 5070f892fab75..31699f073c8dc 100644 --- a/packages/react-components/react-tree/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -1,3 +1,4 @@ +import { Context, ContextSelector, createContext, useContextSelector } from '@fluentui/react-context-selector'; import { TreeOpenChangeData } from '../Tree'; import * as React from 'react'; @@ -24,9 +25,10 @@ const defaultContextValue: TreeContextValue = { }, }; -export const TreeContext: React.Context = React.createContext< - TreeContextValue | undefined ->(undefined); +export const TreeContext: Context = createContext( + undefined, +); export const { Provider: TreeProvider } = TreeContext; -export const useTreeContext_unstable = (): TreeContextValue => React.useContext(TreeContext) ?? defaultContextValue; +export const useTreeContext_unstable = (selector: ContextSelector): T => + useContextSelector(TreeContext, (ctx = defaultContextValue) => selector(ctx)); From 48071f7f0b6f0a656c2a94b5658218865fb75da7 Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Wed, 23 Nov 2022 22:11:57 +0000 Subject: [PATCH 3/4] chore: converts focus to use a tree walker --- .../react-tree/etc/react-tree.api.md | 21 +- .../src/components/Tree/Tree.types.ts | 4 +- .../src/components/Tree/renderTree.tsx | 4 +- .../react-tree/src/components/Tree/useTree.ts | 184 +++++++++++++----- .../components/Tree/useTreeContextValues.ts | 25 +-- .../src/components/TreeItem/TreeItem.types.ts | 8 +- .../src/components/TreeItem/useTreeItem.ts | 24 +-- .../react-tree/src/contexts/treeContext.ts | 20 +- .../react-tree/src/utils/useTreeWalker.ts | 16 ++ 9 files changed, 191 insertions(+), 115 deletions(-) create mode 100644 packages/react-components/react-tree/src/utils/useTreeWalker.ts diff --git a/packages/react-components/react-tree/etc/react-tree.api.md b/packages/react-components/react-tree/etc/react-tree.api.md index e50fe90e02619..2f6c6cc1814c3 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -9,7 +9,10 @@ import type { ARIAButtonSlotProps } from '@fluentui/react-aria'; import type { ComponentProps } from '@fluentui/react-utilities'; import type { ComponentState } from '@fluentui/react-utilities'; import { ContextSelector } from '@fluentui/react-context-selector'; +import { FC } from 'react'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { Provider } from 'react'; +import { ProviderProps } from 'react'; import * as React_2 from 'react'; import type { Slot } from '@fluentui/react-utilities'; import type { SlotClassNames } from '@fluentui/react-utilities'; @@ -22,11 +25,10 @@ export const treeClassNames: SlotClassNames; // @public (undocumented) export type TreeContextValue = { - openTrees: string[]; level: number; - treeRef: React_2.RefObject; - subtreeRef: React_2.RefObject; - isSubtree: boolean; + openSubtrees: string[]; + focusFirstSubtreeItem(target: TreeItemElement): void; + focusSubtreeOwnerItem(target: TreeItemElement): void; requestOpenChange(data: TreeOpenChangeData): void; }; @@ -45,10 +47,7 @@ export type TreeItemSlots = { }; // @public -export type TreeItemState = ComponentState & { - open: boolean; - isLeaf: boolean; -}; +export type TreeItemState = ComponentState; // @public (undocumented) export type TreeProps = ComponentProps & { @@ -58,7 +57,7 @@ export type TreeProps = ComponentProps & { }; // @public (undocumented) -export const TreeProvider: React_2.Provider & React_2.FC>; +export const TreeProvider: Provider & FC>; // @public (undocumented) export type TreeSlots = { @@ -66,8 +65,8 @@ export type TreeSlots = { }; // @public -export type TreeState = ComponentState & Pick & { - isOpen: boolean; +export type TreeState = ComponentState & TreeContextValue & { + open: boolean; }; // @public (undocumented) diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts index c750b47dc54be..43ac520762ed4 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts +++ b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts @@ -51,6 +51,6 @@ export type TreeProps = ComponentProps & { * State used in rendering Tree */ export type TreeState = ComponentState & - Pick & { - isOpen: boolean; + TreeContextValue & { + open: boolean; }; diff --git a/packages/react-components/react-tree/src/components/Tree/renderTree.tsx b/packages/react-components/react-tree/src/components/Tree/renderTree.tsx index afcf59e3f8186..b821e143fad8d 100644 --- a/packages/react-components/react-tree/src/components/Tree/renderTree.tsx +++ b/packages/react-components/react-tree/src/components/Tree/renderTree.tsx @@ -4,12 +4,12 @@ import type { TreeState, TreeSlots, TreeContextValues } from './Tree.types'; import { TreeProvider } from '../../contexts'; export const renderTree_unstable = (state: TreeState, contextValues: TreeContextValues) => { - const { isOpen } = state; + const { open } = state; const { slots, slotProps } = getSlots(state); return ( - {isOpen && {slotProps.root.children}} + {open && {slotProps.root.children}} ); }; diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index d6530cf1c11d8..bbb120cab3066 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -5,9 +5,12 @@ import { useEventCallback, useMergedRefs, } from '@fluentui/react-utilities'; -import type { TreeOpenChangeData, TreeProps, TreeState } from './Tree.types'; +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import { useArrowNavigationGroup } from '@fluentui/react-tabster'; +import type { TreeOpenChangeData, TreeProps, TreeState } from './Tree.types'; import { useTreeContext_unstable } from '../../contexts/treeContext'; +import { useTreeWalker } from '../../utils/useTreeWalker'; +import { TreeItemElement } from '../TreeItem/TreeItem.types'; /** * Create the state required to render Tree. @@ -19,89 +22,168 @@ import { useTreeContext_unstable } from '../../contexts/treeContext'; * @param ref - reference to root HTMLElement of Tree */ export const useTree_unstable = (props: TreeProps, ref: React.Ref): TreeState => { - const isSubtree = useTreeContext_unstable(ctx => ctx.isSubtree); + const isSubtree = useTreeContext_unstable(ctx => ctx.level > 0); + // as isSubtree is static, this doesn't break rule of hooks + // and if this becomes an issue later on, this can be easily converted + // eslint-disable-next-line react-hooks/rules-of-hooks + return isSubtree ? useSubtree(props, ref) : useRootTree(props, ref); +}; + +/** + * Create the common state required to render Tree. + * + * The returned state can be modified with hooks such as useTreeStyles_unstable, + * before being passed to renderTree_unstable. + * + * @param props - props from this instance of Tree + * @param ref - reference to root HTMLElement of Tree + */ +function useSubtree(props: TreeProps, ref: React.Ref): TreeState { const parentLevel = useTreeContext_unstable(ctx => ctx.level); - const rootOpenTrees = useTreeContext_unstable(ctx => ctx.openTrees); - const rootRequestOpenChange = useTreeContext_unstable(ctx => ctx.requestOpenChange); - const parentTreeRef = useTreeContext_unstable(ctx => ctx.treeRef); - warnIfNecessary(props, isSubtree); - const { openSubtrees: stateOpenTrees, defaultOpenSubtrees: defaultOpenTrees, onOpenChange, ...rest } = props; + const focusFirstSubtreeItem = useTreeContext_unstable(ctx => ctx.focusFirstSubtreeItem); + const focusSubtreeOwnerItem = useTreeContext_unstable(ctx => ctx.focusSubtreeOwnerItem); + const openSubtrees = useTreeContext_unstable(ctx => ctx.openSubtrees); + const requestOpenChange = useTreeContext_unstable(ctx => ctx.requestOpenChange); + const isSubtree = parentLevel > 0; + + if (isSubtree) { + warnIfNoProperPropsSubtree(props); + } + + const open = useTreeContext_unstable( + ctx => !isSubtree || props.id === undefined || ctx.openSubtrees.includes(props.id), + ); const arrowNavigationProps = useArrowNavigationGroup({ tabbable: true, axis: 'vertical', }); - const [localOpenTrees, setOpenTrees] = useControllableState({ - state: React.useMemo(() => normalizeOpenTreesOrUndefined(stateOpenTrees), [stateOpenTrees]), - defaultState: () => normalizeOpenTrees(defaultOpenTrees), - initialState: [], - }); - const localRequestOpenChange = useEventCallback((data: TreeOpenChangeData) => { - onOpenChange?.(data.event, data); - if (!data.event.isDefaultPrevented()) { - setOpenTrees(updateOpenTrees(data, openTrees)); - } - }); - const openTrees = isSubtree ? rootOpenTrees : localOpenTrees; - const requestOpenChange = isSubtree ? rootRequestOpenChange : localRequestOpenChange; - const isOpen = React.useMemo(() => !isSubtree || props.id === undefined || openTrees.includes(props.id), [ - props.id, - openTrees, - isSubtree, - ]); - const treeRef = React.useRef(null); - const subtreeRef = React.useRef(null); return { components: { root: 'div', }, - isOpen, - treeRef: isSubtree ? parentTreeRef : treeRef, - subtreeRef, + open, level: parentLevel + 1, + openSubtrees, + requestOpenChange, + focusFirstSubtreeItem, + focusSubtreeOwnerItem, root: getNativeElementProps('div', { - ref: useMergedRefs(ref, isSubtree ? subtreeRef : treeRef), + ref, role: isSubtree ? 'group' : 'tree', - ...rest, + ...props, ...(isSubtree ? undefined : arrowNavigationProps), }), - openTrees, + }; +} + +/** + * Create the state required to render the root Tree. + * + * The returned state can be modified with hooks such as useTreeStyles_unstable, + * before being passed to renderTree_unstable. + * + * @param props - props from this instance of Tree + * @param ref - reference to root HTMLElement of Tree + */ +function useRootTree(props: TreeProps, ref: React.Ref): TreeState { + warnIfNoProperPropsRootTree(props); + const { openSubtrees: stateOpenSubtrees, defaultOpenSubtrees, onOpenChange } = props; + const [openSubtrees, setOpenSubtrees] = useControllableState({ + state: React.useMemo(() => normalizeOpenSubtreesOrUndefined(stateOpenSubtrees), [stateOpenSubtrees]), + defaultState: () => normalizeOpenSubtrees(defaultOpenSubtrees), + initialState: [], + }); + const { targetDocument } = useFluent_unstable(); + const requestOpenChange = useEventCallback((data: TreeOpenChangeData) => { + onOpenChange?.(data.event, data); + if (!data.event.isDefaultPrevented()) { + setOpenSubtrees(updateOpenSubtrees(data, openSubtrees)); + } + }); + const { treeWalker: treeWalkerRef, root: treeRef } = useTreeWalker(NodeFilter.SHOW_ELEMENT, { + acceptNode: filterTreeItemAndSubtree, + }); + const commonState = useSubtree(props, useMergedRefs(ref, treeRef)); + return { + ...commonState, + openSubtrees, requestOpenChange, + focusFirstSubtreeItem: useEventCallback(target => { + const treeWalker = treeWalkerRef.current; + if (!treeWalker) { + return; + } + const groupId = target.getAttribute('aria-owns'); + if (groupId && targetDocument) { + const element = targetDocument.getElementById(groupId); + if (treeWalker && element) { + treeWalker.currentNode = element; + const firstTreeItem = treeWalker.firstChild() as TreeItemElement | null; + return firstTreeItem?.focus(); + } + } + }), + focusSubtreeOwnerItem: useEventCallback(target => { + const treeWalker = treeWalkerRef.current; + if (!treeWalker) { + return; + } + treeWalker.currentNode = target; + const group = treeWalker.parentNode() as HTMLElement | null; + if (group) { + while (treeWalker.previousNode()) { + const treeItem = treeWalker.currentNode as TreeItemElement; + if (treeItem.getAttribute('aria-owns') === group.id) { + return treeItem.focus(); + } + } + } + }), }; -}; +} + +function filterTreeItemAndSubtree(node: Node) { + const element = node as HTMLElement & { role: string }; + return element.role === 'treeitem' || element.role === 'group' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; +} -function warnIfNecessary(props: Pick, isSubtree: boolean) { +function warnIfNoProperPropsSubtree(props: Pick) { if (process.env.NODE_ENV === 'development') { - if (isSubtree) { - if (!props.id) { - // eslint-disable-next-line no-console - console.warn('as sub Tree must have an id to be referred by a TreeItem'); - } - } else if (!props['aria-label'] && !props['aria-labelledby']) { + if (!props.id) { + // eslint-disable-next-line no-console + console.warn('as sub Tree must have an id to be referred by a TreeItem'); + } + } +} + +function warnIfNoProperPropsRootTree(props: Pick) { + if (process.env.NODE_ENV === 'development') { + if (!props['aria-label'] && !props['aria-labelledby']) { // eslint-disable-next-line no-console console.warn('Tree must have either a `aria-label` or `aria-labelledby` property defined'); } } } -function normalizeOpenTrees(openItems?: string | string[]) { - if (!openItems) { +function normalizeOpenSubtrees(openSubtrees?: string | string[]) { + if (!openSubtrees) { return []; } - return Array.isArray(openItems) ? openItems : [openItems]; + return Array.isArray(openSubtrees) ? openSubtrees : [openSubtrees]; } -function normalizeOpenTreesOrUndefined(openItems?: string | string[]) { - if (!openItems) { +function normalizeOpenSubtreesOrUndefined(openSubtrees?: string | string[]) { + if (!openSubtrees) { return undefined; } - return normalizeOpenTrees(openItems); + return normalizeOpenSubtrees(openSubtrees); } -function updateOpenTrees(data: TreeOpenChangeData, previousOpenTrees: string[]) { +function updateOpenSubtrees(data: TreeOpenChangeData, previousOpenSubtrees: string[]) { if (data.open) { - return previousOpenTrees.includes(data.id) ? previousOpenTrees : [...previousOpenTrees, data.id]; + return previousOpenSubtrees.includes(data.id) ? previousOpenSubtrees : [...previousOpenSubtrees, data.id]; } - const nextOpenItems = previousOpenTrees.filter(value => value !== data.id); - return nextOpenItems.length === previousOpenTrees.length ? previousOpenTrees : nextOpenItems; + const nextOpenItems = previousOpenSubtrees.filter(value => value !== data.id); + return nextOpenItems.length === previousOpenSubtrees.length ? previousOpenSubtrees : nextOpenItems; } diff --git a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts index 7c939e9e58229..1c7c24a4cd1c9 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts @@ -1,26 +1,19 @@ -import * as React from 'react'; -import type { TreeContextValue } from '../../contexts'; +import { TreeContextValue } from '../../contexts'; import type { TreeContextValues, TreeState } from './Tree.types'; export function useTreeContextValues_unstable(state: TreeState): TreeContextValues { - const { openTrees, requestOpenChange, level, treeRef, subtreeRef } = state; - + const { openSubtrees, level, requestOpenChange, focusFirstSubtreeItem, focusSubtreeOwnerItem } = state; /** * This context is created with "@fluentui/react-context-selector", * there is no sense to memoize it */ - const tree: TreeContextValue = React.useMemo( - () => ({ - openTrees, - requestOpenChange, - level, - treeRef, - subtreeRef, - isSubtree: true, - }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [openTrees, requestOpenChange, level], - ); + const tree: TreeContextValue = { + level, + openSubtrees, + requestOpenChange, + focusFirstSubtreeItem, + focusSubtreeOwnerItem, + }; return { tree }; } diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts index 9c8ab67383df0..8df84ff9cfd75 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts @@ -21,10 +21,4 @@ export type TreeItemProps = ComponentProps; /** * State used in rendering TreeItem */ -export type TreeItemState = ComponentState & { - open: boolean; - /** - * indicates if this TreeItem is a leaf (has no subtree) - */ - isLeaf: boolean; -}; +export type TreeItemState = ComponentState; diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts index 926d3ab7b1abd..d7c59f8a271ad 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.ts @@ -2,10 +2,8 @@ import * as React from 'react'; import { getNativeElementProps, useEventCallback } from '@fluentui/react-utilities'; import type { TreeItemElement, TreeItemElementIntersection, TreeItemProps, TreeItemState } from './TreeItem.types'; import { useARIAButtonProps } from '@fluentui/react-aria'; -import { useFocusFinders } from '@fluentui/react-tabster'; import { ArrowRight, ArrowLeft } from '@fluentui/keyboard-keys'; import { useTreeContext_unstable } from '../../contexts/treeContext'; -import { useFluent_unstable } from '@fluentui/react-shared-contexts'; /** * Create the state required to render TreeItem. * @@ -18,15 +16,13 @@ import { useFluent_unstable } from '@fluentui/react-shared-contexts'; export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref): TreeItemState => { const { 'aria-owns': ariaOwns, as = 'div', onKeyDown, ...rest } = props; - const requestOpenChange = useTreeContext_unstable(ctx => ctx.requestOpenChange); const level = useTreeContext_unstable(ctx => ctx.level); - const treeRef = useTreeContext_unstable(ctx => ctx.treeRef); - const subtreeRef = useTreeContext_unstable(ctx => ctx.subtreeRef); - const { findFirstFocusable } = useFocusFinders(); - const { targetDocument } = useFluent_unstable(); + const requestOpenChange = useTreeContext_unstable(ctx => ctx.requestOpenChange); + const focusFirstSubtreeItem = useTreeContext_unstable(ctx => ctx.focusFirstSubtreeItem); + const focusSubtreeOwnerItem = useTreeContext_unstable(ctx => ctx.focusSubtreeOwnerItem); const isBranch = typeof ariaOwns === 'string'; - const open = useTreeContext_unstable(ctx => isBranch && ctx.openTrees.includes(ariaOwns!)); + const open = useTreeContext_unstable(ctx => isBranch && ctx.openSubtrees.includes(ariaOwns!)); const handleClick = useEventCallback((event: React.MouseEvent) => { if (isBranch) { @@ -35,19 +31,15 @@ export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref) => { if (open && isBranch) { - // find first focusable on the subtree and focus on it - const subtree = targetDocument?.getElementById(ariaOwns!); - if (subtree) { - subtree && findFirstFocusable(subtree)?.focus(); - } + focusFirstSubtreeItem(event.currentTarget); } if (isBranch && !open) { requestOpenChange({ event, open: true, type: 'arrowRight', id: ariaOwns! }); } }; const handleArrowLeft = (event: React.KeyboardEvent) => { - if ((!isBranch || !open) && subtreeRef.current && subtreeRef.current.id) { - treeRef.current?.querySelector(`[aria-owns="${subtreeRef.current.id}"]`)?.focus(); + if (!isBranch || !open) { + focusSubtreeOwnerItem(event.currentTarget); } if (isBranch && open) { requestOpenChange({ event, open: false, type: 'arrowLeft', id: ariaOwns! }); @@ -71,8 +63,6 @@ export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref; - subtreeRef: React.RefObject; - isSubtree: boolean; + openSubtrees: string[]; + focusFirstSubtreeItem(target: TreeItemElement): void; + focusSubtreeOwnerItem(target: TreeItemElement): void; /** * Requests dialog main component to update it's internal open state */ @@ -16,10 +15,13 @@ export type TreeContextValue = { const defaultContextValue: TreeContextValue = { level: 0, - isSubtree: false, - treeRef: React.createRef(), - subtreeRef: React.createRef(), - openTrees: [], + openSubtrees: [], + focusFirstSubtreeItem() { + /* noop */ + }, + focusSubtreeOwnerItem() { + /* noop */ + }, requestOpenChange() { /* noop */ }, diff --git a/packages/react-components/react-tree/src/utils/useTreeWalker.ts b/packages/react-components/react-tree/src/utils/useTreeWalker.ts new file mode 100644 index 0000000000000..8fbde4eb82de1 --- /dev/null +++ b/packages/react-components/react-tree/src/utils/useTreeWalker.ts @@ -0,0 +1,16 @@ +import { useFluent_unstable } from '@fluentui/react-shared-contexts'; +import * as React from 'react'; + +export function useTreeWalker(whatToShow?: number, filter?: NodeFilter | null) { + const rootRef = React.useRef(null); + const treeWalkerRef = React.useRef(null); + const { targetDocument } = useFluent_unstable(); + + React.useEffect(() => { + if (rootRef.current && targetDocument) { + treeWalkerRef.current = targetDocument.createTreeWalker(rootRef.current, whatToShow, filter); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return { treeWalker: treeWalkerRef as React.RefObject, root: rootRef }; +} From 632ac327f596e2719a480a9070a3843eeff974bc Mon Sep 17 00:00:00 2001 From: Bernardo Sunderhus Date: Thu, 24 Nov 2022 14:04:52 +0100 Subject: [PATCH 4/4] Update packages/react-components/react-tree/src/components/Tree/useTree.ts Co-authored-by: ling1726 --- .../react-components/react-tree/src/components/Tree/useTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index bbb120cab3066..3c74f414e1a88 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -145,7 +145,7 @@ function useRootTree(props: TreeProps, ref: React.Ref): TreeState { function filterTreeItemAndSubtree(node: Node) { const element = node as HTMLElement & { role: string }; - return element.role === 'treeitem' || element.role === 'group' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + return element.role === 'treeitem' || element.role === 'group' ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } function warnIfNoProperPropsSubtree(props: Pick) {