Skip to content

Commit d459364

Browse files
authored
TreeView: Support async behavior (#2388)
* Add async story * Create TreeView.LoadingItem * Create async error story * Create breezy-bobcats-explain.md * Update docs * Reduce loading item delay in stories * Update docs/content/TreeView.mdx
1 parent 4a4e47c commit d459364

File tree

4 files changed

+184
-10
lines changed

4 files changed

+184
-10
lines changed
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 `TreeView.LoadingItem` component

docs/content/TreeView.mdx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ Since stateful directory icons are a common use case for TreeView, we provide a
181181
</Box>
182182
```
183183

184+
### With asynchronously loaded items
185+
186+
See [Storybook](https://primer.style/react/storybook?path=/story/components-treeview--async-success) for examples with asynchronously loaded items.
187+
184188
## Props
185189

186190
### TreeView
@@ -266,6 +270,10 @@ Since stateful directory icons are a common use case for TreeView, we provide a
266270
{/* <PropsTableSxRow /> */}
267271
</PropsTable>
268272

273+
### TreeView.LoadingItem
274+
275+
<PropsTable>{/* <PropsTableSxRow /> */}</PropsTable>
276+
269277
### TreeView.SubTree
270278

271279
<PropsTable>
@@ -299,8 +307,6 @@ Since stateful directory icons are a common use case for TreeView, we provide a
299307

300308
<PropsTable>{/* <PropsTableSxRow /> */}</PropsTable>
301309

302-
<!-- TODO: Add components to support async behavior (e.g. LoadingItem) -->
303-
304310
## Status
305311

306312
<ComponentChecklist

src/TreeView/TreeView.stories.tsx

Lines changed: 149 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import React from 'react'
21
import {DiffAddedIcon, DiffModifiedIcon, DiffRemovedIcon, DiffRenamedIcon, FileIcon} from '@primer/octicons-react'
32
import {Meta, Story} from '@storybook/react'
3+
import React from 'react'
4+
import {ActionList} from '../ActionList'
5+
import {ActionMenu} from '../ActionMenu'
46
import Box from '../Box'
7+
import {Button} from '../Button'
8+
import {ConfirmationDialog} from '../Dialog/ConfirmationDialog'
59
import StyledOcticon from '../StyledOcticon'
610
import {TreeView} from './TreeView'
7-
import {Button} from '../Button'
8-
import {ActionMenu} from '../ActionMenu'
9-
import {ActionList} from '../ActionList'
1011

1112
const meta: Meta = {
1213
title: 'Components/TreeView',
@@ -404,4 +405,148 @@ function TreeItem({
404405
)
405406
}
406407

408+
async function wait(ms: number) {
409+
return new Promise(resolve => setTimeout(resolve, ms))
410+
}
411+
412+
async function loadItems(responseTime: number) {
413+
await wait(responseTime)
414+
return ['Avatar.tsx', 'Button.tsx', 'Checkbox.tsx']
415+
}
416+
417+
export const AsyncSuccess: Story = args => {
418+
const [isLoading, setIsLoading] = React.useState(false)
419+
const [asyncItems, setAsyncItems] = React.useState<string[]>([])
420+
421+
return (
422+
<Box sx={{p: 3}}>
423+
<nav aria-label="File navigation">
424+
<TreeView aria-label="File navigation">
425+
<TreeView.Item
426+
onExpandedChange={async isExpanded => {
427+
if (asyncItems.length === 0 && isExpanded) {
428+
// Show loading indicator after a short delay
429+
const timeout = setTimeout(() => setIsLoading(true), 300)
430+
431+
// Load items
432+
const items = await loadItems(args.responseTime)
433+
434+
clearTimeout(timeout)
435+
setIsLoading(false)
436+
setAsyncItems(items)
437+
}
438+
}}
439+
>
440+
<TreeView.LeadingVisual>
441+
<TreeView.DirectoryIcon />
442+
</TreeView.LeadingVisual>
443+
Directory with async items
444+
<TreeView.SubTree>
445+
{isLoading ? <TreeView.LoadingItem /> : null}
446+
{asyncItems.map(item => (
447+
<TreeView.Item key={item}>
448+
<TreeView.LeadingVisual>
449+
<FileIcon />
450+
</TreeView.LeadingVisual>
451+
{item}
452+
</TreeView.Item>
453+
))}
454+
</TreeView.SubTree>
455+
</TreeView.Item>
456+
</TreeView>
457+
</nav>
458+
</Box>
459+
)
460+
}
461+
462+
AsyncSuccess.args = {
463+
responseTime: 2000
464+
}
465+
466+
async function alwaysFails(responseTime: number) {
467+
await wait(responseTime)
468+
throw new Error('Failed to load items')
469+
return []
470+
}
471+
472+
export const AsyncError: Story = args => {
473+
const [isLoading, setIsLoading] = React.useState(false)
474+
const [isExpanded, setIsExpanded] = React.useState(false)
475+
const [asyncItems, setAsyncItems] = React.useState<string[]>([])
476+
const [error, setError] = React.useState<Error | null>(null)
477+
478+
async function loadItems() {
479+
if (asyncItems.length === 0) {
480+
// Show loading indicator after a short delay
481+
const timeout = setTimeout(() => setIsLoading(true), 300)
482+
try {
483+
// Try to load items
484+
const items = await alwaysFails(args.responseTime)
485+
setAsyncItems(items)
486+
} catch (error) {
487+
setError(error as Error)
488+
} finally {
489+
clearTimeout(timeout)
490+
setIsLoading(false)
491+
}
492+
}
493+
}
494+
495+
return (
496+
<Box sx={{p: 3}}>
497+
<nav aria-label="File navigation">
498+
<TreeView aria-label="File navigation">
499+
<TreeView.Item
500+
expanded={isExpanded}
501+
onExpandedChange={isExpanded => {
502+
setIsExpanded(isExpanded)
503+
504+
if (isExpanded) {
505+
loadItems()
506+
}
507+
}}
508+
>
509+
<TreeView.LeadingVisual>
510+
<TreeView.DirectoryIcon />
511+
</TreeView.LeadingVisual>
512+
Directory with async items
513+
<TreeView.SubTree>
514+
{isLoading ? <TreeView.LoadingItem /> : null}
515+
{error ? (
516+
<ConfirmationDialog
517+
title="Error"
518+
onClose={gesture => {
519+
setError(null)
520+
521+
if (gesture === 'confirm') {
522+
loadItems()
523+
} else {
524+
setIsExpanded(false)
525+
}
526+
}}
527+
confirmButtonContent="Retry"
528+
>
529+
{error.message}
530+
</ConfirmationDialog>
531+
) : null}
532+
{asyncItems.map(item => (
533+
<TreeView.Item key={item}>
534+
<TreeView.LeadingVisual>
535+
<FileIcon />
536+
</TreeView.LeadingVisual>
537+
{item}
538+
</TreeView.Item>
539+
))}
540+
</TreeView.SubTree>
541+
</TreeView.Item>
542+
</TreeView>
543+
</nav>
544+
</Box>
545+
)
546+
}
547+
548+
AsyncError.args = {
549+
responseTime: 2000
550+
}
551+
407552
export default meta

src/TreeView/TreeView.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {useSSRSafeId} from '@react-aria/ssr'
88
import React from 'react'
99
import styled from 'styled-components'
1010
import Box from '../Box'
11-
import StyledOcticon from '../StyledOcticon'
1211
import {useControllableState} from '../hooks/useControllableState'
12+
import Spinner from '../Spinner'
13+
import StyledOcticon from '../StyledOcticon'
1314
import sx, {SxProp} from '../sx'
1415
import Text from '../Text'
1516
import {Theme} from '../ThemeProvider'
@@ -282,7 +283,6 @@ const Item: React.FC<TreeViewItemProps> = ({
282283
>
283284
<Slots>
284285
{slots => (
285-
// QUESTION: How should leading and trailing visuals impact the aria-label?
286286
<>
287287
{slots.LeadingVisual}
288288
<Text
@@ -362,6 +362,23 @@ const LinkItem: React.FC<TreeViewLinkItemProps> = ({href, onSelect, ...props}) =
362362
)
363363
}
364364

365+
// ----------------------------------------------------------------------------
366+
// TreeView.LoadingItem
367+
368+
const LoadingItem: React.FC = () => {
369+
return (
370+
// TODO: What aria attributes do we need to add here?
371+
<Item>
372+
<LeadingVisual>
373+
<Spinner size="small" />
374+
</LeadingVisual>
375+
<Text sx={{color: 'fg.muted'}}>Loading...</Text>
376+
</Item>
377+
)
378+
}
379+
380+
LoadingItem.displayName = 'TreeView.LoadingItem'
381+
365382
// ----------------------------------------------------------------------------
366383
// TreeView.SubTree
367384

@@ -417,7 +434,7 @@ const LeadingVisual: React.FC<TreeViewVisualProps> = props => {
417434
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
418435
return (
419436
<Slot name="LeadingVisual">
420-
<Box sx={{color: 'fg.muted'}}>{children}</Box>
437+
<Box sx={{display: 'flex', color: 'fg.muted'}}>{children}</Box>
421438
</Slot>
422439
)
423440
}
@@ -427,7 +444,7 @@ const TrailingVisual: React.FC<TreeViewVisualProps> = props => {
427444
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
428445
return (
429446
<Slot name="TrailingVisual">
430-
<Box sx={{color: 'fg.muted'}}>{children}</Box>
447+
<Box sx={{display: 'flex', color: 'fg.muted'}}>{children}</Box>
431448
</Slot>
432449
)
433450
}
@@ -448,6 +465,7 @@ const DirectoryIcon = () => {
448465
export const TreeView = Object.assign(Root, {
449466
Item,
450467
LinkItem,
468+
LoadingItem,
451469
SubTree,
452470
LeadingVisual,
453471
TrailingVisual,

0 commit comments

Comments
 (0)