Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type { PresenceMotionSlotProps } from '@fluentui/react-motion';
import type { ComponentProps, ComponentState, SelectionMode, Slot } from '@fluentui/react-utilities';
Copy link

@github-actions github-actions bot Apr 18, 2025

Choose a reason for hiding this comment

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

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Avatar Converged 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Avatar Converged.badgeMask.normal.chromium.png 5 Changed
vr-tests-react-components/Drawer 1 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Drawer.overlay drawer full.chromium.png 3304 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 720 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 959 Changed

import type { TreeContextValue, SubtreeContextValue } from '../../contexts';
import type { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Enter, Home } from '@fluentui/keyboard-keys';
import type { TreeItemValue } from '../TreeItem/TreeItem.types';
import type { TreeItemType, TreeItemValue } from '../TreeItem/TreeItem.types';
import { CheckboxProps } from '@fluentui/react-checkbox';
import { RadioProps } from '@fluentui/react-radio';

Expand Down Expand Up @@ -146,6 +146,8 @@ export type TreeProps = ComponentProps<TreeSlots> & {
*/
onNavigation?(event: TreeNavigationEvent_unstable, data: TreeNavigationDataParam): void;

onNavigationIn?(event: React.SyntheticEvent, data: { value: TreeItemValue; itemType: TreeItemType }): void;

/**
* This refers to the selection mode of the tree.
* - undefined: No selection can be done.
Expand All @@ -170,8 +172,14 @@ export type TreeProps = ComponentProps<TreeSlots> & {
* such as checked value and type of interaction that created the event.
*/
onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void;

imperativeRef?: React.RefObject<TreeImperativeRef>;
};

interface TreeImperativeRef {
focus(value: TreeItemValue): void;
}

/**
* State used in rendering Tree
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useTreeNavigation } from '../../hooks/useTreeNavigation';
import { useTreeContext_unstable } from '../../contexts/treeContext';
import { ImmutableSet } from '../../utils/ImmutableSet';
import { ImmutableMap } from '../../utils/ImmutableMap';
import { TreeItemValue } from '../TreeItem/TreeItem.types';

export const useTree_unstable = (props: TreeProps, ref: React.Ref<HTMLElement>): TreeState => {
'use no memo';
Expand All @@ -28,6 +29,16 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS
const checkedItems = useNestedCheckedItems(props);
const navigation = useTreeNavigation(props.navigationMode);

React.useImperativeHandle(
props.imperativeRef,
() => ({
focus: (value: TreeItemValue) => {
navigation.focusOnItem(value);
},
}),
[navigation],
);

return Object.assign(
useRootTree(
{
Expand All @@ -47,6 +58,7 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS
if (!event.isDefaultPrevented()) {
navigation.navigate(data, {
preventScroll: data.isScrollPrevented(),
onNavigateIn: props.onNavigationIn,
});
}
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
const selectionRef = React.useRef<HTMLInputElement>(null);
const treeItemRef = React.useRef<HTMLDivElement>(null);

const treeItemPropertiesRef = React.useCallback(
(el: HTMLElement | null) => {
if (el) {
// TODO use a symbol to hide this from public API
// @ts-expect-error
el._treeItem = {
value,
itemType,
};
}
},
[value, itemType],
);

if (process.env.NODE_ENV !== 'production') {
// This is acceptable since the NODE_ENV will not change during runtime

Expand Down Expand Up @@ -331,7 +345,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi
[dataTreeItemValueAttrName]: value,
role: 'treeitem',
...rest,
ref: useMergedRefs(ref, treeItemRef),
ref: useMergedRefs(ref, treeItemRef, treeItemPropertiesRef),
'aria-level': level,
'aria-checked': selectionMode === 'multiselect' ? checked : undefined,
'aria-selected': ariaSelected !== undefined ? ariaSelected : selectionMode === 'single' ? !!checked : undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TreeNavigationData_unstable, TreeNavigationMode } from '../components/Tree/Tree.types';
import { TreeNavigationData_unstable, TreeNavigationMode, TreeProps } from '../components/Tree/Tree.types';
import { nextTypeAheadElement } from '../utils/nextTypeAheadElement';
import { treeDataTypes } from '../utils/tokens';
import { useRovingTabIndex } from './useRovingTabIndexes';
Expand All @@ -8,6 +8,7 @@ import { useHTMLElementWalkerRef } from './useHTMLElementWalkerRef';
import { useMergedRefs } from '@fluentui/react-utilities';
import { treeItemLayoutClassNames } from '../TreeItemLayout';
import { useFocusFinders } from '@fluentui/react-tabster';
import { TreeItemType, TreeItemValue } from '../TreeItem';

/**
* @internal
Expand Down Expand Up @@ -70,16 +71,50 @@ export function useTreeNavigation(navigationMode: TreeNavigationMode = 'tree') {
return walkerRef.current.previousElement();
}
};
function navigate(data: TreeNavigationData_unstable, focusOptions?: FocusOptions) {
function navigate(
data: TreeNavigationData_unstable,
focusOptions?: FocusOptions & { onNavigateIn?: TreeProps['onNavigationIn'] },
) {
const nextElement = getNextElement(data);
// TODO types
// @ts-expect-error
const value = nextElement._treeItem?.value as TreeItemValue;
// @ts-expect-error
const itemType = nextElement._treeItem?.itemType as TreeItemType;
if (value) {
focusOptions?.onNavigateIn?.(data.event, { value, itemType });
}

if (nextElement) {
rove(nextElement, focusOptions);
Copy link
Contributor

Choose a reason for hiding this comment

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

should the above lines be inside of this if?

}
}

function focusOnItem(value: TreeItemValue) {
if (!walkerRef.current) {
return;
}
const walker = walkerRef.current;
walker.currentElement = walker.root;
let cur = walker.firstChild();
while (cur) {
// @ts-expect-error
if (cur._treeItem?.value === value) {
break;
}

cur = walker.nextElement();
}

if (cur) {
rove(cur);
}
}
return {
navigate,
treeRef: useMergedRefs(walkerRootRef, rootRefCallback) as React.RefCallback<HTMLElement>,
forceUpdateRovingTabIndex,
focusOnItem,
} as const;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,115 @@
import * as React from 'react';
import { Tree, TreeItem, TreeItemLayout } from '@fluentui/react-components';
import { Tree, TreeItem, TreeItemLayout, TreeProps, Select, Button } from '@fluentui/react-components';

const values = [
'maxMustermann',
'johnDoe',
'pierreDupont',
'janNovak',
'janeDoe',
'erikaMustermann',
'general',
'engPlanning',
'pmDiscussion',
];

export const Default = () => {
const [checked, setChecked] = React.useState(['maxMustermann']);
const [itemToSelect, setItemToSelect] = React.useState<string>('maxMustermann');
const onCheckedChange: TreeProps['onCheckedChange'] = (e, data) => {
setChecked([data.value as string]);
};

const onNavigationIn: TreeProps['onNavigationIn'] = (e, data) => {
console.log(data);
if (data.itemType === 'leaf') {
setChecked([data.value as string]);
}
};

// This should be built into an enhanced tree item
const onClick = (e: React.MouseEvent) => {
const treeItem = e.nativeEvent.composedPath().find(el => (el as HTMLElement).getAttribute('role') === 'treeitem');
// @ts-expect-error
const value = treeItem._treeItem?.value;
// @ts-expect-error
const itemType = treeItem._treeItem?.itemType;
if (value && itemType === 'leaf') {
setChecked([value]);
}
};

const imperativeRef: NonNullable<TreeProps['imperativeRef']> = React.useRef(null);

const selectedItem = checked[0];
React.useEffect(() => {
imperativeRef.current?.focus(selectedItem);
}, [selectedItem]);

return (
<Tree aria-label="Default">
<TreeItem itemType="branch">
<TreeItemLayout>level 1, item 1</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf">
<TreeItemLayout>level 2, item 1</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf">
<TreeItemLayout>level 2, item 2</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf">
<TreeItemLayout>level 2, item 3</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
<TreeItem itemType="branch">
<TreeItemLayout>level 1, item 2</TreeItemLayout>
<Tree>
<TreeItem itemType="branch">
<TreeItemLayout>level 2, item 1</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf">
<TreeItemLayout>level 3, item 1</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</Tree>
</TreeItem>
<TreeItem itemType="leaf">
<TreeItemLayout>level 1, item 3</TreeItemLayout>
</TreeItem>
</Tree>
<>
<pre>Selected: {checked[0]}</pre>
<div style={{ display: 'grid', gap: '10px', gridTemplateColumns: '200px 120px' }}>
<Select value={itemToSelect} onChange={(e, data) => setItemToSelect(data.value)}>
{values.map(value => (
<option key={value}>{value}</option>
))}
</Select>
<Button onClick={() => setChecked([itemToSelect])}>Select item imperatively</Button>
</div>
<Tree
imperativeRef={imperativeRef}
aria-label="Default"
selectionMode="single"
checkedItems={checked}
onCheckedChange={onCheckedChange}
onNavigationIn={onNavigationIn}
onClick={onClick}
defaultOpenItems={['favorites', 'teamsAndChannels', 'chats']}
>
<TreeItem itemType="branch" value={'favorites'}>
<TreeItemLayout selector={null}>Favorites</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value={'maxMustermann'}>
<TreeItemLayout>Max Mustermann</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value={'johnDoe'}>
<TreeItemLayout>John Doe</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value={'pierreDupont'}>
<TreeItemLayout>Pierre Dupont</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
<TreeItem itemType="branch" value={'chats'}>
<TreeItemLayout selector={null}>Chats</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value={'janNovak'}>
<TreeItemLayout>Jan Novak</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value={'janeDoe'}>
<TreeItemLayout>Jane Doe</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value={'erikaMustermann'}>
<TreeItemLayout>Erika Mustermann</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
<TreeItem itemType="branch" value={'teamsAndChannels'}>
<TreeItemLayout selector={null}>Teams and Channels</TreeItemLayout>
<Tree>
<TreeItem itemType="leaf" value={'general'}>
<TreeItemLayout>General</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value={'engPlanning'}>
<TreeItemLayout>Eng planning</TreeItemLayout>
</TreeItem>
<TreeItem itemType="leaf" value={'pmDiscussion'}>
<TreeItemLayout>PM discussion</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</Tree>
</>
);
};
Loading