Skip to content

Commit c81898c

Browse files
ayy-bcsiddharthkp
andauthored
Add TreeView.LeadingAction sub-component (#4546)
* add grid area for drag handle when data-drag-and-drop is true * add test to verify dnd attribute * css updates * add docs * test(vrt): update snapshots * Sid/treeview leading action (#4569) * wip: leading action slot * clean up a little * change from prop to subcomponent * remove outdated test * spacer should come before leadingAction * merge snapshots from main * merge package-lock from main * add visual tests * use IconButton for leadingAction * add example of drag handle on hover * Create tame-nails-live.md * test(vrt): update snapshots * change LeadingActio type to React.FC<TreeViewVisualProps> and accept children * change LeadingAction of type React.FC<TreeViewVisualProps> * typo * add `variant="invisible"` to icon button in stories * add leadingActionId and aria-hidden to LeadingAction subcomponent * remove `leadingActionId` to describe the tree view item * remove unused leadingActionId * remove docs from changeset --------- Co-authored-by: ayy-bc <[email protected]> Co-authored-by: Siddharth Kshetrapal <[email protected]> Co-authored-by: siddharthkp <[email protected]>
1 parent 5647054 commit c81898c

15 files changed

+217
-4
lines changed

.changeset/tame-nails-live.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/react": minor
3+
---
4+
5+
TreeView: Add support for `TreeView.LeadingAction`

e2e/components/TreeView.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,4 +138,37 @@ test.describe('TreeView', () => {
138138
})
139139
}
140140
})
141+
142+
test.describe('Leading Action', () => {
143+
for (const theme of themes) {
144+
test.describe(theme, () => {
145+
test('default @vrt', async ({page}) => {
146+
await visit(page, {
147+
id: 'components-treeview-features--leading-action',
148+
globals: {
149+
colorScheme: theme,
150+
},
151+
})
152+
153+
expect(await page.screenshot()).toMatchSnapshot(`TreeView.Leading Action.${theme}.png`)
154+
})
155+
156+
test('axe @aat', async ({page}) => {
157+
await visit(page, {
158+
id: 'components-treeview-features--leading-action',
159+
globals: {
160+
colorScheme: theme,
161+
},
162+
})
163+
await expect(page).toHaveNoViolations({
164+
rules: {
165+
'color-contrast': {
166+
enabled: theme !== 'dark_dimmed',
167+
},
168+
},
169+
})
170+
})
171+
})
172+
}
173+
})
141174
})

packages/react/src/TreeView/TreeView.docs.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,16 @@
105105
}
106106
]
107107
},
108+
{
109+
"name": "TreeView.LeadingAction",
110+
"props": [
111+
{
112+
"name": "children",
113+
"required": true,
114+
"type": "React.ReactNode"
115+
}
116+
]
117+
},
108118
{
109119
"name": "TreeView.DirectoryIcon",
110120
"props": []
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import {GrabberIcon} from '@primer/octicons-react'
2+
import type {Meta, Story} from '@storybook/react'
3+
import React from 'react'
4+
import Box from '../Box'
5+
import {TreeView} from './TreeView'
6+
import {IconButton} from '../Button'
7+
8+
const meta: Meta = {
9+
title: 'Components/TreeView/Examples',
10+
component: TreeView,
11+
decorators: [
12+
Story => {
13+
return (
14+
// Prevent TreeView from expanding to the full width of the screen
15+
<Box sx={{maxWidth: 400}}>
16+
<Story />
17+
</Box>
18+
)
19+
},
20+
],
21+
}
22+
23+
export const DraggableListItem: Story = () => {
24+
return (
25+
<Box
26+
sx={{
27+
// using Box for css, this could be in a css file as well
28+
'.treeview-item': {
29+
'.treeview-leading-action': {visibility: 'hidden'},
30+
'&:hover, &:focus': {
31+
'.treeview-leading-action': {visibility: 'visible'},
32+
},
33+
},
34+
}}
35+
>
36+
<TreeView aria-label="Issues">
37+
<ControlledDraggableItem id="item-1">Item 1</ControlledDraggableItem>
38+
<ControlledDraggableItem id="item-2">
39+
Item 2
40+
<TreeView.SubTree>
41+
<TreeView.Item id="item-2-sub-task-1">sub task 1</TreeView.Item>
42+
<TreeView.Item id="item-2-sub-task-2">sub task 2</TreeView.Item>
43+
</TreeView.SubTree>
44+
</ControlledDraggableItem>
45+
<ControlledDraggableItem id="item-3">Item 3</ControlledDraggableItem>
46+
</TreeView>
47+
</Box>
48+
)
49+
}
50+
51+
const ControlledDraggableItem: React.FC<{id: string; children: React.ReactNode}> = ({id, children}) => {
52+
const [expanded, setExpanded] = React.useState(false)
53+
54+
return (
55+
<>
56+
<TreeView.Item id={id} className="treeview-item" expanded={expanded} onExpandedChange={setExpanded}>
57+
<TreeView.LeadingAction>
58+
<IconButton
59+
icon={GrabberIcon}
60+
variant="invisible"
61+
aria-label="Reorder item"
62+
className="treeview-leading-action"
63+
draggable="true"
64+
onDragStart={() => {
65+
setExpanded(false)
66+
// other drag logic to follow
67+
}}
68+
/>
69+
</TreeView.LeadingAction>
70+
{children}
71+
</TreeView.Item>
72+
</>
73+
)
74+
}
75+
76+
export default meta

packages/react/src/TreeView/TreeView.features.stories.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import {
44
DiffRemovedIcon,
55
DiffRenamedIcon,
66
FileIcon,
7+
GrabberIcon,
78
KebabHorizontalIcon,
9+
IssueClosedIcon,
10+
IssueOpenedIcon,
811
} from '@primer/octicons-react'
912
import type {Meta, Story} from '@storybook/react'
1013
import React from 'react'
@@ -989,4 +992,52 @@ export const WithoutIndentation: Story = () => (
989992
</nav>
990993
)
991994

995+
export const LeadingAction: Story = () => {
996+
return (
997+
<TreeView aria-label="Issues">
998+
<TreeView.Item id="item-0">
999+
<TreeView.LeadingAction>
1000+
<IconButton icon={GrabberIcon} aria-label="Reorder item 1" variant="invisible" />
1001+
</TreeView.LeadingAction>
1002+
<TreeView.LeadingVisual>
1003+
<Octicon icon={IssueClosedIcon} sx={{color: 'done.fg'}} />
1004+
</TreeView.LeadingVisual>
1005+
Item 1
1006+
</TreeView.Item>
1007+
<TreeView.Item id="item-2">
1008+
<TreeView.LeadingAction>
1009+
<IconButton icon={GrabberIcon} aria-label="Reorder item 2" variant="invisible" />
1010+
</TreeView.LeadingAction>
1011+
<TreeView.LeadingVisual>
1012+
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
1013+
</TreeView.LeadingVisual>
1014+
Item 2
1015+
<TreeView.SubTree>
1016+
<TreeView.Item id="item-2-sub-task-1">
1017+
<TreeView.LeadingVisual>
1018+
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
1019+
</TreeView.LeadingVisual>
1020+
sub task 1
1021+
</TreeView.Item>
1022+
<TreeView.Item id="item-2-sub-task-2">
1023+
<TreeView.LeadingVisual>
1024+
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
1025+
</TreeView.LeadingVisual>
1026+
sub task 2
1027+
</TreeView.Item>
1028+
</TreeView.SubTree>
1029+
</TreeView.Item>
1030+
<TreeView.Item id="item-3">
1031+
<TreeView.LeadingAction>
1032+
<IconButton icon={GrabberIcon} aria-label="Reorder item 3" variant="invisible" />
1033+
</TreeView.LeadingAction>
1034+
<TreeView.LeadingVisual>
1035+
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
1036+
</TreeView.LeadingVisual>
1037+
Item 3
1038+
</TreeView.Item>
1039+
</TreeView>
1040+
)
1041+
}
1042+
9921043
export default meta

packages/react/src/TreeView/TreeView.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,20 @@ const UlBox = styled.ul<SxProp>`
9797
outline-offset: -2;
9898
}
9999
}
100+
&[data-has-leading-action] {
101+
--has-leading-action: 1;
102+
}
100103
}
101104
102105
.PRIVATE_TreeView-item-container {
103106
--level: 1; /* default level */
104107
--toggle-width: 1rem; /* 16px */
105108
position: relative;
106109
display: grid;
107-
grid-template-columns: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)) var(--toggle-width) 1fr;
108-
grid-template-areas: 'spacer toggle content';
110+
--leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem);
111+
--spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2));
112+
grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr;
113+
grid-template-areas: 'spacer leadingAction toggle content';
109114
width: 100%;
110115
min-height: 2rem; /* 32px */
111116
font-size: ${get('fontSizes.1')};
@@ -138,7 +143,7 @@ const UlBox = styled.ul<SxProp>`
138143
}
139144
140145
&[data-omit-spacer='true'] .PRIVATE_TreeView-item-container {
141-
grid-template-columns: 0 0 1fr;
146+
grid-template-columns: 0 0 0 1fr;
142147
}
143148
144149
.PRIVATE_TreeView-item[aria-current='true'] > .PRIVATE_TreeView-item-container {
@@ -202,6 +207,12 @@ const UlBox = styled.ul<SxProp>`
202207
color: ${get('colors.fg.muted')};
203208
}
204209
210+
.PRIVATE_TreeView-item-leading-action {
211+
display: flex;
212+
color: ${get('colors.fg.muted')};
213+
grid-area: leadingAction;
214+
}
215+
205216
.PRIVATE_TreeView-item-level-line {
206217
width: 100%;
207218
height: 100%;
@@ -354,11 +365,16 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
354365
},
355366
ref,
356367
) => {
357-
const [slots, rest] = useSlots(children, {leadingVisual: LeadingVisual, trailingVisual: TrailingVisual})
368+
const [slots, rest] = useSlots(children, {
369+
leadingAction: LeadingAction,
370+
leadingVisual: LeadingVisual,
371+
trailingVisual: TrailingVisual,
372+
})
358373
const {expandedStateCache} = React.useContext(RootContext)
359374
const labelId = useId()
360375
const leadingVisualId = useId()
361376
const trailingVisualId = useId()
377+
362378
const [isExpanded, setIsExpanded] = useControllableState({
363379
name: itemId,
364380
// If the item was previously mounted, it's expanded state might be cached.
@@ -449,6 +465,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
449465
aria-expanded={isSubTreeEmpty ? undefined : isExpanded}
450466
aria-current={isCurrentItem ? 'true' : undefined}
451467
aria-selected={isFocused ? 'true' : 'false'}
468+
data-has-leading-action={slots.leadingAction ? true : undefined}
452469
onKeyDown={handleKeyDown}
453470
onFocus={event => {
454471
// Scroll the first child into view when the item receives focus
@@ -488,6 +505,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
488505
<div style={{gridArea: 'spacer', display: 'flex'}}>
489506
<LevelIndicatorLines level={level} />
490507
</div>
508+
{slots.leadingAction}
491509
{hasSubTree ? (
492510
// This lint rule is disabled due to the guidelines in the `TreeView` api docs.
493511
// https://github.com/github/primer/blob/main/apis/tree-view-api.md#the-expandcollapse-chevron-toggle
@@ -829,6 +847,25 @@ const TrailingVisual: React.FC<TreeViewVisualProps> = props => {
829847

830848
TrailingVisual.displayName = 'TreeView.TrailingVisual'
831849

850+
// ----------------------------------------------------------------------------
851+
// TreeView.LeadingAction
852+
853+
const LeadingAction: React.FC<TreeViewVisualProps> = props => {
854+
const {isExpanded} = React.useContext(ItemContext)
855+
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
856+
return (
857+
<>
858+
<div className="PRIVATE_VisuallyHidden" aria-hidden={true}>
859+
{props.label}
860+
</div>
861+
<div className="PRIVATE_TreeView-item-leading-action" aria-hidden={true}>
862+
{children}
863+
</div>
864+
</>
865+
)
866+
}
867+
868+
LeadingAction.displayName = 'TreeView.LeadingAction'
832869
// ----------------------------------------------------------------------------
833870
// TreeView.DirectoryIcon
834871

@@ -898,6 +935,7 @@ ErrorDialog.displayName = 'TreeView.ErrorDialog'
898935
export const TreeView = Object.assign(Root, {
899936
Item,
900937
SubTree,
938+
LeadingAction,
901939
LeadingVisual,
902940
TrailingVisual,
903941
DirectoryIcon,

0 commit comments

Comments
 (0)