Skip to content

Commit

Permalink
feat: removes uncontrolled nested selection
Browse files Browse the repository at this point in the history
  • Loading branch information
bsunderhus committed Aug 2, 2023
1 parent 5022b3a commit c571da5
Show file tree
Hide file tree
Showing 15 changed files with 28 additions and 324 deletions.
7 changes: 4 additions & 3 deletions packages/react-components/react-tree/etc/react-tree.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ 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' | 'defaultCheckedItems'>;
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;
Expand Down Expand Up @@ -154,7 +156,7 @@ export type TreeItemContextValue = {
itemType: TreeItemType;
value: TreeItemValue;
open: boolean;
checked?: TreeSelectionValue;
checked: TreeSelectionValue;
};

// @public
Expand Down Expand Up @@ -305,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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 extends HeadlessTreeItemProps>(
Expand Down Expand Up @@ -68,7 +68,7 @@ export function createNextFlatCheckedItems(
}

function initializeCheckedItems(
props: Pick<TreeProps, 'selectionMode' | 'defaultCheckedItems'>,
props: Pick<HeadlessFlatTreeOptions, 'selectionMode' | 'defaultCheckedItems'>,
headlessTree: HeadlessTree<HeadlessTreeItemProps>,
) {
if (!props.selectionMode) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ export type HeadlessFlatTreeOptions = Pick<
FlatTreeProps,
'onOpenChange' | 'onNavigation_unstable' | 'selectionMode' | 'onCheckedChange'
> &
Pick<TreeProps, 'defaultOpenItems' | 'openItems' | 'checkedItems' | 'defaultCheckedItems'>;
Pick<TreeProps, 'defaultOpenItems' | 'openItems' | 'checkedItems'> & {
defaultCheckedItems?: TreeProps['checkedItems'];
};

/**
* this hook provides FlatTree API to manage all required mechanisms to convert a list of items into renderable TreeItems
Expand Down
107 changes: 0 additions & 107 deletions packages/react-components/react-tree/src/components/Tree/Tree.cy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,6 @@ describe('Tree', () => {
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 @@ -244,106 +238,5 @@ describe('Tree', () => {
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
Expand Up @@ -129,11 +129,6 @@ export type TreeProps = ComponentProps<TreeSlots> & {
* These property is ignored for subtrees.
*/
checkedItems?: Iterable<TreeItemValue | [TreeItemValue, TreeSelectionValue]>;
/**
* This refers to a list of ids of default checked items, or a list of tuples of ids and checked state.
* These property is ignored for subtrees.
*/
defaultCheckedItems?: Iterable<TreeItemValue | [TreeItemValue, TreeSelectionValue]>;
/**
* Callback fired when the component changes value from checked state.
* These property is ignored for subtrees.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,112 +1,22 @@
import { useControllableState } from '@fluentui/react-utilities';
import * as React from 'react';
import type { TreeCheckedChangeData, TreeProps } from './Tree.types';
import { ImmutableMap } from '../../utils/ImmutableMap';
import { createCheckedItems } from '../../utils/createCheckedItems';
import { TreeItemValue } from '../TreeItem/TreeItem.types';
import { getTreeItemValueFromElement } from '../../utils/getTreeItemValueFromElement';
import { HTMLElementWalker } from '../../utils/createHTMLElementWalker';

export function useNestedControllableCheckedItems(props: Pick<TreeProps, 'checkedItems' | 'defaultCheckedItems'>) {
return useControllableState({
initialState: ImmutableMap.empty,
state: React.useMemo(() => props.checkedItems && createCheckedItems(props.checkedItems), [props.checkedItems]),
defaultState: () => createCheckedItems(props.defaultCheckedItems),
});
export function useNestedCheckedItems(props: Pick<TreeProps, 'checkedItems'>) {
return React.useMemo(() => createCheckedItems(props.checkedItems), [props.checkedItems]);
}

export function createNextNestedCheckedItems(
data: TreeCheckedChangeData,
previousCheckedItems: ImmutableMap<TreeItemValue, 'mixed' | boolean>,
walker: HTMLElementWalker,
): ImmutableMap<TreeItemValue, 'mixed' | boolean> {
if (data.selectionMode === 'single') {
return ImmutableMap.create([[data.value, data.checked]]);
}
const nextCheckedItems = new Map(previousCheckedItems);
walker.currentElement = data.target;
for (const descendant of GenerateDescendants(walker.currentElement, walker)) {
const descendantValue = getTreeItemValueFromElement(descendant);
if (!descendantValue) {
continue;
}
nextCheckedItems.set(descendantValue, data.checked);
if (data.selectionMode === 'multiselect') {
return previousCheckedItems.set(data.value, data.checked);
}
nextCheckedItems.set(data.value, data.checked);

let isAncestorsMixed = false;
let previousParent = walker.currentElement;
for (const parent of GenerateAncestors(walker.currentElement, walker)) {
const parentValue = getTreeItemValueFromElement(parent);
if (!parentValue) {
continue;
}
// if one parent is mixed, all ancestors are mixed
if (isAncestorsMixed) {
nextCheckedItems.set(parentValue, 'mixed');
continue;
}
let checkedChildrenSize = 0;
let childrenSize = 0;
for (const child of GenerateChildren(parent, walker)) {
const childValue = getTreeItemValueFromElement(child);
if (!childValue) {
continue;
}
if (child !== previousParent) {
const ariaChecked = child.getAttribute('aria-checked') as 'true' | 'false' | null;
const currentChecked = !ariaChecked ? 'mixed' : ariaChecked === 'true';
nextCheckedItems.set(childValue, currentChecked);
}
if (nextCheckedItems.get(childValue) === data.checked) {
checkedChildrenSize++;
}
childrenSize++;
}
if (checkedChildrenSize === childrenSize) {
nextCheckedItems.set(parentValue, data.checked);
} else {
// if one parent is mixed, all ancestors are mixed
isAncestorsMixed = true;
nextCheckedItems.set(parentValue, 'mixed');
}
previousParent = parent;
}
return ImmutableMap.dangerouslyCreate_unstable(nextCheckedItems);
}

// eslint-disable-next-line @typescript-eslint/naming-convention
function* GenerateChildren(target: HTMLElement, walker: HTMLElementWalker) {
const root = walker.currentElement;
walker.currentElement = target;
let nextChild = walker.firstChild();
while (nextChild) {
yield nextChild;
nextChild = walker.nextSibling();
}
walker.currentElement = root;
}
// eslint-disable-next-line @typescript-eslint/naming-convention
function* GenerateDescendants(target: HTMLElement, walker: HTMLElementWalker): Generator<HTMLElement> {
const root = walker.currentElement;
walker.currentElement = target;
let nextChild = walker.firstChild();
while (nextChild) {
yield nextChild;
yield* GenerateDescendants(nextChild, walker);
nextChild = walker.nextSibling();
}
walker.currentElement = root;
}

// eslint-disable-next-line @typescript-eslint/naming-convention
function* GenerateAncestors(target: HTMLElement, walker: HTMLElementWalker) {
const root = walker.currentElement;
walker.currentElement = target;
let nextParent = walker.parentElement();
while (nextParent) {
yield nextParent;
nextParent = walker.parentElement();
}
walker.currentElement = root;
return previousCheckedItems;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type {
TreeState,
} from './Tree.types';
import { createNextOpenItems, useControllableOpenItems } from '../../hooks/useControllableOpenItems';
import { createNextNestedCheckedItems, useNestedControllableCheckedItems } from './useNestedControllableCheckedItems';
import { createNextNestedCheckedItems, useNestedCheckedItems } from './useNestedControllableCheckedItems';
import { useTreeContext_unstable } from '../../contexts/treeContext';
import { useRootTree } from '../../hooks/useRootTree';
import { useSubtree } from '../../hooks/useSubtree';
Expand All @@ -29,7 +29,7 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref<HTMLElement>):

function useNestedRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeState {
const [openItems, setOpenItems] = useControllableOpenItems(props);
const [checkedItems, setCheckedItems] = useNestedControllableCheckedItems(props);
const checkedItems = useNestedCheckedItems(props);
const { navigate, initialize } = useTreeNavigation();
const walkerRef = React.useRef<HTMLElementWalker>();
const initializeWalker = React.useCallback(
Expand All @@ -53,9 +53,11 @@ function useNestedRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS

const handleCheckedChange = useEventCallback((event: TreeCheckedChangeEvent, data: TreeCheckedChangeData) => {
if (walkerRef.current) {
const nextCheckedItems = createNextNestedCheckedItems(data, checkedItems, walkerRef.current);
props.onCheckedChange?.(event, { ...data, checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable() });
setCheckedItems(nextCheckedItems);
const nextCheckedItems = createNextNestedCheckedItems(data, checkedItems);
props.onCheckedChange?.(event, {
...data,
checkedItems: nextCheckedItems.dangerouslyGetInternalMap_unstable(),
});
}
});
const handleNavigation = useEventCallback(
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, useTreeItemContext_unstable } from '../../contexts/index';
import { useTreeContext_unstable } from '../../contexts/index';
import { dataTreeItemValueAttrName } from '../../utils/getTreeItemValueFromElement';
import { Space } from '@fluentui/keyboard-keys';
import { treeDataTypes } from '../../utils/tokens';
Expand Down Expand Up @@ -43,13 +43,7 @@ export function useTreeItem_unstable(props: TreeItemProps, ref: React.Ref<HTMLDi

const open = useTreeContext_unstable(ctx => ctx.openItems.has(value));
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 checked = useTreeContext_unstable(ctx => ctx.checkedItems.get(value) ?? false);

const handleClick = useEventCallback((event: React.MouseEvent<HTMLDivElement>) => {
onClick?.(event);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const Wrapper: React.FC = ({ children }) => (
isAsideVisible: true,
itemType: 'leaf',
open: false,
checked: false,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +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 checked = useTreeItemContext_unstable(ctx => ctx.checked ?? false);
const checked = useTreeItemContext_unstable(ctx => ctx.checked);
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 @@ -17,6 +17,7 @@ const Wrapper: React.FC = ({ children }) => (
isAsideVisible: true,
itemType: 'leaf',
open: false,
checked: false,
}}
>
{children}
Expand Down
Loading

0 comments on commit c571da5

Please sign in to comment.