Skip to content

Commit

Permalink
TreeView: Support async behavior (#2388)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
colebemis committed Oct 3, 2022
1 parent 4a4e47c commit d459364
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/breezy-bobcats-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

TreeView: Add `TreeView.LoadingItem` component
10 changes: 8 additions & 2 deletions docs/content/TreeView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,10 @@ Since stateful directory icons are a common use case for TreeView, we provide a
</Box>
```

### With asynchronously loaded items

See [Storybook](https://primer.style/react/storybook?path=/story/components-treeview--async-success) for examples with asynchronously loaded items.

## Props

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

### TreeView.LoadingItem

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

### TreeView.SubTree

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

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

<!-- TODO: Add components to support async behavior (e.g. LoadingItem) -->

## Status

<ComponentChecklist
Expand Down
153 changes: 149 additions & 4 deletions src/TreeView/TreeView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React from 'react'
import {DiffAddedIcon, DiffModifiedIcon, DiffRemovedIcon, DiffRenamedIcon, FileIcon} from '@primer/octicons-react'
import {Meta, Story} from '@storybook/react'
import React from 'react'
import {ActionList} from '../ActionList'
import {ActionMenu} from '../ActionMenu'
import Box from '../Box'
import {Button} from '../Button'
import {ConfirmationDialog} from '../Dialog/ConfirmationDialog'
import StyledOcticon from '../StyledOcticon'
import {TreeView} from './TreeView'
import {Button} from '../Button'
import {ActionMenu} from '../ActionMenu'
import {ActionList} from '../ActionList'

const meta: Meta = {
title: 'Components/TreeView',
Expand Down Expand Up @@ -404,4 +405,148 @@ function TreeItem({
)
}

async function wait(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}

async function loadItems(responseTime: number) {
await wait(responseTime)
return ['Avatar.tsx', 'Button.tsx', 'Checkbox.tsx']
}

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

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) {
// Show loading indicator after a short delay
const timeout = setTimeout(() => setIsLoading(true), 300)

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

clearTimeout(timeout)
setIsLoading(false)
setAsyncItems(items)
}
}}
>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Directory with async items
<TreeView.SubTree>
{isLoading ? <TreeView.LoadingItem /> : null}
{asyncItems.map(item => (
<TreeView.Item key={item}>
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
{item}
</TreeView.Item>
))}
</TreeView.SubTree>
</TreeView.Item>
</TreeView>
</nav>
</Box>
)
}

AsyncSuccess.args = {
responseTime: 2000
}

async function alwaysFails(responseTime: number) {
await wait(responseTime)
throw new Error('Failed to load items')
return []
}

export const AsyncError: Story = args => {
const [isLoading, setIsLoading] = React.useState(false)
const [isExpanded, setIsExpanded] = React.useState(false)
const [asyncItems, setAsyncItems] = React.useState<string[]>([])
const [error, setError] = React.useState<Error | null>(null)

async function loadItems() {
if (asyncItems.length === 0) {
// Show loading indicator after a short delay
const timeout = setTimeout(() => setIsLoading(true), 300)
try {
// Try to load items
const items = await alwaysFails(args.responseTime)
setAsyncItems(items)
} catch (error) {
setError(error as Error)
} finally {
clearTimeout(timeout)
setIsLoading(false)
}
}
}

return (
<Box sx={{p: 3}}>
<nav aria-label="File navigation">
<TreeView aria-label="File navigation">
<TreeView.Item
expanded={isExpanded}
onExpandedChange={isExpanded => {
setIsExpanded(isExpanded)

if (isExpanded) {
loadItems()
}
}}
>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Directory with async items
<TreeView.SubTree>
{isLoading ? <TreeView.LoadingItem /> : null}
{error ? (
<ConfirmationDialog
title="Error"
onClose={gesture => {
setError(null)

if (gesture === 'confirm') {
loadItems()
} else {
setIsExpanded(false)
}
}}
confirmButtonContent="Retry"
>
{error.message}
</ConfirmationDialog>
) : null}
{asyncItems.map(item => (
<TreeView.Item key={item}>
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
{item}
</TreeView.Item>
))}
</TreeView.SubTree>
</TreeView.Item>
</TreeView>
</nav>
</Box>
)
}

AsyncError.args = {
responseTime: 2000
}

export default meta
26 changes: 22 additions & 4 deletions src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ import {useSSRSafeId} from '@react-aria/ssr'
import React from 'react'
import styled from 'styled-components'
import Box from '../Box'
import StyledOcticon from '../StyledOcticon'
import {useControllableState} from '../hooks/useControllableState'
import Spinner from '../Spinner'
import StyledOcticon from '../StyledOcticon'
import sx, {SxProp} from '../sx'
import Text from '../Text'
import {Theme} from '../ThemeProvider'
Expand Down Expand Up @@ -282,7 +283,6 @@ const Item: React.FC<TreeViewItemProps> = ({
>
<Slots>
{slots => (
// QUESTION: How should leading and trailing visuals impact the aria-label?
<>
{slots.LeadingVisual}
<Text
Expand Down Expand Up @@ -362,6 +362,23 @@ const LinkItem: React.FC<TreeViewLinkItemProps> = ({href, onSelect, ...props}) =
)
}

// ----------------------------------------------------------------------------
// TreeView.LoadingItem

const LoadingItem: React.FC = () => {
return (
// TODO: What aria attributes do we need to add here?
<Item>
<LeadingVisual>
<Spinner size="small" />
</LeadingVisual>
<Text sx={{color: 'fg.muted'}}>Loading...</Text>
</Item>
)
}

LoadingItem.displayName = 'TreeView.LoadingItem'

// ----------------------------------------------------------------------------
// TreeView.SubTree

Expand Down Expand Up @@ -417,7 +434,7 @@ const LeadingVisual: React.FC<TreeViewVisualProps> = props => {
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
return (
<Slot name="LeadingVisual">
<Box sx={{color: 'fg.muted'}}>{children}</Box>
<Box sx={{display: 'flex', color: 'fg.muted'}}>{children}</Box>
</Slot>
)
}
Expand All @@ -427,7 +444,7 @@ const TrailingVisual: React.FC<TreeViewVisualProps> = props => {
const children = typeof props.children === 'function' ? props.children({isExpanded}) : props.children
return (
<Slot name="TrailingVisual">
<Box sx={{color: 'fg.muted'}}>{children}</Box>
<Box sx={{display: 'flex', color: 'fg.muted'}}>{children}</Box>
</Slot>
)
}
Expand All @@ -448,6 +465,7 @@ const DirectoryIcon = () => {
export const TreeView = Object.assign(Root, {
Item,
LinkItem,
LoadingItem,
SubTree,
LeadingVisual,
TrailingVisual,
Expand Down

0 comments on commit d459364

Please sign in to comment.