Skip to content

Commit

Permalink
feat(react-tree): Actions positioning and behaviour (#26113)
Browse files Browse the repository at this point in the history
* feat(react-tree): properly position actions

* chore: updates API

* chore: fix tests and groupper className

* Update packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx

Co-authored-by: ling1726 <[email protected]>

* chore: ensures functionality with Menu

* chore: adds role presentation to groupper

Co-authored-by: ling1726 <[email protected]>
  • Loading branch information
bsunderhus and ling1726 authored Jan 9, 2023
1 parent 87fcaf1 commit ab386c3
Show file tree
Hide file tree
Showing 12 changed files with 202 additions and 112 deletions.
13 changes: 7 additions & 6 deletions packages/react-components/react-tree/etc/react-tree.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@

/// <reference types="react" />

import type { ARIAButtonElement } from '@fluentui/react-aria';
import type { ARIAButtonSlotProps } from '@fluentui/react-aria';
import type { ComponentProps } from '@fluentui/react-utilities';
import type { ComponentState } from '@fluentui/react-utilities';
import { ContextSelector } from '@fluentui/react-context-selector';
Expand All @@ -30,7 +28,7 @@ export type BaseTreeItemProps = ComponentProps<BaseTreeItemSlots>;

// @public (undocumented)
export type BaseTreeItemSlots = {
root: Slot<ARIAButtonSlotProps>;
root: Slot<'div'>;
};

// @public
Expand Down Expand Up @@ -81,10 +79,13 @@ export type TreeItemSlots = BaseTreeItemSlots & {
iconAfter?: Slot<'span'>;
badges?: Slot<'span'>;
actions?: Slot<'span'>;
groupper: NonNullable<Slot<'span'>>;
};

// @public
export type TreeItemState = ComponentState<TreeItemSlots> & BaseTreeItemState;
export type TreeItemState = ComponentState<TreeItemSlots> & BaseTreeItemState & {
keepActionsOpen: boolean;
};

// @public (undocumented)
export type TreeProps = ComponentProps<TreeSlots> & {
Expand All @@ -109,7 +110,7 @@ export type TreeState = ComponentState<TreeSlots> & TreeContextValue & {
};

// @public
export const useBaseTreeItem_unstable: (props: BaseTreeItemProps, ref: React_2.Ref<BaseTreeItemElement>) => BaseTreeItemState;
export const useBaseTreeItem_unstable: (props: BaseTreeItemProps, ref: React_2.Ref<HTMLDivElement>) => BaseTreeItemState;

// @public
export const useBaseTreeItemStyles_unstable: (state: BaseTreeItemState) => BaseTreeItemState;
Expand All @@ -121,7 +122,7 @@ export const useTree_unstable: (props: TreeProps, ref: React_2.Ref<HTMLElement>)
export const useTreeContext_unstable: <T>(selector: ContextSelector<TreeContextValue, T>) => T;

// @public
export const useTreeItem_unstable: (props: TreeItemProps, ref: React_2.Ref<TreeItemElement>) => TreeItemState;
export const useTreeItem_unstable: (props: TreeItemProps, ref: React_2.Ref<HTMLDivElement>) => TreeItemState;

// @public
export const useTreeItemStyles_unstable: (state: TreeItemState) => TreeItemState;
Expand Down
1 change: 1 addition & 0 deletions packages/react-components/react-tree/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@fluentui/react-shared-contexts": "^9.1.4",
"@fluentui/react-aria": "^9.3.4",
"@fluentui/react-tabster": "^9.3.5",
"@fluentui/react-portal": "^9.0.15",
"@fluentui/keyboard-keys": "^9.0.1",
"@fluentui/react-theme": "^9.1.5",
"@fluentui/react-utilities": "^9.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import type { ARIAButtonElement, ARIAButtonElementIntersection, ARIAButtonSlotProps } from '@fluentui/react-aria';

export type BaseTreeItemElement = ARIAButtonElement;

/** @internal */
export type BaseTreeItemElementIntersection = ARIAButtonElementIntersection;

export type BaseTreeItemSlots = {
/**
* BaseTreeItem root wraps around `props.content`
*/
root: Slot<ARIAButtonSlotProps>;
root: Slot<'div'>;
};

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import * as React from 'react';
import { getNativeElementProps, useEventCallback } from '@fluentui/react-utilities';
import type {
BaseTreeItemElement,
BaseTreeItemElementIntersection,
BaseTreeItemProps,
BaseTreeItemState,
} from './BaseTreeItem.types';
import { useARIAButtonProps } from '@fluentui/react-aria';
import { ArrowRight, ArrowLeft } from '@fluentui/keyboard-keys';
import type { BaseTreeItemProps, BaseTreeItemState } from './BaseTreeItem.types';
import { ArrowRight, ArrowLeft, Enter } from '@fluentui/keyboard-keys';
import { useTreeContext_unstable } from '../../contexts/treeContext';
/**
* Create the state required to render BaseTreeItem.
Expand All @@ -20,7 +14,7 @@ import { useTreeContext_unstable } from '../../contexts/treeContext';
*/
export const useBaseTreeItem_unstable = (
props: BaseTreeItemProps,
ref: React.Ref<BaseTreeItemElement>,
ref: React.Ref<HTMLDivElement>,
): BaseTreeItemState => {
const { 'aria-owns': ariaOwns, as = 'div', onKeyDown, ...rest } = props;

Expand All @@ -32,33 +26,41 @@ export const useBaseTreeItem_unstable = (
const isBranch = typeof ariaOwns === 'string';
const open = useTreeContext_unstable(ctx => isBranch && ctx.openSubtrees.includes(ariaOwns!));

const handleClick = useEventCallback((event: React.MouseEvent<BaseTreeItemElementIntersection>) => {
const handleClick = useEventCallback((event: React.MouseEvent<HTMLDivElement>) => {
if (isBranch) {
requestOpenChange({ event, open: !open, type: 'click', id: ariaOwns! });
}
});
const handleArrowRight = (event: React.KeyboardEvent<BaseTreeItemElementIntersection>) => {
const handleArrowRight = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (open && isBranch) {
focusFirstSubtreeItem(event.currentTarget);
}
if (isBranch && !open) {
requestOpenChange({ event, open: true, type: 'arrowRight', id: ariaOwns! });
}
};
const handleArrowLeft = (event: React.KeyboardEvent<BaseTreeItemElementIntersection>) => {
const handleArrowLeft = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (!isBranch || !open) {
focusSubtreeOwnerItem(event.currentTarget);
}
if (isBranch && open) {
requestOpenChange({ event, open: false, type: 'arrowLeft', id: ariaOwns! });
}
};
const handleKeyDown = useEventCallback((event: React.KeyboardEvent<BaseTreeItemElementIntersection>) => {
const handleEnter = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (isBranch) {
requestOpenChange({ event, open: !open, type: 'enter', id: ariaOwns! });
}
};
const handleKeyDown = useEventCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event);
if (event.isDefaultPrevented()) {
return;
}
switch (event.code) {
switch (event.key) {
case Enter: {
return handleEnter(event);
}
case ArrowRight: {
return handleArrowRight(event);
}
Expand All @@ -73,20 +75,17 @@ export const useBaseTreeItem_unstable = (
},
isLeaf: !isBranch,
open,
root: getNativeElementProps(
as,
useARIAButtonProps(as, {
...rest,
// casting here is required to convert union to intersection
ref: ref as React.Ref<BaseTreeItemElementIntersection>,
'aria-owns': ariaOwns,
'aria-level': level,
// FIXME: tabster fails to navigate when aria-expanded is true
// 'aria-expanded': isBranch ? isOpen : undefined,
role: 'treeitem',
onClick: handleClick,
onKeyDown: handleKeyDown,
}),
),
root: getNativeElementProps(as, {
...rest,
ref,
tabIndex: 0,
'aria-owns': ariaOwns,
'aria-level': level,
// FIXME: tabster fails to navigate when aria-expanded is true
// 'aria-expanded': isBranch ? isOpen : undefined,
role: 'treeitem',
onClick: handleClick,
onKeyDown: handleKeyDown,
}),
};
};
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from 'react';
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import type { BaseTreeItemElement } from '../BaseTreeItem/BaseTreeItem.types';
import { TreeContextValue } from '../../contexts/treeContext';

export type TreeSlots = {
Expand All @@ -9,16 +8,24 @@ export type TreeSlots = {

export type TreeOpenChangeData = { open: boolean; id: string } & (
| {
event: React.MouseEvent<BaseTreeItemElement>;
event: React.MouseEvent<HTMLElement>;
type: 'expandIconClick';
}
| {
event: React.MouseEvent<BaseTreeItemElement>;
event: React.MouseEvent<HTMLElement>;
type: 'click';
}
| {
event: React.KeyboardEvent<BaseTreeItemElement>;
type: 'arrowRight' | 'arrowLeft';
event: React.KeyboardEvent<HTMLElement>;
type: 'enter';
}
| {
event: React.KeyboardEvent<HTMLElement>;
type: 'arrowRight';
}
| {
event: React.KeyboardEvent<HTMLElement>;
type: 'arrowLeft';
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ import { render } from '@testing-library/react';
import { TreeItem } from './TreeItem';
import { isConformant } from '../../testing/isConformant';
import { TreeItemProps } from './TreeItem.types';
import { treeItemClassNames } from './useTreeItemStyles';

describe('TreeItem', () => {
isConformant<TreeItemProps>({
Component: TreeItem,
displayName: 'TreeItem',
// primarySlot: 'groupper',
getTargetElement(renderResult, attr) {
return renderResult.container.querySelector(`.${treeItemClassNames.root}`) ?? renderResult.container;
},
disabledTests: ['component-has-static-classnames-object'],
testOptions: {
'has-static-classnames': [
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,5 @@
import type { ComponentProps, ComponentState, Slot } from '@fluentui/react-utilities';
import {
BaseTreeItemElement,
BaseTreeItemElementIntersection,
BaseTreeItemProps,
BaseTreeItemSlots,
BaseTreeItemState,
} from '../BaseTreeItem/index';

export type TreeItemElement = BaseTreeItemElement;

/** @internal */
export type TreeItemElementIntersection = BaseTreeItemElementIntersection;
import { BaseTreeItemProps, BaseTreeItemSlots, BaseTreeItemState } from '../BaseTreeItem/index';

export type TreeItemSlots = BaseTreeItemSlots & {
/**
Expand All @@ -35,6 +24,7 @@ export type TreeItemSlots = BaseTreeItemSlots & {
* when the item is hovered/focused
*/
actions?: Slot<'span'>;
groupper: NonNullable<Slot<'span'>>;
};

/**
Expand All @@ -45,4 +35,10 @@ export type TreeItemProps = ComponentProps<Partial<TreeItemSlots>> & BaseTreeIte
/**
* State used in rendering TreeItem
*/
export type TreeItemState = ComponentState<TreeItemSlots> & BaseTreeItemState;
export type TreeItemState = ComponentState<TreeItemSlots> &
BaseTreeItemState & {
/**
* boolean indicating that actions should remain open due to focus on some portal
*/
keepActionsOpen: boolean;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,20 @@

exports[`TreeItem renders a default state 1`] = `
<div>
<div
aria-level="0"
class="fui-TreeItem"
role="treeitem"
style="--fluent-TreeItem--level: -1;"
tabindex="0"
<span
class="fui-TreeItem__groupper"
data-tabster="{\\"groupper\\":{}}"
role="presentation"
>
Default TreeItem
</div>
<div
aria-level="0"
class="fui-TreeItem"
role="treeitem"
style="--fluent-TreeItem--level: -1;"
tabindex="0"
>
Default TreeItem
</div>
</span>
</div>
`;
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ export const renderTreeItem_unstable = (state: TreeItemState) => {
const { slots, slotProps } = getSlots<TreeItemSlots>(state);

return (
<slots.root {...slotProps.root}>
{slots.expandIcon && <slots.expandIcon {...slotProps.expandIcon} />}
{slots.iconBefore && <slots.iconBefore {...slotProps.iconBefore} />}
{slotProps.root.children}
{slots.iconAfter && <slots.iconAfter {...slotProps.iconAfter} />}
{slots.badges && <slots.badges {...slotProps.badges} />}
{slots.actions && <slots.actions {...slotProps.actions} />}
</slots.root>
<slots.groupper {...slotProps.groupper}>
<slots.root {...slotProps.root}>
{slots.expandIcon && <slots.expandIcon {...slotProps.expandIcon} />}
{slots.iconBefore && <slots.iconBefore {...slotProps.iconBefore} />}
{slotProps.root.children}
{slots.iconAfter && <slots.iconAfter {...slotProps.iconAfter} />}
{slots.badges && <slots.badges {...slotProps.badges} />}
{slots.actions && <slots.actions {...slotProps.actions} />}
</slots.root>
</slots.groupper>
);
};
Loading

0 comments on commit ab386c3

Please sign in to comment.