Skip to content

Commit

Permalink
Add TreeView.LeadingAction sub-component (#4546)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
4 people committed May 15, 2024
1 parent 5647054 commit c81898c
Show file tree
Hide file tree
Showing 15 changed files with 217 additions and 4 deletions.
5 changes: 5 additions & 0 deletions .changeset/tame-nails-live.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": minor
---

TreeView: Add support for `TreeView.LeadingAction`
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
33 changes: 33 additions & 0 deletions e2e/components/TreeView.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,37 @@ test.describe('TreeView', () => {
})
}
})

test.describe('Leading Action', () => {
for (const theme of themes) {
test.describe(theme, () => {
test('default @vrt', async ({page}) => {
await visit(page, {
id: 'components-treeview-features--leading-action',
globals: {
colorScheme: theme,
},
})

expect(await page.screenshot()).toMatchSnapshot(`TreeView.Leading Action.${theme}.png`)
})

test('axe @aat', async ({page}) => {
await visit(page, {
id: 'components-treeview-features--leading-action',
globals: {
colorScheme: theme,
},
})
await expect(page).toHaveNoViolations({
rules: {
'color-contrast': {
enabled: theme !== 'dark_dimmed',
},
},
})
})
})
}
})
})
10 changes: 10 additions & 0 deletions packages/react/src/TreeView/TreeView.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,16 @@
}
]
},
{
"name": "TreeView.LeadingAction",
"props": [
{
"name": "children",
"required": true,
"type": "React.ReactNode"
}
]
},
{
"name": "TreeView.DirectoryIcon",
"props": []
Expand Down
76 changes: 76 additions & 0 deletions packages/react/src/TreeView/TreeView.examples.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {GrabberIcon} from '@primer/octicons-react'
import type {Meta, Story} from '@storybook/react'
import React from 'react'
import Box from '../Box'
import {TreeView} from './TreeView'
import {IconButton} from '../Button'

const meta: Meta = {
title: 'Components/TreeView/Examples',
component: TreeView,
decorators: [
Story => {
return (
// Prevent TreeView from expanding to the full width of the screen
<Box sx={{maxWidth: 400}}>
<Story />
</Box>
)
},
],
}

export const DraggableListItem: Story = () => {
return (
<Box
sx={{
// using Box for css, this could be in a css file as well
'.treeview-item': {
'.treeview-leading-action': {visibility: 'hidden'},
'&:hover, &:focus': {
'.treeview-leading-action': {visibility: 'visible'},
},
},
}}
>
<TreeView aria-label="Issues">
<ControlledDraggableItem id="item-1">Item 1</ControlledDraggableItem>
<ControlledDraggableItem id="item-2">
Item 2
<TreeView.SubTree>
<TreeView.Item id="item-2-sub-task-1">sub task 1</TreeView.Item>
<TreeView.Item id="item-2-sub-task-2">sub task 2</TreeView.Item>
</TreeView.SubTree>
</ControlledDraggableItem>
<ControlledDraggableItem id="item-3">Item 3</ControlledDraggableItem>
</TreeView>
</Box>
)
}

const ControlledDraggableItem: React.FC<{id: string; children: React.ReactNode}> = ({id, children}) => {
const [expanded, setExpanded] = React.useState(false)

return (
<>
<TreeView.Item id={id} className="treeview-item" expanded={expanded} onExpandedChange={setExpanded}>
<TreeView.LeadingAction>
<IconButton
icon={GrabberIcon}
variant="invisible"
aria-label="Reorder item"
className="treeview-leading-action"
draggable="true"
onDragStart={() => {
setExpanded(false)
// other drag logic to follow
}}
/>
</TreeView.LeadingAction>
{children}
</TreeView.Item>
</>
)
}

export default meta
51 changes: 51 additions & 0 deletions packages/react/src/TreeView/TreeView.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import {
DiffRemovedIcon,
DiffRenamedIcon,
FileIcon,
GrabberIcon,
KebabHorizontalIcon,
IssueClosedIcon,
IssueOpenedIcon,
} from '@primer/octicons-react'
import type {Meta, Story} from '@storybook/react'
import React from 'react'
Expand Down Expand Up @@ -989,4 +992,52 @@ export const WithoutIndentation: Story = () => (
</nav>
)

export const LeadingAction: Story = () => {
return (
<TreeView aria-label="Issues">
<TreeView.Item id="item-0">
<TreeView.LeadingAction>
<IconButton icon={GrabberIcon} aria-label="Reorder item 1" variant="invisible" />
</TreeView.LeadingAction>
<TreeView.LeadingVisual>
<Octicon icon={IssueClosedIcon} sx={{color: 'done.fg'}} />
</TreeView.LeadingVisual>
Item 1
</TreeView.Item>
<TreeView.Item id="item-2">
<TreeView.LeadingAction>
<IconButton icon={GrabberIcon} aria-label="Reorder item 2" variant="invisible" />
</TreeView.LeadingAction>
<TreeView.LeadingVisual>
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
</TreeView.LeadingVisual>
Item 2
<TreeView.SubTree>
<TreeView.Item id="item-2-sub-task-1">
<TreeView.LeadingVisual>
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
</TreeView.LeadingVisual>
sub task 1
</TreeView.Item>
<TreeView.Item id="item-2-sub-task-2">
<TreeView.LeadingVisual>
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
</TreeView.LeadingVisual>
sub task 2
</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
<TreeView.Item id="item-3">
<TreeView.LeadingAction>
<IconButton icon={GrabberIcon} aria-label="Reorder item 3" variant="invisible" />
</TreeView.LeadingAction>
<TreeView.LeadingVisual>
<Octicon icon={IssueOpenedIcon} sx={{color: 'open.fg'}} />
</TreeView.LeadingVisual>
Item 3
</TreeView.Item>
</TreeView>
)
}

export default meta
46 changes: 42 additions & 4 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,20 @@ const UlBox = styled.ul<SxProp>`
outline-offset: -2;
}
}
&[data-has-leading-action] {
--has-leading-action: 1;
}
}
.PRIVATE_TreeView-item-container {
--level: 1; /* default level */
--toggle-width: 1rem; /* 16px */
position: relative;
display: grid;
grid-template-columns: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)) var(--toggle-width) 1fr;
grid-template-areas: 'spacer toggle content';
--leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem);
--spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2));
grid-template-columns: var(--spacer-width) var(--leading-action-width) var(--toggle-width) 1fr;
grid-template-areas: 'spacer leadingAction toggle content';
width: 100%;
min-height: 2rem; /* 32px */
font-size: ${get('fontSizes.1')};
Expand Down Expand Up @@ -138,7 +143,7 @@ const UlBox = styled.ul<SxProp>`
}
&[data-omit-spacer='true'] .PRIVATE_TreeView-item-container {
grid-template-columns: 0 0 1fr;
grid-template-columns: 0 0 0 1fr;
}
.PRIVATE_TreeView-item[aria-current='true'] > .PRIVATE_TreeView-item-container {
Expand Down Expand Up @@ -202,6 +207,12 @@ const UlBox = styled.ul<SxProp>`
color: ${get('colors.fg.muted')};
}
.PRIVATE_TreeView-item-leading-action {
display: flex;
color: ${get('colors.fg.muted')};
grid-area: leadingAction;
}
.PRIVATE_TreeView-item-level-line {
width: 100%;
height: 100%;
Expand Down Expand Up @@ -354,11 +365,16 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
},
ref,
) => {
const [slots, rest] = useSlots(children, {leadingVisual: LeadingVisual, trailingVisual: TrailingVisual})
const [slots, rest] = useSlots(children, {
leadingAction: LeadingAction,
leadingVisual: LeadingVisual,
trailingVisual: TrailingVisual,
})
const {expandedStateCache} = React.useContext(RootContext)
const labelId = useId()
const leadingVisualId = useId()
const trailingVisualId = useId()

const [isExpanded, setIsExpanded] = useControllableState({
name: itemId,
// If the item was previously mounted, it's expanded state might be cached.
Expand Down Expand Up @@ -449,6 +465,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
aria-expanded={isSubTreeEmpty ? undefined : isExpanded}
aria-current={isCurrentItem ? 'true' : undefined}
aria-selected={isFocused ? 'true' : 'false'}
data-has-leading-action={slots.leadingAction ? true : undefined}
onKeyDown={handleKeyDown}
onFocus={event => {
// Scroll the first child into view when the item receives focus
Expand Down Expand Up @@ -488,6 +505,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
<div style={{gridArea: 'spacer', display: 'flex'}}>
<LevelIndicatorLines level={level} />
</div>
{slots.leadingAction}
{hasSubTree ? (
// This lint rule is disabled due to the guidelines in the `TreeView` api docs.
// https://github.com/github/primer/blob/main/apis/tree-view-api.md#the-expandcollapse-chevron-toggle
Expand Down Expand Up @@ -829,6 +847,25 @@ const TrailingVisual: React.FC<TreeViewVisualProps> = props => {

TrailingVisual.displayName = 'TreeView.TrailingVisual'

// ----------------------------------------------------------------------------
// TreeView.LeadingAction

const LeadingAction: React.FC<TreeViewVisualProps> = props => {
const {isExpanded} = React.useContext(ItemContext)
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
return (
<>
<div className="PRIVATE_VisuallyHidden" aria-hidden={true}>
{props.label}
</div>
<div className="PRIVATE_TreeView-item-leading-action" aria-hidden={true}>
{children}
</div>
</>
)
}

LeadingAction.displayName = 'TreeView.LeadingAction'
// ----------------------------------------------------------------------------
// TreeView.DirectoryIcon

Expand Down Expand Up @@ -898,6 +935,7 @@ ErrorDialog.displayName = 'TreeView.ErrorDialog'
export const TreeView = Object.assign(Root, {
Item,
SubTree,
LeadingAction,
LeadingVisual,
TrailingVisual,
DirectoryIcon,
Expand Down

0 comments on commit c81898c

Please sign in to comment.