Skip to content

Commit

Permalink
feat: implements selection (#28497)
Browse files Browse the repository at this point in the history
* feat: implements selection

* chore: adds unstable suffix to immutable interfaces

* chore: begins implementation of createNextFlatCheckedItems

* chore: implements selection in flat scenario

* tree selection stories for single and multi select

* chore: adds short circuit to improve performance on selection

* chore: removes checkedItem from TreeCheckedChangeData
+ exports TreeCheckedChangeData and TreeCheckedChangeEvent

* chore: uses aria-checked for multiselect

* chore: creates generator to properly navigate through the flat tree

* chore: stops change event prevention

---------

Co-authored-by: petdud <[email protected]>
  • Loading branch information
bsunderhus and petdud authored Jul 20, 2023
1 parent 21d69b7 commit f5b6134
Show file tree
Hide file tree
Showing 35 changed files with 817 additions and 69 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "feat: implements selection",
"packageName": "@fluentui/react-tree",
"email": "[email protected]",
"dependentChangeType": "patch"
}
38 changes: 35 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 @@ -13,6 +13,8 @@ import type { ArrowUp } from '@fluentui/keyboard-keys';
import type { AvatarContextValue } from '@fluentui/react-avatar';
import type { AvatarSize } from '@fluentui/react-avatar';
import { ButtonContextValue } from '@fluentui/react-button';
import type { Checkbox } from '@fluentui/react-checkbox';
import { CheckboxProps } from '@fluentui/react-checkbox';
import type { ComponentProps } from '@fluentui/react-utilities';
import type { ComponentState } from '@fluentui/react-utilities';
import { ContextSelector } from '@fluentui/react-context-selector';
Expand All @@ -24,7 +26,10 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities';
import type { Home } from '@fluentui/keyboard-keys';
import { Provider } from 'react';
import { ProviderProps } from 'react';
import type { Radio } from '@fluentui/react-radio';
import { RadioProps } from '@fluentui/react-radio';
import * as React_2 from 'react';
import { SelectionMode as SelectionMode_2 } from '@fluentui/react-utilities';
import type { Slot } from '@fluentui/react-utilities';
import type { SlotClassNames } from '@fluentui/react-utilities';

Expand All @@ -44,7 +49,7 @@ export type FlatTree<Props extends FlatTreeItemProps = FlatTreeItemProps> = {
export type FlatTreeItem<Props extends FlatTreeItemProps = FlatTreeItemProps> = {
index: number;
level: number;
childrenSize: number;
childrenValues: TreeItemValue[];
value: TreeItemValue;
parentValue: TreeItemValue | undefined;
getTreeItemProps(): Required<Pick<Props, 'value' | 'aria-setsize' | 'aria-level' | 'aria-posinset' | 'itemType'>> & Omit<Props, 'parentId'>;
Expand All @@ -57,7 +62,7 @@ export type FlatTreeItemProps = Omit<TreeItemProps, 'itemType' | 'value'> & Part
};

// @public (undocumented)
export type FlatTreeProps = Required<Pick<TreeProps, 'openItems' | 'onOpenChange' | 'onNavigation_unstable'>> & {
export type FlatTreeProps = Required<Pick<TreeProps, 'openItems' | 'onOpenChange' | 'onNavigation_unstable' | 'checkedItems' | 'onCheckedChange'>> & {
ref: React_2.Ref<HTMLDivElement>;
openItems: ImmutableSet<TreeItemValue>;
};
Expand All @@ -83,15 +88,34 @@ export const renderTreeItemPersonaLayout_unstable: (state: TreeItemPersonaLayout
// @public
export const Tree: ForwardRefComponent<TreeProps>;

// @public (undocumented)
export type TreeCheckedChangeData = {
value: TreeItemValue;
target: HTMLElement;
event: React_2.ChangeEvent<HTMLElement>;
type: 'Change';
} & ({
selectionMode: 'multiselect';
checked: MultiSelectValue;
} | {
selectionMode: 'single';
checked: SingleSelectValue;
});

// @public (undocumented)
export type TreeCheckedChangeEvent = TreeCheckedChangeData['event'];

// @public (undocumented)
export const treeClassNames: SlotClassNames<TreeSlots>;

// @public (undocumented)
export type TreeContextValue = {
level: number;
selectionMode: 'none' | SelectionMode_2;
appearance: 'subtle' | 'subtle-alpha' | 'transparent';
size: 'small' | 'medium';
openItems: ImmutableSet<TreeItemValue>;
checkedItems: ImmutableMap<TreeItemValue, 'mixed' | boolean>;
requestTreeResponse(request: TreeItemRequest): void;
};

Expand Down Expand Up @@ -168,6 +192,8 @@ export type TreeItemSlots = {
actions?: Slot<ExtractSlotProps<Slot<'div'> & {
visible?: boolean;
}>>;
checkboxIndicator?: Slot<typeof Checkbox>;
radioIndicator?: Slot<typeof Radio>;
};

// @public
Expand Down Expand Up @@ -217,7 +243,6 @@ export type TreeOpenChangeData = {
open: boolean;
value: TreeItemValue;
target: HTMLElement;
openItems: ImmutableSet<TreeItemValue>;
} & ({
event: React_2.MouseEvent<HTMLElement>;
type: 'ExpandIconClick';
Expand Down Expand Up @@ -246,11 +271,18 @@ export type TreeProps = ComponentProps<TreeSlots> & {
defaultOpenItems?: Iterable<TreeItemValue>;
onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void;
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;
};

// @public (undocumented)
export const TreeProvider: Provider<TreeContextValue | undefined> & FC<ProviderProps<TreeContextValue | undefined>>;

// @public (undocumented)
export type TreeSelectionValue = MultiSelectValue | SingleSelectValue;

// @public (undocumented)
export type TreeSlots = {
root: Slot<'div'>;
Expand Down
2 changes: 2 additions & 0 deletions packages/react-components/react-tree/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@
"@fluentui/react-aria": "^9.3.26",
"@fluentui/react-avatar": "^9.5.12",
"@fluentui/react-button": "^9.3.23",
"@fluentui/react-checkbox": "^9.1.24",
"@fluentui/react-context-selector": "^9.1.26",
"@fluentui/react-icons": "^2.0.203",
"@fluentui/react-portal": "^9.3.1",
"@fluentui/react-radio": "^9.1.24",
"@fluentui/react-shared-contexts": "^9.6.0",
"@fluentui/react-tabster": "^9.10.0",
"@fluentui/react-theme": "^9.1.9",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import * as React from 'react';
import { render } from '@testing-library/react';
import { Tree } from './Tree';
import { isConformant } from '../../testing/isConformant';
import { TreeProps } from './index';

describe('Tree', () => {
isConformant({
isConformant<TreeProps>({
Component: Tree,
displayName: 'Tree',
disabledTests: ['consistent-callback-args'],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type * as React from 'react';
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import type { ComponentProps, ComponentState, SelectionMode, Slot } from '@fluentui/react-utilities';
import type { TreeContextValue } from '../../contexts/treeContext';
import type { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, End, Enter, Home } from '@fluentui/keyboard-keys';
import type { TreeItemValue } from '../TreeItem/TreeItem.types';
import { ImmutableSet } from '../../utils/ImmutableSet';
import { CheckboxProps } from '@fluentui/react-checkbox';
import { RadioProps } from '@fluentui/react-radio';

export type MultiSelectValue = NonNullable<CheckboxProps['checked']>;
export type SingleSelectValue = NonNullable<RadioProps['checked']>;
export type TreeSelectionValue = MultiSelectValue | SingleSelectValue;

export type TreeSlots = {
root: Slot<'div'>;
Expand All @@ -28,7 +33,6 @@ export type TreeOpenChangeData = {
open: boolean;
value: TreeItemValue;
target: HTMLElement;
openItems: ImmutableSet<TreeItemValue>;
} & (
| { event: React.MouseEvent<HTMLElement>; type: 'ExpandIconClick' }
| { event: React.MouseEvent<HTMLElement>; type: 'Click' }
Expand All @@ -39,6 +43,24 @@ export type TreeOpenChangeData = {

export type TreeOpenChangeEvent = TreeOpenChangeData['event'];

export type TreeCheckedChangeData = {
value: TreeItemValue;
target: HTMLElement;
event: React.ChangeEvent<HTMLElement>;
type: 'Change';
} & (
| {
selectionMode: 'multiselect';
checked: MultiSelectValue;
}
| {
selectionMode: 'single';
checked: SingleSelectValue;
}
);

export type TreeCheckedChangeEvent = TreeCheckedChangeData['event'];

export type TreeContextValues = {
tree: TreeContextValue;
};
Expand Down Expand Up @@ -90,6 +112,37 @@ export type TreeProps = ComponentProps<TreeSlots> & {
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
onNavigation_unstable?(event: TreeNavigationEvent_unstable, data: TreeNavigationData_unstable): void;

/**
* This refers to the selection mode of the tree.
* - undefined: No selection can be done.
* - 'single': Only one tree item can be selected, radio buttons are rendered.
* - 'multiselect': Multiple tree items can be selected, checkboxes are rendered.
*
* @default undefined
*/
selectionMode?: SelectionMode;
/**
* This refers to a list of ids of checked tree items, or a list of tuples of ids and checked state.
* Controls the state of the checked tree items.
* These property is ignored for subtrees.
*/
checkedItems?: Iterable<TreeItemValue | [TreeItemValue, TreeSelectionValue]>;
/**
* This refers to a list of ids of checked tree items, or a list of tuples of ids and checked state.
* Default value for the uncontrolled state of checked tree items.
* 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.
*
* @param event - a React's Synthetic event
* @param data - A data object with relevant information,
* such as checked value and type of interaction that created the event.
*/
onCheckedChange?(event: TreeCheckedChangeEvent, data: TreeCheckedChangeData): void;
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { getNativeElementProps, useEventCallback, useMergedRefs } from '@fluentui/react-utilities';
import type { TreeOpenChangeData, TreeProps, TreeState, TreeNavigationData_unstable } from './Tree.types';
import { createNextOpenItems, useControllableOpenItems, useNestedTreeNavigation } from '../../hooks';
import { SelectionMode, getNativeElementProps, useEventCallback, useMergedRefs } from '@fluentui/react-utilities';
import {
TreeOpenChangeData,
TreeProps,
TreeState,
TreeNavigationData_unstable,
TreeCheckedChangeData,
} from './Tree.types';
import {
useControllableOpenItems,
useNestedTreeNavigation,
useNestedControllableCheckedItems,
createNextOpenItems,
} from '../../hooks';
import { treeDataTypes } from '../../utils/tokens';
import { TreeItemRequest } from '../../contexts';

Expand All @@ -15,19 +26,26 @@ import { TreeItemRequest } from '../../contexts';
export function useRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeState {
warnIfNoProperPropsRootTree(props);

const { appearance = 'subtle', size = 'medium' } = props;
const { appearance = 'subtle', size = 'medium', selectionMode = 'none' } = props;

const [openItems, setOpenItems] = useControllableOpenItems(props);

const [checkedItems] = useNestedControllableCheckedItems(props);
const [navigate, navigationRef] = useNestedTreeNavigation();

const requestOpenChange = (data: Omit<TreeOpenChangeData, 'openItems'>) => {
const nextOpenItems = createNextOpenItems(data, openItems);
props.onOpenChange?.(data.event, { ...data, openItems: nextOpenItems } as TreeOpenChangeData);
const requestOpenChange = (data: TreeOpenChangeData) => {
props.onOpenChange?.(data.event, data);
if (data.event.isDefaultPrevented()) {
return;
}
return setOpenItems(nextOpenItems);
return setOpenItems(createNextOpenItems(data, openItems));
};

const requestCheckedChange = (data: TreeCheckedChangeData) => {
props.onCheckedChange?.(data.event, data);
// TODO:
// we should implement the logic for nested tree selection
// return setCheckedItems(checkedItems);
};

const requestNavigation = (data: TreeNavigationData_unstable) => {
Expand Down Expand Up @@ -77,22 +95,33 @@ export function useRootTree(props: TreeProps, ref: React.Ref<HTMLElement>): Tree
case treeDataTypes.ArrowDown:
case treeDataTypes.TypeAhead:
return requestNavigation({ ...request, target: request.event.currentTarget });
case treeDataTypes.Change: {
const previousCheckedValue = checkedItems.get(request.value);
return requestCheckedChange({
...request,
selectionMode: selectionMode as SelectionMode,
checked: previousCheckedValue === 'mixed' ? true : !previousCheckedValue,
});
}
}
});

return {
components: {
root: 'div',
},
selectionMode,
open: true,
appearance,
size,
level: 1,
openItems,
checkedItems,
requestTreeResponse,
root: getNativeElementProps('div', {
ref: useMergedRefs(navigationRef, ref),
role: 'tree',
'aria-multiselectable': selectionMode === 'multiselect' ? true : undefined,
...props,
}),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export function useSubtree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS
const { appearance = contextAppearance ?? 'subtle', size = contextSize ?? 'medium' } = props;

const parentLevel = useTreeContext_unstable(ctx => ctx.level);
const selectionMode = useTreeContext_unstable(ctx => ctx.selectionMode);
const openItems = useTreeContext_unstable(ctx => ctx.openItems);
const checkedItems = useTreeContext_unstable(ctx => ctx.checkedItems);
const requestTreeResponse = useTreeContext_unstable(ctx => ctx.requestTreeResponse);

const open = openItems.has(value);
Expand All @@ -30,13 +32,15 @@ export function useSubtree(props: TreeProps, ref: React.Ref<HTMLElement>): TreeS
},
appearance,
size,
selectionMode,
level: parentLevel + 1,
root: getNativeElementProps('div', {
ref: useMergedRefs(ref, subtreeRef),
role: 'group',
...props,
}),
openItems,
checkedItems,
requestTreeResponse,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,18 @@ import { TreeContextValue } from '../../contexts';
import type { TreeContextValues, TreeState } from './Tree.types';

export function useTreeContextValues_unstable(state: TreeState): TreeContextValues {
const { openItems, level, appearance, size, requestTreeResponse } = state;
const { openItems, checkedItems, selectionMode, level, appearance, size, requestTreeResponse } = state;
/**
* This context is created with "@fluentui/react-context-selector",
* there is no sense to memoize it
*/
const tree: TreeContextValue = {
appearance,
size,
level,
openItems,
appearance,
checkedItems,
selectionMode,
requestTreeResponse,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { TreeItemContextValue } from '../../contexts';
import { treeItemLevelToken } from '../../utils/tokens';
import * as React from 'react';
import { TreeItemSlotsContextValue } from '../../contexts/treeItemSlotsContext';
import type { Checkbox } from '@fluentui/react-checkbox';
import type { Radio } from '@fluentui/react-radio';

export type TreeItemCSSProperties = React.CSSProperties & { [treeItemLevelToken]?: string | number };

Expand Down Expand Up @@ -30,6 +32,14 @@ export type TreeItemSlots = {
}
>
>;
/**
* Selection indicator if selection type is checkbox
*/
checkboxIndicator?: Slot<typeof Checkbox>;
/**
* Selection indicator if selection type is radio
*/
radioIndicator?: Slot<typeof Radio>;
};

export type TreeItemInternalSlot = Pick<TreeItemSlots, 'root'>;
Expand Down
Loading

0 comments on commit f5b6134

Please sign in to comment.