Skip to content

Commit b39e370

Browse files
ayy-bcsiddharthkp
authored andcommitted
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 8f6475b commit b39e370

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'
@@ -988,6 +991,54 @@ export const WithoutIndentation: Story = () => (
988991
</nav>
989992
)
990993

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

packages/react/src/TreeView/TreeView.tsx

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ const UlBox = styled.ul<SxProp>`
100100
outline-offset: -2;
101101
}
102102
}
103+
&[data-has-leading-action] {
104+
--has-leading-action: 1;
105+
}
103106
}
104107
105108
.PRIVATE_TreeView-item-container {
@@ -108,8 +111,10 @@ const UlBox = styled.ul<SxProp>`
108111
--min-item-height: 2rem; /* 32px */
109112
position: relative;
110113
display: grid;
111-
grid-template-columns: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)) var(--toggle-width) 1fr;
112-
grid-template-areas: 'spacer toggle content';
114+
--leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem);
115+
--spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2));
116+
grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr;
117+
grid-template-areas: 'spacer leadingAction toggle content';
113118
width: 100%;
114119
font-size: ${get('fontSizes.1')};
115120
color: ${get('colors.fg.default')};
@@ -141,7 +146,7 @@ const UlBox = styled.ul<SxProp>`
141146
}
142147
143148
&[data-omit-spacer='true'] .PRIVATE_TreeView-item-container {
144-
grid-template-columns: 0 0 1fr;
149+
grid-template-columns: 0 0 0 1fr;
145150
}
146151
147152
.PRIVATE_TreeView-item[aria-current='true'] > .PRIVATE_TreeView-item-container {
@@ -217,6 +222,12 @@ const UlBox = styled.ul<SxProp>`
217222
height: var(--custom-line-height, 1.3rem);
218223
}
219224
225+
.PRIVATE_TreeView-item-leading-action {
226+
display: flex;
227+
color: ${get('colors.fg.muted')};
228+
grid-area: leadingAction;
229+
}
230+
220231
.PRIVATE_TreeView-item-level-line {
221232
width: 100%;
222233
height: 100%;
@@ -369,11 +380,16 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
369380
},
370381
ref,
371382
) => {
372-
const [slots, rest] = useSlots(children, {leadingVisual: LeadingVisual, trailingVisual: TrailingVisual})
383+
const [slots, rest] = useSlots(children, {
384+
leadingAction: LeadingAction,
385+
leadingVisual: LeadingVisual,
386+
trailingVisual: TrailingVisual,
387+
})
373388
const {expandedStateCache} = React.useContext(RootContext)
374389
const labelId = useId()
375390
const leadingVisualId = useId()
376391
const trailingVisualId = useId()
392+
377393
const [isExpanded, setIsExpanded] = useControllableState({
378394
name: itemId,
379395
// If the item was previously mounted, it's expanded state might be cached.
@@ -464,6 +480,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
464480
aria-expanded={isSubTreeEmpty ? undefined : isExpanded}
465481
aria-current={isCurrentItem ? 'true' : undefined}
466482
aria-selected={isFocused ? 'true' : 'false'}
483+
data-has-leading-action={slots.leadingAction ? true : undefined}
467484
onKeyDown={handleKeyDown}
468485
onFocus={event => {
469486
// Scroll the first child into view when the item receives focus
@@ -503,6 +520,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
503520
<div style={{gridArea: 'spacer', display: 'flex'}}>
504521
<LevelIndicatorLines level={level} />
505522
</div>
523+
{slots.leadingAction}
506524
{hasSubTree ? (
507525
// This lint rule is disabled due to the guidelines in the `TreeView` api docs.
508526
// https://github.com/github/primer/blob/main/apis/tree-view-api.md#the-expandcollapse-chevron-toggle
@@ -848,6 +866,25 @@ const TrailingVisual: React.FC<TreeViewVisualProps> = props => {
848866

849867
TrailingVisual.displayName = 'TreeView.TrailingVisual'
850868

869+
// ----------------------------------------------------------------------------
870+
// TreeView.LeadingAction
871+
872+
const LeadingAction: React.FC<TreeViewVisualProps> = props => {
873+
const {isExpanded} = React.useContext(ItemContext)
874+
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
875+
return (
876+
<>
877+
<div className="PRIVATE_VisuallyHidden" aria-hidden={true}>
878+
{props.label}
879+
</div>
880+
<div className="PRIVATE_TreeView-item-leading-action" aria-hidden={true}>
881+
{children}
882+
</div>
883+
</>
884+
)
885+
}
886+
887+
LeadingAction.displayName = 'TreeView.LeadingAction'
851888
// ----------------------------------------------------------------------------
852889
// TreeView.DirectoryIcon
853890

@@ -917,6 +954,7 @@ ErrorDialog.displayName = 'TreeView.ErrorDialog'
917954
export const TreeView = Object.assign(Root, {
918955
Item,
919956
SubTree,
957+
LeadingAction,
920958
LeadingVisual,
921959
TrailingVisual,
922960
DirectoryIcon,

0 commit comments

Comments
 (0)