diff --git a/packages/react/src/TreeView/TreeView.examples.stories.tsx b/packages/react/src/TreeView/TreeView.examples.stories.tsx index da32c326985..520f86d2427 100644 --- a/packages/react/src/TreeView/TreeView.examples.stories.tsx +++ b/packages/react/src/TreeView/TreeView.examples.stories.tsx @@ -1,9 +1,10 @@ -import {GrabberIcon} from '@primer/octicons-react' +import {GearIcon, GrabberIcon} from '@primer/octicons-react' import type {Meta, StoryFn} from '@storybook/react' import React from 'react' import Box from '../Box' import {TreeView} from './TreeView' import {IconButton} from '../Button' +import {Dialog} from '../Dialog/Dialog' const meta: Meta = { title: 'Components/TreeView/Examples', @@ -73,4 +74,91 @@ const ControlledDraggableItem: React.FC<{id: string; children: React.ReactNode}> ) } +export const TrailingActions: StoryFn = () => { + return ( + +

Trailing Actions: Example with direct focus

+

Press (Command + Shift + U) to focus the trailing action button

+ + Item 1 + + Item 2 + + sub task 1 + sub task 2 + + + Item 3 + + +

Trailing Actions: Example with dialog

+

Press (Command + Shift + U) to interact with the trailing action

+ + Item 1 + + Item 2 + + sub task 1 + sub task 2 + + + Item 3 + +
+ ) +} + +const TrailingAction: React.FC<{id: string; children: React.ReactNode; dialogOnOpen?: boolean}> = ({ + id, + dialogOnOpen, + children, +}) => { + const [expanded, setExpanded] = React.useState(false) + const [dialogOpen, setDialogOpen] = React.useState(false) + + const btnRef = React.useRef(null) + + const mockKeyboardShortcut = (event: React.KeyboardEvent) => { + if (!dialogOnOpen) btnRef.current?.focus() + if (dialogOnOpen) setDialogOpen(true) + } + + return ( + <> + + {children} + + { + setExpanded(false) + // other drag logic to follow + }} + onClick={() => { + setDialogOpen(true) + }} + ref={btnRef} + /> + + + + {dialogOpen ? ( + setDialogOpen(false)}> + Dialog that opens when the trailing action is clicked. + + ) : null} + + ) +} + export default meta diff --git a/packages/react/src/TreeView/TreeView.module.css b/packages/react/src/TreeView/TreeView.module.css index 4ba01ba558f..357e141dd51 100644 --- a/packages/react/src/TreeView/TreeView.module.css +++ b/packages/react/src/TreeView/TreeView.module.css @@ -47,7 +47,7 @@ cursor: pointer; border-radius: var(--borderRadius-medium); grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr; - grid-template-areas: 'spacer leadingAction toggle content'; + grid-template-areas: 'spacer leadingAction toggle content trailingAction'; --leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem); --spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)); @@ -180,6 +180,16 @@ } } + .TreeViewItemTrailingAction { + display: flex; + color: var(--fgColor-muted); + grid-area: trailingAction; + + &>button { + flex-shrink: 1; + } + } + .TreeViewItemLevelLine { width: 100%; height: 100%; diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index ce07a66683d..be12da72e39 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -162,6 +162,7 @@ export type TreeViewItemProps = { expanded?: boolean | null onExpandedChange?: (expanded: boolean) => void onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void + onKeyDown?: (event: React.KeyboardEvent) => void className?: string } @@ -179,6 +180,7 @@ const Item = React.forwardRef( className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, + onKeyDown, }, ref, ) => { @@ -186,6 +188,7 @@ const Item = React.forwardRef( leadingAction: LeadingAction, leadingVisual: LeadingVisual, trailingVisual: TrailingVisual, + trailingAction: TrailingAction, }) const {expandedStateCache} = React.useContext(RootContext) const labelId = useId() @@ -251,6 +254,11 @@ const Item = React.forwardRef( event.stopPropagation() setIsExpandedWithCache(false) break + case 'u': + if (!event.metaKey || !event.shiftKey || !onKeyDown) return + // If the user presses `Shift + Meta + U` + onKeyDown(event) + break } }, [onSelect, setIsExpandedWithCache, toggle], @@ -364,6 +372,7 @@ const Item = React.forwardRef( {slots.trailingVisual} + {slots.trailingAction} {subTree} @@ -671,6 +680,42 @@ const LeadingAction: React.FC = props => { LeadingAction.displayName = 'TreeView.LeadingAction' // ---------------------------------------------------------------------------- +// TreeView.TrailingAction + +export type TreeViewTrailingAction = { + children: React.ReactNode | ((props: {isExpanded: boolean}) => React.ReactNode) + // Provide an accessible name for the visual. This should provide information + // about what the visual indicates or represents + label?: string + visible?: boolean +} + +const TrailingAction: React.FC = props => { + const {isExpanded} = React.useContext(ItemContext) + const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children + return ( + <> +
+ {props.label} +
+
+ // Prevent focus event from bubbling up to parent items + // This is needed to prevent the TreeView from interfering with trailing actions + event.stopPropagation() + } + onKeyDown={event => event.stopPropagation()} + > + {children} +
+ + ) +} + +TrailingAction.displayName = 'TreeView.TrailingAction' +// ---------------------------------------------------------------------------- // TreeView.DirectoryIcon const DirectoryIcon = () => { @@ -740,6 +785,7 @@ export const TreeView = Object.assign(Root, { Item, SubTree, LeadingAction, + TrailingAction, LeadingVisual, TrailingVisual, DirectoryIcon,