Skip to content

Commit

Permalink
feaTt(react-tree): adds openItems and checkedItems to tree callback d…
Browse files Browse the repository at this point in the history
…ata (#28669)
  • Loading branch information
bsunderhus authored Jul 31, 2023
1 parent 0884286 commit f9e014b
Show file tree
Hide file tree
Showing 20 changed files with 164 additions and 119 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: adds openItems and checkedItems to tree callback data",
"packageName": "@fluentui/react-tree",
"email": "[email protected]",
"dependentChangeType": "patch"
}
3 changes: 3 additions & 0 deletions packages/react-components/react-tree/etc/react-tree.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export const Tree: ForwardRefComponent<TreeProps>;
// @public (undocumented)
export type TreeCheckedChangeData = {
value: TreeItemValue;
checkedItems: Map<TreeItemValue, TreeSelectionValue>;
target: HTMLElement;
event: React_2.ChangeEvent<HTMLElement>;
type: 'Change';
Expand Down Expand Up @@ -152,6 +153,7 @@ export type TreeItemContextValue = {
itemType: TreeItemType;
value: TreeItemValue;
open: boolean;
checked?: TreeSelectionValue;
};

// @public
Expand Down Expand Up @@ -269,6 +271,7 @@ export type TreeNavigationEvent_unstable = TreeNavigationData_unstable['event'];
// @public (undocumented)
export type TreeOpenChangeData = {
open: boolean;
openItems: Set<TreeItemValue>;
value: TreeItemValue;
target: HTMLElement;
} & ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`FlatTree renders a default state 1`] = `
<div>
<div
class="fui-FlatTree"
role="baseTree"
role="tree"
>
Default FlatTree
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,56 +1,54 @@
import { useFluent_unstable } from '@fluentui/react-shared-contexts';
import { useEventCallback, useMergedRefs } from '@fluentui/react-utilities';
import { useEventCallback } from '@fluentui/react-utilities';
import { TreeNavigationData_unstable } from '../../Tree';
import { HeadlessTree, HeadlessTreeItemProps } from '../../utils/createHeadlessTree';
import { nextTypeAheadElement } from '../../utils/nextTypeAheadElement';
import { treeDataTypes } from '../../utils/tokens';
import { treeItemFilter } from '../../utils/treeItemFilter';
import { HTMLElementWalker, useHTMLElementWalkerRef } from '../../hooks/useHTMLElementWalker';
import { useRovingTabIndex } from '../../hooks/useRovingTabIndexes';
import { dataTreeItemValueAttrName, getTreeItemValueFromElement } from '../../utils/getTreeItemValueFromElement';
import { HTMLElementWalker } from '../../utils/createHTMLElementWalker';

export function useFlatTreeNavigation<Props extends HeadlessTreeItemProps>(virtualTree: HeadlessTree<Props>) {
const { targetDocument } = useFluent_unstable();
const [treeItemWalkerRef, treeItemWalkerRootRef] = useHTMLElementWalkerRef(treeItemFilter);
const [{ rove }, rovingRootRef] = useRovingTabIndex(treeItemFilter);
const { rove, initialize } = useRovingTabIndex(treeItemFilter);

function getNextElement(data: TreeNavigationData_unstable) {
if (!targetDocument || !treeItemWalkerRef.current) {
function getNextElement(data: TreeNavigationData_unstable, walker: HTMLElementWalker) {
if (!targetDocument) {
return null;
}
const treeItemWalker = treeItemWalkerRef.current;
switch (data.type) {
case treeDataTypes.Click:
return data.target;
case treeDataTypes.TypeAhead:
treeItemWalker.currentElement = data.target;
return nextTypeAheadElement(treeItemWalker, data.event.key);
walker.currentElement = data.target;
return nextTypeAheadElement(walker, data.event.key);
case treeDataTypes.ArrowLeft:
return parentElement(virtualTree, data.target, treeItemWalker);
return parentElement(virtualTree, data.target, walker);
case treeDataTypes.ArrowRight:
treeItemWalker.currentElement = data.target;
return firstChild(data.target, treeItemWalker);
walker.currentElement = data.target;
return firstChild(data.target, walker);
case treeDataTypes.End:
treeItemWalker.currentElement = treeItemWalker.root;
return treeItemWalker.lastChild();
walker.currentElement = walker.root;
return walker.lastChild();
case treeDataTypes.Home:
treeItemWalker.currentElement = treeItemWalker.root;
return treeItemWalker.firstChild();
walker.currentElement = walker.root;
return walker.firstChild();
case treeDataTypes.ArrowDown:
treeItemWalker.currentElement = data.target;
return treeItemWalker.nextElement();
walker.currentElement = data.target;
return walker.nextElement();
case treeDataTypes.ArrowUp:
treeItemWalker.currentElement = data.target;
return treeItemWalker.previousElement();
walker.currentElement = data.target;
return walker.previousElement();
}
}
const navigate = useEventCallback((data: TreeNavigationData_unstable) => {
const nextElement = getNextElement(data);
const navigate = useEventCallback((data: TreeNavigationData_unstable, walker: HTMLElementWalker) => {
const nextElement = getNextElement(data, walker);
if (nextElement) {
rove(nextElement);
}
});
return [navigate, useMergedRefs(treeItemWalkerRootRef, rovingRootRef)] as const;
return { navigate, initialize } as const;
}

function firstChild(target: HTMLElement, treeWalker: HTMLElementWalker): HTMLElement | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
TreeOpenChangeEvent,
TreeProps,
} from '../Tree/Tree.types';
import { HTMLElementWalker, createHTMLElementWalker } from '../../utils/createHTMLElementWalker';
import { treeItemFilter } from '../../utils/treeItemFilter';

export type HeadlessFlatTreeItemProps = HeadlessTreeItemProps;
export type HeadlessFlatTreeItem<Props extends HeadlessFlatTreeItemProps> = HeadlessTreeItem<Props>;
Expand Down Expand Up @@ -114,7 +116,18 @@ export function useHeadlessFlatTree_unstable<Props extends HeadlessTreeItemProps
const headlessTree = React.useMemo(() => createHeadlessTree(props), [props]);
const [openItems, setOpenItems] = useControllableOpenItems(options);
const [checkedItems, setCheckedItems] = useFlatControllableCheckedItems(options);
const [navigate, navigationRef] = useFlatTreeNavigation(headlessTree);
const { initialize, navigate } = useFlatTreeNavigation(headlessTree);
const walkerRef = React.useRef<HTMLElementWalker>();
const initializeWalker = React.useCallback(
(root: HTMLElement | null) => {
if (root) {
walkerRef.current = createHTMLElementWalker(root, treeItemFilter);
initialize(walkerRef.current);
}
},
[initialize],
);

const treeRef = React.useRef<HTMLDivElement>(null);
const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
options.onOpenChange?.(event, data);
Expand All @@ -129,7 +142,9 @@ export function useHeadlessFlatTree_unstable<Props extends HeadlessTreeItemProps
const handleNavigation = useEventCallback(
(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => {
options.onNavigation_unstable?.(event, data);
navigate(data);
if (walkerRef.current) {
navigate(data, walkerRef.current);
}
},
);

Expand Down Expand Up @@ -161,7 +176,7 @@ export function useHeadlessFlatTree_unstable<Props extends HeadlessTreeItemProps
return treeRef.current?.querySelector(`[${dataTreeItemValueAttrName}="${item.value}"]`) as HTMLElement | null;
}, []);

const ref = useMergedRefs<HTMLDivElement>(treeRef, navigationRef as React.Ref<HTMLDivElement>);
const ref = useMergedRefs<HTMLDivElement>(treeRef, initializeWalker);

const getTreeProps = React.useCallback(
() => ({
Expand All @@ -181,7 +196,17 @@ export function useHeadlessFlatTree_unstable<Props extends HeadlessTreeItemProps
const items = React.useCallback(() => headlessTree.visibleItems(openItems), [openItems, headlessTree]);

return React.useMemo<HeadlessFlatTree<Props>>(
() => ({ navigate, getTreeProps, getNextNavigableItem, getElementFromItem, items }),
() => ({
navigate: data => {
if (walkerRef.current) {
navigate(data, walkerRef.current);
}
},
getTreeProps,
getNextNavigableItem,
getElementFromItem,
items,
}),
[navigate, getTreeProps, getNextNavigableItem, getElementFromItem, items],
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type TreeNavigationEvent_unstable = TreeNavigationData_unstable['event'];

export type TreeOpenChangeData = {
open: boolean;
openItems: Set<TreeItemValue>;
value: TreeItemValue;
target: HTMLElement;
} & (
Expand All @@ -45,6 +46,7 @@ export type TreeOpenChangeEvent = TreeOpenChangeData['event'];

export type TreeCheckedChangeData = {
value: TreeItemValue;
checkedItems: Map<TreeItemValue, TreeSelectionValue>;
target: HTMLElement;
event: React.ChangeEvent<HTMLElement>;
type: 'Change';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exports[`Tree renders a default state 1`] = `
<div>
<div
class="fui-Tree"
role="baseTree"
role="tree"
>
Default Tree
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,23 @@ import { useControllableCheckedItems } from './useControllableCheckedItems';
import { useTreeContext_unstable } from '../../contexts/treeContext';
import { useRootTree } from '../../hooks/useRootTree';
import { useSubtree } from '../../hooks/useSubtree';
import { HTMLElementWalker, createHTMLElementWalker } from '../../utils/createHTMLElementWalker';
import { treeItemFilter } from '../../utils/treeItemFilter';

export const useTree_unstable = (props: TreeProps, ref: React.Ref<HTMLElement>): TreeState => {
const [openItems, setOpenItems] = useControllableOpenItems(props);
const [checkedItems] = useControllableCheckedItems(props);
const [navigate, navigationRef] = useTreeNavigation();
const { navigate, initialize } = useTreeNavigation();
const walkerRef = React.useRef<HTMLElementWalker>();
const initializeWalker = React.useCallback(
(root: HTMLElement | null) => {
if (root) {
walkerRef.current = createHTMLElementWalker(root, treeItemFilter);
initialize(walkerRef.current);
}
},
[initialize],
);

const handleOpenChange = useEventCallback((event: TreeOpenChangeEvent, data: TreeOpenChangeData) => {
props.onOpenChange?.(event, data);
Expand All @@ -33,7 +45,9 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref<HTMLElement>):
const handleNavigation = useEventCallback(
(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable) => {
props.onNavigation_unstable?.(event, data);
navigate(data);
if (walkerRef.current) {
navigate(data, walkerRef.current);
}
},
);

Expand All @@ -47,7 +61,7 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref<HTMLElement>):
onCheckedChange: handleCheckedChange,
};

const baseRef = useMergedRefs(ref, navigationRef);
const baseRef = useMergedRefs(ref, initializeWalker);

const isSubtree = useTreeContext_unstable(ctx => ctx.level > 0);
// as isSubTree is static, this doesn't break rule of hooks
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
import { useMergedRefs } from '@fluentui/react-utilities';
import { TreeNavigationData_unstable } from './Tree.types';
import { nextTypeAheadElement } from '../../utils/nextTypeAheadElement';
import { treeDataTypes } from '../../utils/tokens';
import { treeItemFilter } from '../../utils/treeItemFilter';
import { useRovingTabIndex } from '../../hooks/useRovingTabIndexes';
import { HTMLElementWalker, useHTMLElementWalkerRef } from '../../hooks/useHTMLElementWalker';
import { HTMLElementWalker } from '../../utils/createHTMLElementWalker';

export function useTreeNavigation() {
const [{ rove }, rovingRootRef] = useRovingTabIndex(treeItemFilter);
const [walkerRef, rootRef] = useHTMLElementWalkerRef(treeItemFilter);
const { rove, initialize } = useRovingTabIndex(treeItemFilter);

const getNextElement = (data: TreeNavigationData_unstable) => {
if (!walkerRef.current) {
return;
}
const treeItemWalker = walkerRef.current;
const getNextElement = (data: TreeNavigationData_unstable, treeItemWalker: HTMLElementWalker) => {
switch (data.type) {
case treeDataTypes.Click:
return data.target;
Expand All @@ -41,13 +35,13 @@ export function useTreeNavigation() {
return treeItemWalker.previousElement();
}
};
function navigate(data: TreeNavigationData_unstable) {
const nextElement = getNextElement(data);
function navigate(data: TreeNavigationData_unstable, walker: HTMLElementWalker) {
const nextElement = getNextElement(data, walker);
if (nextElement) {
rove(nextElement);
}
}
return [navigate, useMergedRefs(rootRef, rovingRootRef)] as const;
return { navigate, initialize } as const;
}

function lastChildRecursive(walker: HTMLElementWalker) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { getNativeElementProps, useId, useMergedRefs } from '@fluentui/react-uti
import { useEventCallback } from '@fluentui/react-utilities';
import { elementContains } from '@fluentui/react-portal';
import type { TreeItemProps, TreeItemState } from './TreeItem.types';
import { useTreeContext_unstable } from '../../contexts/index';
import { useTreeContext_unstable, useTreeItemContext_unstable } from '../../contexts/index';
import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement';
import { Space } from '@fluentui/keyboard-keys';
import { treeDataTypes } from '../../utils/tokens';
Expand Down Expand Up @@ -42,8 +42,14 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
const selectionRef = React.useRef<HTMLInputElement>(null);

const open = useTreeContext_unstable(ctx => ctx.openItems.has(value));
const checked = useTreeContext_unstable(ctx => ctx.checkedItems.get(value) ?? false);
const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode);
const parentChecked = useTreeItemContext_unstable(ctx => ctx.checked);
const checked = useTreeContext_unstable(ctx => {
if (selectionMode === 'multiselect' && typeof parentChecked === 'boolean') {
return parentChecked;
}
return ctx.checkedItems.get(value);
});

const handleClick = useEventCallback((event: React.MouseEvent<HTMLDivElement>) => {
onClick?.(event);
Expand Down Expand Up @@ -133,13 +139,21 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
if (isEventFromSubtree) {
return;
}
requestTreeResponse({ event, value, itemType, type: 'Change', target: event.currentTarget });
requestTreeResponse({
event,
value,
itemType,
type: 'Change',
target: event.currentTarget,
checked: checked === 'mixed' ? true : !checked,
});
});

const isBranch = itemType === 'branch';
return {
value,
open,
checked,
subtreeRef,
layoutRef,
selectionRef,
Expand All @@ -159,7 +173,8 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
role: 'treeitem',
'aria-level': level,
[dataTreeItemValueAttrName]: value,
'aria-checked': selectionMode === 'multiselect' ? checked : undefined,
'aria-checked':
selectionMode === 'multiselect' ? (checked === 'mixed' ? undefined : checked ?? false) : undefined,
'aria-selected': selectionMode === 'single' ? checked : undefined,
'aria-expanded': isBranch ? open : undefined,
onClick: handleClick,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export function useTreeItemContextValues_unstable(state: TreeItemState): TreeIte
isActionsVisible,
isAsideVisible,
selectionRef,
checked,
} = state;

/**
Expand All @@ -21,6 +22,7 @@ export function useTreeItemContextValues_unstable(state: TreeItemState): TreeIte
*/
const treeItem: TreeItemContextValue = {
value,
checked,
itemType,
layoutRef,
subtreeRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ export const useTreeItemLayout_unstable = (
const selectionRef = useTreeItemContext_unstable(ctx => ctx.selectionRef);
const expandIconRef = useTreeItemContext_unstable(ctx => ctx.expandIconRef);
const actionsRef = useTreeItemContext_unstable(ctx => ctx.actionsRef);
const value = useTreeItemContext_unstable(ctx => ctx.value);
const checked = useTreeContext_unstable(ctx => ctx.checkedItems.get(value) ?? false);
const checked = useTreeItemContext_unstable(ctx => ctx.checked ?? false);
const isBranch = useTreeItemContext_unstable(ctx => ctx.itemType === 'branch');

const expandIcon = resolveShorthand(props.expandIcon, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ export type TreeContextValue = {
};

export type TreeItemRequest = { itemType: TreeItemType } & (
| OmitWithoutExpanding<TreeOpenChangeData, 'open'>
| OmitWithoutExpanding<TreeOpenChangeData, 'open' | 'openItems'>
| TreeNavigationData_unstable
| OmitWithoutExpanding<TreeCheckedChangeData, 'checked' | 'selectionMode'>
| OmitWithoutExpanding<TreeCheckedChangeData, 'selectionMode' | 'checkedItems'>
);

// helper type that avoids the expansion of unions while inferring it, should work exactly the same as Omit
Expand Down
Loading

0 comments on commit f9e014b

Please sign in to comment.