diff --git a/packages/react-components/react-tree/etc/react-tree.api.md b/packages/react-components/react-tree/etc/react-tree.api.md index ce2b1a87218de..468b8d734a330 100644 --- a/packages/react-components/react-tree/etc/react-tree.api.md +++ b/packages/react-components/react-tree/etc/react-tree.api.md @@ -58,6 +58,8 @@ export const treeClassNames: SlotClassNames; export type TreeContextValue = { level: number; openSubtrees: string[]; + appearance: 'subtle' | 'subtle-alpha' | 'transparent'; + size: 'small' | 'medium'; focusFirstSubtreeItem(target: HTMLElement): void; focusSubtreeOwnerItem(target: HTMLElement): void; requestOpenChange(data: TreeOpenChangeData): void; @@ -70,21 +72,24 @@ export const TreeItem: ForwardRefComponent; export const treeItemClassNames: SlotClassNames; // @public -export type TreeItemProps = ComponentProps & BaseTreeItemProps; +export type TreeItemProps = ComponentProps> & BaseTreeItemProps; // @public (undocumented) export type TreeItemSlots = BaseTreeItemSlots & { expandIcon?: Slot<'span'>; iconBefore?: Slot<'span'>; iconAfter?: Slot<'span'>; - actionIcon?: Slot<'span'>; + badges?: Slot<'span'>; + actions?: Slot<'span'>; }; // @public -export type TreeItemState = ComponentState; +export type TreeItemState = ComponentState & BaseTreeItemState; // @public (undocumented) export type TreeProps = ComponentProps & { + appearance?: 'subtle' | 'subtle-alpha' | 'transparent'; + size?: 'small' | 'medium'; openSubtrees?: string | string[]; defaultOpenSubtrees?: string | string[]; onOpenChange?(event: TreeOpenChangeEvent, data: TreeOpenChangeData): void; diff --git a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts index afda582ae91c4..fb1bdd91c67ac 100644 --- a/packages/react-components/react-tree/src/components/Tree/Tree.types.ts +++ b/packages/react-components/react-tree/src/components/Tree/Tree.types.ts @@ -8,6 +8,10 @@ export type TreeSlots = { }; export type TreeOpenChangeData = { open: boolean; id: string } & ( + | { + event: React.MouseEvent; + type: 'expandIconClick'; + } | { event: React.MouseEvent; type: 'click'; @@ -25,6 +29,20 @@ export type TreeContextValues = { }; export type TreeProps = ComponentProps & { + /** + * A tree item can have various appearances: + * - 'subtle' (default): The default tree item styles. + * - 'subtle-alpha': Minimizes emphasis on hovered or focused states. + * - 'transparent': Removes background color. + * @default 'subtle' + */ + appearance?: 'subtle' | 'subtle-alpha' | 'transparent'; + + /** + * Size of the tree item. + * @default 'medium' + */ + size?: 'small' | 'medium'; /** * Controls the state of the open subtrees. * These property is ignored for subtrees. diff --git a/packages/react-components/react-tree/src/components/Tree/useTree.ts b/packages/react-components/react-tree/src/components/Tree/useTree.ts index f8dcbbd9d5d23..10abb6aa2a964 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTree.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTree.ts @@ -39,6 +39,7 @@ export const useTree_unstable = (props: TreeProps, ref: React.Ref): * @param ref - reference to root HTMLElement of Tree */ function useSubtree(props: TreeProps, ref: React.Ref): TreeState { + const { appearance = 'subtle', size = 'medium' } = props; const parentLevel = useTreeContext_unstable(ctx => ctx.level); const focusFirstSubtreeItem = useTreeContext_unstable(ctx => ctx.focusFirstSubtreeItem); const focusSubtreeOwnerItem = useTreeContext_unstable(ctx => ctx.focusSubtreeOwnerItem); @@ -62,6 +63,8 @@ function useSubtree(props: TreeProps, ref: React.Ref): TreeState { components: { root: 'div', }, + appearance, + size, open, level: parentLevel + 1, openSubtrees, diff --git a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts index 1c7c24a4cd1c9..64ef71d94927a 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTreeContextValues.ts @@ -2,12 +2,22 @@ import { TreeContextValue } from '../../contexts'; import type { TreeContextValues, TreeState } from './Tree.types'; export function useTreeContextValues_unstable(state: TreeState): TreeContextValues { - const { openSubtrees, level, requestOpenChange, focusFirstSubtreeItem, focusSubtreeOwnerItem } = state; + const { + openSubtrees, + level, + appearance, + size, + requestOpenChange, + focusFirstSubtreeItem, + focusSubtreeOwnerItem, + } = state; /** * This context is created with "@fluentui/react-context-selector", * there is no sense to memoize it */ const tree: TreeContextValue = { + appearance, + size, level, openSubtrees, requestOpenChange, diff --git a/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts b/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts index 60eb775afd9fa..ff48e8a9bd434 100644 --- a/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts +++ b/packages/react-components/react-tree/src/components/Tree/useTreeStyles.ts @@ -1,13 +1,23 @@ -import { mergeClasses } from '@griffel/react'; +import { makeStyles, mergeClasses } from '@griffel/react'; import type { TreeSlots, TreeState } from './Tree.types'; import type { SlotClassNames } from '@fluentui/react-utilities'; +import { tokens } from '@fluentui/react-theme'; export const treeClassNames: SlotClassNames = { root: 'fui-Tree', }; +const useStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + rowGap: tokens.spacingVerticalXXS, + }, +}); + export const useTreeStyles_unstable = (state: TreeState): TreeState => { - state.root.className = mergeClasses(treeClassNames.root, state.root.className); + const styles = useStyles(); + state.root.className = mergeClasses(treeClassNames.root, styles.root, state.root.className); return state; }; diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx index 3c180eaf84772..79512cdfa7064 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.test.tsx @@ -15,7 +15,8 @@ describe('TreeItem', () => { expandIcon: 'Test Expand Icon', iconBefore: 'Test Icon Before', iconAfter: 'Test Icon After', - actionIcon: 'test Action Icon', + actions: 'test Actions', + badges: 'test Badges', }, }, ], diff --git a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts index e800e6d3ed49b..4fe814c7abf87 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts +++ b/packages/react-components/react-tree/src/components/TreeItem/TreeItem.types.ts @@ -4,6 +4,7 @@ import { BaseTreeItemElementIntersection, BaseTreeItemProps, BaseTreeItemSlots, + BaseTreeItemState, } from '../BaseTreeItem/index'; export type TreeItemElement = BaseTreeItemElement; @@ -26,17 +27,22 @@ export type TreeItemSlots = BaseTreeItemSlots & { */ iconAfter?: Slot<'span'>; /** - * Icon slot that renders on the end of the main content + * Actions slot that renders on the end of tree item */ - actionIcon?: Slot<'span'>; + badges?: Slot<'span'>; + /** + * Actions slot that renders on the end of tree item + * when the item is hovered/focused + */ + actions?: Slot<'span'>; }; /** * TreeItem Props */ -export type TreeItemProps = ComponentProps & BaseTreeItemProps; +export type TreeItemProps = ComponentProps> & BaseTreeItemProps; /** * State used in rendering TreeItem */ -export type TreeItemState = ComponentState; +export type TreeItemState = ComponentState & BaseTreeItemState; diff --git a/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap b/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap index 5a041001ae09e..0a31ffa7e10d4 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap +++ b/packages/react-components/react-tree/src/components/TreeItem/__snapshots__/TreeItem.test.tsx.snap @@ -6,6 +6,7 @@ exports[`TreeItem renders a default state 1`] = ` aria-level="0" class="fui-TreeItem" role="treeitem" + style="--fluent-TreeItem--level: -1;" tabindex="0" > Default TreeItem diff --git a/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx b/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx index 3ec53d93c3783..fc9a376575b50 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/renderTreeItem.tsx @@ -14,7 +14,8 @@ export const renderTreeItem_unstable = (state: TreeItemState) => { {slots.iconBefore && } {slotProps.root.children} {slots.iconAfter && } - {slots.actionIcon && } + {slots.badges && } + {slots.actions && } ); }; diff --git a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx index 2da901b68f0fd..f30bcd068fc15 100644 --- a/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx +++ b/packages/react-components/react-tree/src/components/TreeItem/useTreeItem.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; -import { resolveShorthand } from '@fluentui/react-utilities'; +import { isResolvedShorthand, resolveShorthand } from '@fluentui/react-utilities'; import type { TreeItemElement, TreeItemProps, TreeItemState } from './TreeItem.types'; import { ChevronRightRegular } from '@fluentui/react-icons'; import { useFluent_unstable } from '@fluentui/react-shared-contexts'; import { useBaseTreeItem_unstable } from '../BaseTreeItem/index'; +import { useEventCallback } from '@fluentui/react-utilities'; /** * Create the state required to render TreeItem. @@ -16,9 +17,18 @@ import { useBaseTreeItem_unstable } from '../BaseTreeItem/index'; */ export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref): TreeItemState => { const treeItemState = useBaseTreeItem_unstable(props, ref); - const { expandIcon, iconBefore, iconAfter, actionIcon } = props; + const { expandIcon, iconBefore, iconAfter, actions, badges } = props; const { dir } = useFluent_unstable(); const expandIconRotation = treeItemState.open ? 90 : dir !== 'rtl' ? 0 : 180; + + // prevent default of a click from actions to ensure it doesn't open the treeitem + const handleActionsClick = useEventCallback((event: React.MouseEvent) => { + if (isResolvedShorthand(actions)) { + actions.onClick?.(event); + } + event.preventDefault(); + }); + return { ...treeItemState, components: { @@ -26,7 +36,8 @@ export const useTreeItem_unstable = (props: TreeItemProps, ref: React.Ref = { root: 'fui-TreeItem', expandIcon: 'fui-TreeItem__expandIcon', iconBefore: 'fui-TreeItem__iconBefore', iconAfter: 'fui-TreeItem__iconAfter', - actionIcon: 'fui-TreeItem__actionIcon', + actions: 'fui-TreeItem__actions', + badges: 'fui-TreeItem__badges', }; +const treeItemTokens = { + level: '--fluent-TreeItem--level', +} as const; +const treeItemTokenValues = { + level: `var(${treeItemTokens.level}, 0)`, +} as const; + +/** + * Styles for the root slot + */ +const useRootStyles = makeStyles({ + base: { + position: 'relative', + alignItems: 'center', + backgroundColor: tokens.colorSubtleBackground, + cursor: 'pointer', + color: tokens.colorNeutralForeground2, + display: 'flex', + paddingRight: tokens.spacingHorizontalNone, + paddingLeft: `calc(${treeItemTokenValues.level} * ${tokens.spacingHorizontalXXL})`, + ...shorthands.borderRadius(tokens.borderRadiusMedium), + ':active': { + color: tokens.colorNeutralForeground2Pressed, + backgroundColor: tokens.colorSubtleBackgroundPressed, + [`& .${treeItemClassNames.expandIcon}`]: { + color: tokens.colorNeutralForeground3Pressed, + }, + }, + ':focus': { + [`& .${treeItemClassNames.actions}`]: { + display: 'flex', + opacity: '1', + position: 'relative', + width: 'unset', + height: 'unset', + right: 'unset', + }, + }, + ':hover': { + color: tokens.colorNeutralForeground2Hover, + backgroundColor: tokens.colorSubtleBackgroundHover, + [`& .${treeItemClassNames.actions}`]: { + display: 'flex', + opacity: '1', + position: 'relative', + width: 'unset', + height: 'unset', + right: 'unset', + }, + [`& .${treeItemClassNames.expandIcon}`]: { + color: tokens.colorNeutralForeground3Hover, + }, + }, + }, + actionsAndBadges: { + ':focus': { + [`& .${treeItemClassNames.badges}`]: { + display: 'none', + }, + }, + ':hover': { + [`& .${treeItemClassNames.badges}`]: { + display: 'none', + }, + }, + }, + focusIndicator: createFocusOutlineStyle(), + // Appearance variations + subtle: {}, + 'subtle-alpha': { + ':hover': { + backgroundColor: tokens.colorSubtleBackgroundLightAlphaHover, + }, + ':active': { + backgroundColor: tokens.colorSubtleBackgroundLightAlphaPressed, + }, + }, + transparent: { + backgroundColor: tokens.colorTransparentBackground, + ':hover': { + backgroundColor: tokens.colorTransparentBackgroundHover, + }, + ':active': { + backgroundColor: tokens.colorTransparentBackgroundPressed, + }, + }, + + // Size variations + medium: { + minHeight: '32px', + ...typographyStyles.body1, + }, + small: { + minHeight: '24px', + ...typographyStyles.caption1, + }, + leaf: { + // FIXME: for some reason prettier is not wrapping this after 120 characters + // eslint-disable-next-line @fluentui/max-len + paddingLeft: `calc((${treeItemTokenValues.level} * ${tokens.spacingHorizontalXXL}) + ${tokens.spacingHorizontalXXL})`, + }, +}); + +/** + * Styles for the expand icon slot + */ +const useExpandIconStyles = makeStyles({ + base: { + display: 'flex', + alignItems: 'center', + paddingRight: tokens.spacingHorizontalXS, + color: tokens.colorNeutralForeground3, + }, + medium: { + paddingLeft: tokens.spacingHorizontalS, + }, + small: { + paddingLeft: tokens.spacingHorizontalSNudge, + }, +}); + +/** + * Styles for the before/after icon slot + */ +const useIconStyles = makeStyles({ + base: { + display: 'flex', + alignItems: 'center', + color: tokens.colorNeutralForeground2, + lineHeight: tokens.lineHeightBase500, + fontSize: tokens.fontSizeBase500, + }, +}); + +const useIconBefore = makeStyles({ + medium: { + paddingRight: tokens.spacingHorizontalSNudge, + }, + small: { + paddingRight: tokens.spacingHorizontalXS, + }, +}); + +const useIconAfter = makeStyles({ + medium: { + paddingLeft: tokens.spacingHorizontalSNudge, + }, + small: { + paddingLeft: tokens.spacingHorizontalXS, + }, +}); + +/** + * Styles for the action icon slot + */ +const useBadgesStyles = makeStyles({ + base: { + display: 'flex', + alignItems: 'center', + marginLeft: 'auto', + ...shorthands.padding(0, tokens.spacingHorizontalXS), + ...shorthands.gap(tokens.spacingHorizontalXS), + }, +}); +/** + * Styles for the action icon slot + */ +const useActionsStyles = makeStyles({ + base: { + opacity: '0', + position: 'absolute', + width: 0, + height: 0, + right: 0, + marginLeft: 'auto', + ...shorthands.padding(0, tokens.spacingHorizontalXS), + }, +}); + /** * Apply styling to the TreeItem slots based on the state */ export const useTreeItemStyles_unstable = (state: TreeItemState): TreeItemState => { - state.root.className = mergeClasses(treeItemClassNames.root, state.root.className); + const rootStyles = useRootStyles(); + const expandIconStyles = useExpandIconStyles(); + const iconStyles = useIconStyles(); + const iconBeforeStyles = useIconBefore(); + const iconAfterStyles = useIconAfter(); + const actionsStyles = useActionsStyles(); + const badgesStyles = useBadgesStyles(); - if (state.expandIcon) { - state.expandIcon.className = mergeClasses(treeItemClassNames.expandIcon, state.expandIcon.className); + const level = useTreeContext_unstable(ctx => ctx.level) - 1; + const size = useTreeContext_unstable(ctx => ctx.size); + const appearance = useTreeContext_unstable(ctx => ctx.appearance); + + const { iconAfter, actions, iconBefore, expandIcon, badges } = state; + + state.root.className = mergeClasses( + treeItemClassNames.root, + rootStyles.base, + rootStyles[appearance], + rootStyles.focusIndicator, + rootStyles[size], + actions && badges && rootStyles.actionsAndBadges, + state.isLeaf && rootStyles.leaf, + state.root.className, + ); + + state.root.style = { + ...state.root.style, + [treeItemTokens.level]: level, + } as React.CSSProperties; + + if (expandIcon) { + expandIcon.className = mergeClasses( + treeItemClassNames.expandIcon, + expandIconStyles.base, + expandIconStyles[size], + expandIcon.className, + ); } - if (state.iconBefore) { - state.iconBefore.className = mergeClasses(treeItemClassNames.iconBefore, state.iconBefore.className); + if (iconBefore) { + iconBefore.className = mergeClasses( + treeItemClassNames.iconBefore, + iconStyles.base, + iconBeforeStyles[size], + iconBefore.className, + ); } - if (state.iconAfter) { - state.iconAfter.className = mergeClasses(treeItemClassNames.iconAfter, state.iconAfter.className); + if (iconAfter) { + iconAfter.className = mergeClasses( + treeItemClassNames.iconAfter, + iconStyles.base, + iconAfterStyles[size], + iconAfter.className, + ); } - if (state.actionIcon) { - state.actionIcon.className = mergeClasses(treeItemClassNames.actionIcon, state.actionIcon.className); + if (actions) { + actions.className = mergeClasses(treeItemClassNames.actions, actionsStyles.base, actions.className); + } + if (badges) { + badges.className = mergeClasses(treeItemClassNames.badges, badgesStyles.base, badges.className); } return state; diff --git a/packages/react-components/react-tree/src/contexts/treeContext.ts b/packages/react-components/react-tree/src/contexts/treeContext.ts index 233b4f63b875b..b63db74c078db 100644 --- a/packages/react-components/react-tree/src/contexts/treeContext.ts +++ b/packages/react-components/react-tree/src/contexts/treeContext.ts @@ -4,6 +4,8 @@ import { TreeOpenChangeData } from '../Tree'; export type TreeContextValue = { level: number; openSubtrees: string[]; + appearance: 'subtle' | 'subtle-alpha' | 'transparent'; + size: 'small' | 'medium'; focusFirstSubtreeItem(target: HTMLElement): void; focusSubtreeOwnerItem(target: HTMLElement): void; /** @@ -24,6 +26,8 @@ const defaultContextValue: TreeContextValue = { requestOpenChange() { /* noop */ }, + appearance: 'subtle', + size: 'medium', }; export const TreeContext: Context = createContext( diff --git a/packages/react-components/react-tree/stories/Tree/TreeActions.stories.tsx b/packages/react-components/react-tree/stories/Tree/TreeActions.stories.tsx new file mode 100644 index 0000000000000..ec0814ae73fab --- /dev/null +++ b/packages/react-components/react-tree/stories/Tree/TreeActions.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Tree, TreeItem } from '@fluentui/react-tree'; +import { Edit20Regular, MoreHorizontal20Regular } from '@fluentui/react-icons'; +import { Button } from '@fluentui/react-components'; + +const RenderActions = () => ( + <> +