Skip to content

Commit

Permalink
feat(TreeView): add count prop to TreeView.SubTree (#2455)
Browse files Browse the repository at this point in the history
* feat(TreeView): add count prop to TreeView.SubTree

Co-authored-by: Mike Perrotti <mperrotti@users.noreply.github.com>

* chore: add changeset

* refactor(TreeView): update px units to rem

* Update docs/content/TreeView.mdx

Co-authored-by: Cole Bemis <colebemis@github.com>

* Update .changeset/tender-turtles-serve.md

Co-authored-by: Cole Bemis <colebemis@github.com>

* refactor(TreeView): update stories and merge sx prop

* refactor: update height for items and adjust height for coarse pointers

* Update src/TreeView/TreeView.stories.tsx

Co-authored-by: Cole Bemis <colebemis@github.com>

* fix: update coarse pointer styles

Co-authored-by: Mike Perrotti <mperrotti@users.noreply.github.com>
Co-authored-by: Cole Bemis <colebemis@github.com>
  • Loading branch information
3 people committed Oct 20, 2022
1 parent c3eedb2 commit 77c0259
Show file tree
Hide file tree
Showing 4 changed files with 274 additions and 54 deletions.
5 changes: 5 additions & 0 deletions .changeset/tender-turtles-serve.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

TreeView: Add support for a skeleton state with the TreeView.SubTree `count` prop
7 changes: 6 additions & 1 deletion docs/content/TreeView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,12 @@ See [Storybook](https://primer.style/react/storybook?path=/story/components-tree
</>
}
/>
{/* <PropsTableSxRow /> */}
<PropsTableRow
name="count"
type="number"
description="The number of items expected to be in the subtree. When in the loading state, the subtree will render a skeleton loading placeholder with the specified count of items"
/>
<PropsTableSxRow />
</PropsTable>

### TreeView.LeadingVisual
Expand Down
101 changes: 101 additions & 0 deletions src/TreeView/TreeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,107 @@ AsyncSuccess.args = {
responseTime: 2000
}

export const AsyncWithCount: Story = args => {
const [isLoading, setIsLoading] = React.useState(false)
const [asyncItems, setAsyncItems] = React.useState<string[]>([])

let state: SubTreeState = 'initial'

if (isLoading) {
state = 'loading'
} else if (asyncItems.length > 0) {
state = 'done'
}

return (
<Box sx={{p: 3}}>
<nav aria-label="File navigation">
<TreeView aria-label="File navigation">
<TreeView.Item
onExpandedChange={async isExpanded => {
if (asyncItems.length === 0 && isExpanded) {
setIsLoading(true)

// Load items
const items = await loadItems(args.responseTime)

setIsLoading(false)
setAsyncItems(items)
}
}}
>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Directory with async items
<TreeView.SubTree state={state} count={args.count}>
{asyncItems.map(item => (
<TreeView.Item key={item}>
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
{item}
</TreeView.Item>
))}
</TreeView.SubTree>
</TreeView.Item>
<TreeView.LinkItem href="#src">
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
src
<TreeView.SubTree>
<TreeView.LinkItem href="#avatar-tsx">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
Avatar.tsx
</TreeView.LinkItem>
<TreeView.LinkItem href="#button" current>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Button
<TreeView.SubTree>
<TreeView.LinkItem href="#button-tsx">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
Button.tsx
</TreeView.LinkItem>
<TreeView.LinkItem href="#button-test-tsx">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
Button.test.tsx
</TreeView.LinkItem>
</TreeView.SubTree>
</TreeView.LinkItem>
<TreeView.Item>
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
ReallyLongFileNameThatShouldBeTruncated.tsx
</TreeView.Item>
</TreeView.SubTree>
</TreeView.LinkItem>
</TreeView>
</nav>
</Box>
)
}

AsyncWithCount.args = {
responseTime: 2000,
count: 3
}

AsyncWithCount.argTypes = {
count: {
type: 'number'
}
}

async function alwaysFails(responseTime: number) {
await wait(responseTime)
throw new Error('Failed to load items')
Expand Down
215 changes: 162 additions & 53 deletions src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import {
} from '@primer/octicons-react'
import {useSSRSafeId} from '@react-aria/ssr'
import React from 'react'
import styled from 'styled-components'
import styled, {keyframes} from 'styled-components'
import Box from '../Box'
import {get} from '../constants'
import {useControllableState} from '../hooks/useControllableState'
import useSafeTimeout from '../hooks/useSafeTimeout'
import Spinner from '../Spinner'
import StyledOcticon from '../StyledOcticon'
import sx, {SxProp} from '../sx'
import sx, {SxProp, merge} from '../sx'
import Text from '../Text'
import {Theme} from '../ThemeProvider'
import createSlots from '../utils/create-slots'
Expand Down Expand Up @@ -112,12 +113,15 @@ export type TreeViewItemProps = {
expanded?: boolean
onExpandedChange?: (expanded: boolean) => void
onSelect?: (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => void
}
} & SxProp

const {Slots, Slot} = createSlots(['LeadingVisual', 'TrailingVisual'])

const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
({current: isCurrentItem = false, defaultExpanded = false, expanded, onExpandedChange, onSelect, children}, ref) => {
(
{current: isCurrentItem = false, defaultExpanded = false, expanded, onExpandedChange, onSelect, children, sx = {}},
ref
) => {
const itemId = useSSRSafeId()
const labelId = useSSRSafeId()
const leadingVisualId = useSSRSafeId()
Expand Down Expand Up @@ -219,54 +223,57 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
toggle(event)
}
}}
sx={{
'--toggle-width': '1rem', // 16px
position: 'relative',
display: 'grid',
gridTemplateColumns: `calc(${level - 1} * (var(--toggle-width) / 2)) var(--toggle-width) 1fr`,
gridTemplateAreas: `"spacer toggle content"`,
width: '100%',
height: '2rem', // 32px
fontSize: 1,
color: 'fg.default',
borderRadius: 2,
cursor: 'pointer',
'&:hover': {
backgroundColor: 'actionListItem.default.hoverBg',
'@media (forced-colors: active)': {
outline: '2px solid transparent',
outlineOffset: -2
}
},
'@media (pointer: coarse)': {
'--toggle-width': '1.5rem', // 24px
height: '2.75rem' // 44px
},
// WARNING: styled-components v5.2 introduced a bug that changed
// how it expands `&` in CSS selectors. The following selectors
// are unnecessarily specific to work around that styled-components bug.
// Reference issue: https://github.com/styled-components/styled-components/issues/3265
[`#${itemId}:focus-visible > &:is(div)`]: {
boxShadow: (theme: Theme) => `inset 0 0 0 2px ${theme.colors.accent.emphasis}`,
'@media (forced-colors: active)': {
outline: '2px solid SelectedItem',
outlineOffset: -2
sx={merge.all([
{
'--toggle-width': '1rem', // 16px
position: 'relative',
display: 'grid',
gridTemplateColumns: `calc(${level - 1} * (var(--toggle-width) / 2)) var(--toggle-width) 1fr`,
gridTemplateAreas: `"spacer toggle content"`,
width: '100%',
minHeight: '2rem', // 32px
fontSize: 1,
color: 'fg.default',
borderRadius: 2,
cursor: 'pointer',
'&:hover': {
backgroundColor: 'actionListItem.default.hoverBg',
'@media (forced-colors: active)': {
outline: '2px solid transparent',
outlineOffset: -2
}
},
'@media (pointer: coarse)': {
'--toggle-width': '1.5rem', // 24px
minHeight: '2.75rem' // 44px
},
// WARNING: styled-components v5.2 introduced a bug that changed
// how it expands `&` in CSS selectors. The following selectors
// are unnecessarily specific to work around that styled-components bug.
// Reference issue: https://github.com/styled-components/styled-components/issues/3265
[`#${itemId}:focus-visible > &:is(div)`]: {
boxShadow: (theme: Theme) => `inset 0 0 0 2px ${theme.colors.accent.emphasis}`,
'@media (forced-colors: active)': {
outline: '2px solid SelectedItem',
outlineOffset: -2
}
},
'[role=treeitem][aria-current=true] > &:is(div)': {
bg: 'actionListItem.default.selectedBg',
'&::after': {
position: 'absolute',
top: 'calc(50% - 12px)',
left: -2,
width: '4px',
height: '24px',
content: '""',
bg: 'accent.fg',
borderRadius: 2
}
}
},
'[role=treeitem][aria-current=true] > &:is(div)': {
bg: 'actionListItem.default.selectedBg',
'&::after': {
position: 'absolute',
top: 'calc(50% - 12px)',
left: -2,
width: '4px',
height: '24px',
content: '""',
bg: 'accent.fg',
borderRadius: 2
}
}
}}
sx as SxProp
])}
>
<Box sx={{gridArea: 'spacer', display: 'flex'}}>
<LevelIndicatorLines level={level} />
Expand Down Expand Up @@ -401,9 +408,13 @@ export type SubTreeState = 'initial' | 'loading' | 'done' | 'error'
export type TreeViewSubTreeProps = {
children?: React.ReactNode
state?: SubTreeState
/**
* Display a skeleton loading state with the specified count of items
*/
count?: number
}

const SubTree: React.FC<TreeViewSubTreeProps> = ({state, children}) => {
const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
const {announceUpdate} = React.useContext(RootContext)
const {itemId, isExpanded} = React.useContext(ItemContext)
const [isLoadingItemVisible, setIsLoadingItemVisible] = React.useState(false)
Expand Down Expand Up @@ -469,14 +480,112 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({state, children}) => {
margin: 0
}}
>
{isLoadingItemVisible ? <LoadingItem ref={loadingItemRef} /> : children}
{isLoadingItemVisible ? <LoadingItem ref={loadingItemRef} count={count} /> : children}
</Box>
)
}

SubTree.displayName = 'TreeView.SubTree'

const LoadingItem = React.forwardRef<HTMLElement>((props, ref) => {
const shimmer = keyframes`
from { mask-position: 200%; }
to { mask-position: 0%; }
`

const SkeletonItem = styled.span`
display: flex;
align-items: center;
column-gap: 0.5rem;
height: 2rem;
@media (pointer: coarse) {
height: 2.75rem;
}
@media (prefers-reduced-motion: no-preference) {
mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%);
mask-size: 200%;
animation: ${shimmer};
animation-duration: 1s;
animation-iteration-count: infinite;
}
&::before {
content: '';
display: block;
width: 1rem;
height: 1rem;
background-color: ${get('colors.neutral.subtle')};
border-radius: 3px;
@media (forced-colors: active) {
outline: 1px solid transparent;
outline-offset: -1px;
}
}
&::after {
content: '';
display: block;
width: var(--tree-item-loading-width, 67%);
height: 1rem;
background-color: ${get('colors.neutral.subtle')};
border-radius: 3px;
@media (forced-colors: active) {
outline: 1px solid transparent;
outline-offset: -1px;
}
}
&:nth-of-type(5n + 1) {
--tree-item-loading-width: 67%;
}
&:nth-of-type(5n + 2) {
--tree-item-loading-width: 47%;
}
&:nth-of-type(5n + 3) {
--tree-item-loading-width: 73%;
}
&:nth-of-type(5n + 4) {
--tree-item-loading-width: 64%;
}
&:nth-of-type(5n + 5) {
--tree-item-loading-width: 50%;
}
`

type LoadingItemProps = {
count?: number
}

const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>((props, ref) => {
const {count} = props

if (count) {
return (
<Item
ref={ref}
sx={{
'&:hover': {
backgroundColor: 'transparent',
cursor: 'default',
'@media (forced-colors: active)': {
outline: 'none'
}
}
}}
>
{Array.from({length: count}).map((_, i) => {
return <SkeletonItem aria-hidden={true} key={i} />
})}
<VisuallyHidden>Loading {count} items</VisuallyHidden>
</Item>
)
}

return (
<Item ref={ref}>
<LeadingVisual>
Expand Down

0 comments on commit 77c0259

Please sign in to comment.