Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implements nested tree selection #28668

Merged
Merged
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
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: implements nested tree selection",
"packageName": "@fluentui/react-tree",
"email": "[email protected]",
"dependentChangeType": "patch"
}
21 changes: 12 additions & 9 deletions packages/react-components/react-tree/etc/react-tree.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,15 @@ export type HeadlessFlatTreeItem<Props extends HeadlessFlatTreeItemProps> = Head
export type HeadlessFlatTreeItemProps = HeadlessTreeItemProps;

// @public (undocumented)
export type HeadlessFlatTreeOptions = Pick<FlatTreeProps, 'onOpenChange' | 'onNavigation_unstable' | 'selectionMode' | 'onCheckedChange'> & Pick<TreeProps, 'defaultOpenItems' | 'openItems' | 'checkedItems' | 'defaultChecked'>;
export type HeadlessFlatTreeOptions = Pick<FlatTreeProps, 'onOpenChange' | 'onNavigation_unstable' | 'selectionMode' | 'onCheckedChange'> & Pick<TreeProps, 'defaultOpenItems' | 'openItems' | 'checkedItems'> & {
defaultCheckedItems?: TreeProps['checkedItems'];
};

// @public (undocumented)
export const renderFlatTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element;

// @public (undocumented)
const renderTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element;
export { renderTree_unstable as renderFlatTree_unstable }
export { renderTree_unstable }
export const renderTree_unstable: (state: TreeState, contextValues: TreeContextValues) => JSX.Element;

// @public
export const renderTreeItem_unstable: (state: TreeItemState, contextValues: TreeItemContextValues) => JSX.Element;
Expand Down Expand Up @@ -153,7 +156,7 @@ export type TreeItemContextValue = {
itemType: TreeItemType;
value: TreeItemValue;
open: boolean;
checked?: TreeSelectionValue;
checked: TreeSelectionValue;
};

// @public
Expand Down Expand Up @@ -304,7 +307,6 @@ export type TreeProps = ComponentProps<TreeSlots> & {
onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void;
selectionMode?: SelectionMode_2;
checkedItems?: Iterable<TreeItemValue | [TreeItemValue, TreeSelectionValue]>;
defaultCheckedItems?: Iterable<TreeItemValue | [TreeItemValue, TreeSelectionValue]>;
onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void;
};

Expand All @@ -331,6 +333,9 @@ export { TreeState }
// @public (undocumented)
export const useFlatTree_unstable: (props: FlatTreeProps, ref: React_2.Ref<HTMLElement>) => TreeState;

// @public (undocumented)
export const useFlatTreeContextValues_unstable: (state: TreeState) => TreeContextValues;

// @public (undocumented)
export const useFlatTreeStyles_unstable: (state: TreeState) => TreeState;

Expand All @@ -344,9 +349,7 @@ export const useTree_unstable: (props: TreeProps, ref: React_2.Ref<HTMLElement>)
export const useTreeContext_unstable: <T>(selector: ContextSelector<TreeContextValue, T>) => T;

// @public (undocumented)
function useTreeContextValues_unstable(state: TreeState): TreeContextValues;
export { useTreeContextValues_unstable as useFlatTreeContextValues_unstable }
export { useTreeContextValues_unstable }
export function useTreeContextValues_unstable(state: TreeState): TreeContextValues;

// @public
export function useTreeItem_unstable(props: TreeItemProps, ref: React_2.Ref<HTMLDivElement>): TreeItemState;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/// <reference types="cypress" />
/// <reference types="cypress-real-events" />

import * as React from 'react';
import { mount as mountBase } from '@cypress/react';
import { FluentProvider } from '@fluentui/react-provider';
Expand Down Expand Up @@ -60,7 +63,7 @@ const TreeTest: React.FC<FlatTreeProps & HeadlessFlatTreeOptions> = props => {
props,
);
return (
<FlatTree {...props} {...flatTree.getTreeProps()} id="baseTree" aria-label="Tree">
<FlatTree {...props} {...flatTree.getTreeProps()} id="tree" aria-label="Tree">
{Array.from(flatTree.items(), item => (
<TreeItem key={item.value} {...item.getTreeItemProps()} />
))}
Expand All @@ -69,7 +72,7 @@ const TreeTest: React.FC<FlatTreeProps & HeadlessFlatTreeOptions> = props => {
};
TreeTest.displayName = 'FlatTree';

describe(TreeTest.displayName!, () => {
describe('FlatTree', () => {
it('should have all but first level items hidden', () => {
mount(<TreeTest />);
cy.get('[data-testid="item1__item1"]').should('not.exist');
Expand Down Expand Up @@ -125,7 +128,7 @@ describe(TreeTest.displayName!, () => {
});
it('should not expand/collapse item on actions click', () => {
mount(
<TreeTest id="baseTree" aria-label="Tree">
<TreeTest id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout actions={<Button id="action">action!</Button>}>level 1, item 1</TreeItemLayout>
<Tree>
Expand All @@ -141,6 +144,12 @@ describe(TreeTest.displayName!, () => {
cy.get(`#action`).realClick();
cy.get('[data-testid="item1__item1"]').should('not.exist');
});
it('should select item on selector click', () => {
mount(<TreeTest selectionMode="single" />);
cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true');
cy.get(`[data-testid="item1"] .${treeItemLayoutClassNames.selector}`).realClick();
cy.get('[data-testid="item1"]').should('have.attr', 'aria-selected', 'true');
});
});
describe('Keyboard interactions', () => {
it('should expand/collapse item on Enter key', () => {
Expand Down Expand Up @@ -168,7 +177,7 @@ describe(TreeTest.displayName!, () => {
});
it('should focus on actions when pressing tab key', () => {
mount(
<TreeTest id="baseTree" aria-label="Tree">
<TreeTest id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout actions={<Button id="action">action</Button>}>level 1, item 1</TreeItemLayout>
<Tree>
Expand All @@ -187,7 +196,7 @@ describe(TreeTest.displayName!, () => {
});
it('should not expand/collapse item on actions Enter/Space key', () => {
mount(
<TreeTest id="baseTree" aria-label="Tree">
<TreeTest id="tree" aria-label="Tree">
<TreeItem itemType="branch" value="item1" data-testid="item1">
<TreeItemLayout actions={<Button id="action">action</Button>}>level 1, item 1</TreeItemLayout>
<Tree>
Expand All @@ -213,7 +222,7 @@ describe(TreeTest.displayName!, () => {
cy.document().realPress('Tab');
cy.get('[data-testid="item1"]').should('be.focused');
});
it('should focus out of baseTree when pressing tab key inside baseTree.', () => {
it('should focus out of tree when pressing tab key inside tree.', () => {
mount(<TreeTest />);
cy.focused().should('not.exist');
cy.document().realPress('Tab');
Expand Down Expand Up @@ -249,5 +258,106 @@ describe(TreeTest.displayName!, () => {
cy.get('[data-testid="item1"]').should('be.focused');
});
});
it('should select item on Space key', () => {
mount(<TreeTest selectionMode="single" />);
cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true');
cy.get(`[data-testid="item1"]`).focus().realPress('Space');
cy.get('[data-testid="item1"]').should('have.attr', 'aria-selected', 'true');
});
});
describe('single selection', () => {
it('should switch selection between items', () => {
mount(<TreeTest selectionMode="single" />);
cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true');
cy.get('[data-testid="item2"]').should('not.have.attr', 'aria-selected', 'true');
cy.get(`[data-testid="item1"]`).focus().realPress('Space');
cy.get(`[data-testid="item2"]`).focus().realPress('Space');
cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-selected', 'true');
cy.get('[data-testid="item2"]').should('have.attr', 'aria-selected', 'true');
});
it('should render with a default selected item', () => {
mount(<TreeTest selectionMode="single" defaultCheckedItems={['item1']} />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-selected', 'true');
});
it('should maintain selection when closing and reopening a branch', () => {
mount(<TreeTest selectionMode="single" defaultOpenItems={['item1']} defaultCheckedItems={['item1__item1']} />);
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-selected', 'true');
cy.get('[data-testid="item1"]').focus().realPress('{enter}');
cy.get('[data-testid="item1__item1"]').should('not.exist');
cy.get('[data-testid="item1"]').focus().realPress('{enter}');
cy.get('[data-testid="item1__item1"]').should('exist');
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-selected', 'true');
});
});
describe('multiple selection', () => {
it('should select multiple items', () => {
mount(<TreeTest selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item2"]').should('not.have.attr', 'aria-checked', 'true');
cy.get(`[data-testid="item1"]`).focus().realPress('Space');
cy.get(`[data-testid="item2"]`).focus().realPress('Space');
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item2"]').should('have.attr', 'aria-checked', 'true');
});
it('should have multiple items default selected', () => {
mount(<TreeTest defaultCheckedItems={['item1', 'item2']} selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item2"]').should('have.attr', 'aria-checked', 'true');
});
it('should select all children when selecting a parent', () => {
mount(<TreeTest defaultCheckedItems={['item1']} defaultOpenItems={['item1']} selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item1__item2"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item1__item3"]').should('have.attr', 'aria-checked', 'true');
});
it('should deselect all children when deselecting a parent', () => {
mount(<TreeTest defaultCheckedItems={['item1']} defaultOpenItems={['item1']} selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').focus().realPress('Space');
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false');
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'false');
cy.get('[data-testid="item1__item2"]').should('have.attr', 'aria-checked', 'false');
cy.get('[data-testid="item1__item3"]').should('have.attr', 'aria-checked', 'false');
});
it('should deselect parent when deselecting all children', () => {
mount(<TreeTest defaultCheckedItems={['item1']} defaultOpenItems={['item1']} selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item1__item1"]').focus().realPress('Space');
cy.get('[data-testid="item1__item2"]').focus().realPress('Space');
cy.get('[data-testid="item1__item3"]').focus().realPress('Space');
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false');
});
it('should select parent when selecting all children', () => {
mount(<TreeTest defaultOpenItems={['item1']} selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false');
cy.get('[data-testid="item1__item1"]').focus().realPress('Space');
cy.get('[data-testid="item1__item2"]').focus().realPress('Space');
cy.get('[data-testid="item1__item3"]').focus().realPress('Space');
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'true');
});
it('parent should be indeterminate when selecting some children', () => {
mount(<TreeTest defaultOpenItems={['item1']} selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false');
cy.get('[data-testid="item1__item1"]').focus().realPress('Space');
cy.get('[data-testid="item1__item2"]').focus().realPress('Space');
cy.get('[data-testid="item1"]').should('not.have.attr', 'aria-checked', 'false');
});
it('should maintain selection when closing and reopening a branch', () => {
mount(
<TreeTest selectionMode="multiselect" defaultOpenItems={['item1']} defaultCheckedItems={['item1__item1']} />,
);
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true');
cy.get('[data-testid="item1"]').focus().realPress('{enter}');
cy.get('[data-testid="item1__item1"]').should('not.exist');
cy.get('[data-testid="item1"]').focus().realPress('{enter}');
cy.get('[data-testid="item1__item1"]').should('exist');
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true');
});
it('should change selection when selecting a closed branch', () => {
mount(<TreeTest selectionMode="multiselect" />);
cy.get('[data-testid="item1"]').should('have.attr', 'aria-checked', 'false');
cy.get('[data-testid="item1"]').focus().realPress('Space').realPress('{enter}');
cy.get('[data-testid="item1__item1"]').should('have.attr', 'aria-checked', 'true');
});
});
});
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import * as React from 'react';
import type { ForwardRefComponent } from '@fluentui/react-utilities';
import type { FlatTreeProps } from './FlatTree.types';
import {
useTreeContextValues_unstable as useFlatTreeContextValues_unstable,
renderTree_unstable as renderFlatTree_unstable,
} from '../Tree/index';
import { useFlatTree_unstable } from './useFlatTree';
import { useFlatTreeStyles_unstable } from './useFlatTreeStyles.styles';
import { useFlatTreeContextValues_unstable } from './useFlatTreeContextValues';
import { renderFlatTree_unstable } from './renderFlatTree';

/**
* FlatTree component - TODO: add more docs
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
export * from './FlatTree';
export * from './FlatTree.types';
export {
renderTree_unstable as renderFlatTree_unstable,
useTreeContextValues_unstable as useFlatTreeContextValues_unstable,
} from '../Tree/index';
export * from './useHeadlessFlatTree';
export * from './useFlatTree';
export * from './useFlatTreeStyles.styles';
export * from './useFlatTreeContextValues';
export * from './renderFlatTree';
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { TreeContextValues, renderTree_unstable } from '../../Tree';
import type { FlatTreeState } from './FlatTree.types';

export const renderFlatTree_unstable: (state: FlatTreeState, contextValues: TreeContextValues) => JSX.Element =
renderTree_unstable;
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,32 @@ import { ImmutableMap } from '../../utils/ImmutableMap';
import * as React from 'react';
import type { HeadlessTree, HeadlessTreeItemProps } from '../../utils/createHeadlessTree';
import { createCheckedItems } from '../../utils/createCheckedItems';
import type { TreeCheckedChangeData, TreeProps } from '../Tree/Tree.types';
import type { TreeCheckedChangeData } from '../Tree/Tree.types';
import { HeadlessFlatTreeOptions } from './useHeadlessFlatTree';

export function useFlatControllableCheckedItems(props: Pick<TreeProps, 'checkedItems' | 'defaultCheckedItems'>) {
const [checkedItems, setCheckedItems] = useControllableState({
export function useFlatControllableCheckedItems<Props extends HeadlessTreeItemProps>(
props: Pick<HeadlessFlatTreeOptions, 'checkedItems' | 'defaultCheckedItems' | 'selectionMode'>,
headlessTree: HeadlessTree<Props>,
) {
return useControllableState({
initialState: ImmutableMap.empty,
state: React.useMemo(() => props.checkedItems && createCheckedItems(props.checkedItems), [props.checkedItems]),
defaultState: () => createCheckedItems(props.defaultCheckedItems),
state: React.useMemo(
() => (props.selectionMode ? props.checkedItems && createCheckedItems(props.checkedItems) : undefined),
[props.checkedItems, props.selectionMode],
),
defaultState: () => initializeCheckedItems(props, headlessTree),
});

return [checkedItems, setCheckedItems] as const;
}

export function createNextFlatCheckedItems<Props extends HeadlessTreeItemProps>(
export function createNextFlatCheckedItems(
data: Pick<TreeCheckedChangeData, 'value' | 'checked' | 'selectionMode'>,
previousCheckedItems: ImmutableMap<TreeItemValue, 'mixed' | boolean>,
virtualTree: HeadlessTree<Props>,
headlessTree: HeadlessTree<HeadlessTreeItemProps>,
): ImmutableMap<TreeItemValue, 'mixed' | boolean> {
if (data.selectionMode === 'single') {
return ImmutableMap.create([[data.value, data.checked]]);
}
const treeItem = virtualTree.get(data.value);
const treeItem = headlessTree.get(data.value);
if (!treeItem) {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
Expand All @@ -33,20 +38,20 @@ export function createNextFlatCheckedItems<Props extends HeadlessTreeItemProps>(
return previousCheckedItems;
}
const nextCheckedItems = new Map(previousCheckedItems);
for (const children of virtualTree.subtree(data.value)) {
for (const children of headlessTree.subtree(data.value)) {
nextCheckedItems.set(children.value, data.checked);
}
nextCheckedItems.set(data.value, data.checked);

let isAncestorsMixed = false;
for (const parent of virtualTree.ancestors(treeItem.value)) {
for (const parent of headlessTree.ancestors(treeItem.value)) {
// if one parent is mixed, all ancestors are mixed
if (isAncestorsMixed) {
nextCheckedItems.set(parent.value, 'mixed');
continue;
}
const checkedChildren = [];
for (const child of virtualTree.children(parent.value)) {
for (const child of headlessTree.children(parent.value)) {
if ((nextCheckedItems.get(child.value) ?? false) === data.checked) {
checkedChildren.push(child);
}
Expand All @@ -61,3 +66,19 @@ export function createNextFlatCheckedItems<Props extends HeadlessTreeItemProps>(
}
return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems);
}

function initializeCheckedItems(
props: Pick<HeadlessFlatTreeOptions, 'selectionMode' | 'defaultCheckedItems'>,
headlessTree: HeadlessTree<HeadlessTreeItemProps>,
) {
if (!props.selectionMode) {
return ImmutableMap.empty;
}
let state = createCheckedItems(props.defaultCheckedItems);
if (props.selectionMode === 'multiselect') {
for (const [value, checked] of state) {
state = createNextFlatCheckedItems({ value, checked, selectionMode: props.selectionMode }, state, headlessTree);
}
}
return state;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { TreeContextValues, useTreeContextValues_unstable } from '../../Tree';
import type { FlatTreeState } from './FlatTree.types';

export const useFlatTreeContextValues_unstable: (state: FlatTreeState) => TreeContextValues =
useTreeContextValues_unstable;
Loading