Skip to content

Commit 77c0259

Browse files
joshblackmperrotticolebemis
authored
feat(TreeView): add count prop to TreeView.SubTree (#2455)
* 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>
1 parent c3eedb2 commit 77c0259

File tree

4 files changed

+274
-54
lines changed

4 files changed

+274
-54
lines changed

.changeset/tender-turtles-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': patch
3+
---
4+
5+
TreeView: Add support for a skeleton state with the TreeView.SubTree `count` prop

docs/content/TreeView.mdx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -307,7 +307,12 @@ See [Storybook](https://primer.style/react/storybook?path=/story/components-tree
307307
</>
308308
}
309309
/>
310-
{/* <PropsTableSxRow /> */}
310+
<PropsTableRow
311+
name="count"
312+
type="number"
313+
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"
314+
/>
315+
<PropsTableSxRow />
311316
</PropsTable>
312317

313318
### TreeView.LeadingVisual

src/TreeView/TreeView.stories.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,107 @@ AsyncSuccess.args = {
459459
responseTime: 2000
460460
}
461461

462+
export const AsyncWithCount: Story = args => {
463+
const [isLoading, setIsLoading] = React.useState(false)
464+
const [asyncItems, setAsyncItems] = React.useState<string[]>([])
465+
466+
let state: SubTreeState = 'initial'
467+
468+
if (isLoading) {
469+
state = 'loading'
470+
} else if (asyncItems.length > 0) {
471+
state = 'done'
472+
}
473+
474+
return (
475+
<Box sx={{p: 3}}>
476+
<nav aria-label="File navigation">
477+
<TreeView aria-label="File navigation">
478+
<TreeView.Item
479+
onExpandedChange={async isExpanded => {
480+
if (asyncItems.length === 0 && isExpanded) {
481+
setIsLoading(true)
482+
483+
// Load items
484+
const items = await loadItems(args.responseTime)
485+
486+
setIsLoading(false)
487+
setAsyncItems(items)
488+
}
489+
}}
490+
>
491+
<TreeView.LeadingVisual>
492+
<TreeView.DirectoryIcon />
493+
</TreeView.LeadingVisual>
494+
Directory with async items
495+
<TreeView.SubTree state={state} count={args.count}>
496+
{asyncItems.map(item => (
497+
<TreeView.Item key={item}>
498+
<TreeView.LeadingVisual>
499+
<FileIcon />
500+
</TreeView.LeadingVisual>
501+
{item}
502+
</TreeView.Item>
503+
))}
504+
</TreeView.SubTree>
505+
</TreeView.Item>
506+
<TreeView.LinkItem href="#src">
507+
<TreeView.LeadingVisual>
508+
<TreeView.DirectoryIcon />
509+
</TreeView.LeadingVisual>
510+
src
511+
<TreeView.SubTree>
512+
<TreeView.LinkItem href="#avatar-tsx">
513+
<TreeView.LeadingVisual>
514+
<FileIcon />
515+
</TreeView.LeadingVisual>
516+
Avatar.tsx
517+
</TreeView.LinkItem>
518+
<TreeView.LinkItem href="#button" current>
519+
<TreeView.LeadingVisual>
520+
<TreeView.DirectoryIcon />
521+
</TreeView.LeadingVisual>
522+
Button
523+
<TreeView.SubTree>
524+
<TreeView.LinkItem href="#button-tsx">
525+
<TreeView.LeadingVisual>
526+
<FileIcon />
527+
</TreeView.LeadingVisual>
528+
Button.tsx
529+
</TreeView.LinkItem>
530+
<TreeView.LinkItem href="#button-test-tsx">
531+
<TreeView.LeadingVisual>
532+
<FileIcon />
533+
</TreeView.LeadingVisual>
534+
Button.test.tsx
535+
</TreeView.LinkItem>
536+
</TreeView.SubTree>
537+
</TreeView.LinkItem>
538+
<TreeView.Item>
539+
<TreeView.LeadingVisual>
540+
<FileIcon />
541+
</TreeView.LeadingVisual>
542+
ReallyLongFileNameThatShouldBeTruncated.tsx
543+
</TreeView.Item>
544+
</TreeView.SubTree>
545+
</TreeView.LinkItem>
546+
</TreeView>
547+
</nav>
548+
</Box>
549+
)
550+
}
551+
552+
AsyncWithCount.args = {
553+
responseTime: 2000,
554+
count: 3
555+
}
556+
557+
AsyncWithCount.argTypes = {
558+
count: {
559+
type: 'number'
560+
}
561+
}
562+
462563
async function alwaysFails(responseTime: number) {
463564
await wait(responseTime)
464565
throw new Error('Failed to load items')

src/TreeView/TreeView.tsx

Lines changed: 162 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import {
66
} from '@primer/octicons-react'
77
import {useSSRSafeId} from '@react-aria/ssr'
88
import React from 'react'
9-
import styled from 'styled-components'
9+
import styled, {keyframes} from 'styled-components'
1010
import Box from '../Box'
11+
import {get} from '../constants'
1112
import {useControllableState} from '../hooks/useControllableState'
1213
import useSafeTimeout from '../hooks/useSafeTimeout'
1314
import Spinner from '../Spinner'
1415
import StyledOcticon from '../StyledOcticon'
15-
import sx, {SxProp} from '../sx'
16+
import sx, {SxProp, merge} from '../sx'
1617
import Text from '../Text'
1718
import {Theme} from '../ThemeProvider'
1819
import createSlots from '../utils/create-slots'
@@ -112,12 +113,15 @@ export type TreeViewItemProps = {
112113
expanded?: boolean
113114
onExpandedChange?: (expanded: boolean) => void
114115
onSelect?: (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => void
115-
}
116+
} & SxProp
116117

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

119120
const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
120-
({current: isCurrentItem = false, defaultExpanded = false, expanded, onExpandedChange, onSelect, children}, ref) => {
121+
(
122+
{current: isCurrentItem = false, defaultExpanded = false, expanded, onExpandedChange, onSelect, children, sx = {}},
123+
ref
124+
) => {
121125
const itemId = useSSRSafeId()
122126
const labelId = useSSRSafeId()
123127
const leadingVisualId = useSSRSafeId()
@@ -219,54 +223,57 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
219223
toggle(event)
220224
}
221225
}}
222-
sx={{
223-
'--toggle-width': '1rem', // 16px
224-
position: 'relative',
225-
display: 'grid',
226-
gridTemplateColumns: `calc(${level - 1} * (var(--toggle-width) / 2)) var(--toggle-width) 1fr`,
227-
gridTemplateAreas: `"spacer toggle content"`,
228-
width: '100%',
229-
height: '2rem', // 32px
230-
fontSize: 1,
231-
color: 'fg.default',
232-
borderRadius: 2,
233-
cursor: 'pointer',
234-
'&:hover': {
235-
backgroundColor: 'actionListItem.default.hoverBg',
236-
'@media (forced-colors: active)': {
237-
outline: '2px solid transparent',
238-
outlineOffset: -2
239-
}
240-
},
241-
'@media (pointer: coarse)': {
242-
'--toggle-width': '1.5rem', // 24px
243-
height: '2.75rem' // 44px
244-
},
245-
// WARNING: styled-components v5.2 introduced a bug that changed
246-
// how it expands `&` in CSS selectors. The following selectors
247-
// are unnecessarily specific to work around that styled-components bug.
248-
// Reference issue: https://github.com/styled-components/styled-components/issues/3265
249-
[`#${itemId}:focus-visible > &:is(div)`]: {
250-
boxShadow: (theme: Theme) => `inset 0 0 0 2px ${theme.colors.accent.emphasis}`,
251-
'@media (forced-colors: active)': {
252-
outline: '2px solid SelectedItem',
253-
outlineOffset: -2
226+
sx={merge.all([
227+
{
228+
'--toggle-width': '1rem', // 16px
229+
position: 'relative',
230+
display: 'grid',
231+
gridTemplateColumns: `calc(${level - 1} * (var(--toggle-width) / 2)) var(--toggle-width) 1fr`,
232+
gridTemplateAreas: `"spacer toggle content"`,
233+
width: '100%',
234+
minHeight: '2rem', // 32px
235+
fontSize: 1,
236+
color: 'fg.default',
237+
borderRadius: 2,
238+
cursor: 'pointer',
239+
'&:hover': {
240+
backgroundColor: 'actionListItem.default.hoverBg',
241+
'@media (forced-colors: active)': {
242+
outline: '2px solid transparent',
243+
outlineOffset: -2
244+
}
245+
},
246+
'@media (pointer: coarse)': {
247+
'--toggle-width': '1.5rem', // 24px
248+
minHeight: '2.75rem' // 44px
249+
},
250+
// WARNING: styled-components v5.2 introduced a bug that changed
251+
// how it expands `&` in CSS selectors. The following selectors
252+
// are unnecessarily specific to work around that styled-components bug.
253+
// Reference issue: https://github.com/styled-components/styled-components/issues/3265
254+
[`#${itemId}:focus-visible > &:is(div)`]: {
255+
boxShadow: (theme: Theme) => `inset 0 0 0 2px ${theme.colors.accent.emphasis}`,
256+
'@media (forced-colors: active)': {
257+
outline: '2px solid SelectedItem',
258+
outlineOffset: -2
259+
}
260+
},
261+
'[role=treeitem][aria-current=true] > &:is(div)': {
262+
bg: 'actionListItem.default.selectedBg',
263+
'&::after': {
264+
position: 'absolute',
265+
top: 'calc(50% - 12px)',
266+
left: -2,
267+
width: '4px',
268+
height: '24px',
269+
content: '""',
270+
bg: 'accent.fg',
271+
borderRadius: 2
272+
}
254273
}
255274
},
256-
'[role=treeitem][aria-current=true] > &:is(div)': {
257-
bg: 'actionListItem.default.selectedBg',
258-
'&::after': {
259-
position: 'absolute',
260-
top: 'calc(50% - 12px)',
261-
left: -2,
262-
width: '4px',
263-
height: '24px',
264-
content: '""',
265-
bg: 'accent.fg',
266-
borderRadius: 2
267-
}
268-
}
269-
}}
275+
sx as SxProp
276+
])}
270277
>
271278
<Box sx={{gridArea: 'spacer', display: 'flex'}}>
272279
<LevelIndicatorLines level={level} />
@@ -401,9 +408,13 @@ export type SubTreeState = 'initial' | 'loading' | 'done' | 'error'
401408
export type TreeViewSubTreeProps = {
402409
children?: React.ReactNode
403410
state?: SubTreeState
411+
/**
412+
* Display a skeleton loading state with the specified count of items
413+
*/
414+
count?: number
404415
}
405416

406-
const SubTree: React.FC<TreeViewSubTreeProps> = ({state, children}) => {
417+
const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
407418
const {announceUpdate} = React.useContext(RootContext)
408419
const {itemId, isExpanded} = React.useContext(ItemContext)
409420
const [isLoadingItemVisible, setIsLoadingItemVisible] = React.useState(false)
@@ -469,14 +480,112 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({state, children}) => {
469480
margin: 0
470481
}}
471482
>
472-
{isLoadingItemVisible ? <LoadingItem ref={loadingItemRef} /> : children}
483+
{isLoadingItemVisible ? <LoadingItem ref={loadingItemRef} count={count} /> : children}
473484
</Box>
474485
)
475486
}
476487

477488
SubTree.displayName = 'TreeView.SubTree'
478489

479-
const LoadingItem = React.forwardRef<HTMLElement>((props, ref) => {
490+
const shimmer = keyframes`
491+
from { mask-position: 200%; }
492+
to { mask-position: 0%; }
493+
`
494+
495+
const SkeletonItem = styled.span`
496+
display: flex;
497+
align-items: center;
498+
column-gap: 0.5rem;
499+
height: 2rem;
500+
501+
@media (pointer: coarse) {
502+
height: 2.75rem;
503+
}
504+
505+
@media (prefers-reduced-motion: no-preference) {
506+
mask-image: linear-gradient(75deg, #000 30%, rgba(0, 0, 0, 0.65) 80%);
507+
mask-size: 200%;
508+
animation: ${shimmer};
509+
animation-duration: 1s;
510+
animation-iteration-count: infinite;
511+
}
512+
513+
&::before {
514+
content: '';
515+
display: block;
516+
width: 1rem;
517+
height: 1rem;
518+
background-color: ${get('colors.neutral.subtle')};
519+
border-radius: 3px;
520+
@media (forced-colors: active) {
521+
outline: 1px solid transparent;
522+
outline-offset: -1px;
523+
}
524+
}
525+
526+
&::after {
527+
content: '';
528+
display: block;
529+
width: var(--tree-item-loading-width, 67%);
530+
height: 1rem;
531+
background-color: ${get('colors.neutral.subtle')};
532+
border-radius: 3px;
533+
@media (forced-colors: active) {
534+
outline: 1px solid transparent;
535+
outline-offset: -1px;
536+
}
537+
}
538+
539+
&:nth-of-type(5n + 1) {
540+
--tree-item-loading-width: 67%;
541+
}
542+
543+
&:nth-of-type(5n + 2) {
544+
--tree-item-loading-width: 47%;
545+
}
546+
547+
&:nth-of-type(5n + 3) {
548+
--tree-item-loading-width: 73%;
549+
}
550+
551+
&:nth-of-type(5n + 4) {
552+
--tree-item-loading-width: 64%;
553+
}
554+
555+
&:nth-of-type(5n + 5) {
556+
--tree-item-loading-width: 50%;
557+
}
558+
`
559+
560+
type LoadingItemProps = {
561+
count?: number
562+
}
563+
564+
const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>((props, ref) => {
565+
const {count} = props
566+
567+
if (count) {
568+
return (
569+
<Item
570+
ref={ref}
571+
sx={{
572+
'&:hover': {
573+
backgroundColor: 'transparent',
574+
cursor: 'default',
575+
'@media (forced-colors: active)': {
576+
outline: 'none'
577+
}
578+
}
579+
}}
580+
>
581+
{Array.from({length: count}).map((_, i) => {
582+
return <SkeletonItem aria-hidden={true} key={i} />
583+
})}
584+
<VisuallyHidden>Loading {count} items</VisuallyHidden>
585+
</Item>
586+
)
587+
}
588+
480589
return (
481590
<Item ref={ref}>
482591
<LeadingVisual>

0 commit comments

Comments
 (0)