Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙋 Is ConfirmationDialog the right kind of dialog for error messages? cc @ericwbailey

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think so as well.

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>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙋 What aria props should we add to the LoadingItem? cc @ericwbailey

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in the process of testing this out, but I believe we'll need a combination of aria-hidden (to hide decorative nodes), aria-busy (to communicate loading state), and aria-live (to announce state change).

<LeadingVisual>
<Spinner size="small" />
</LeadingVisual>
<Text sx={{color: 'fg.muted'}}>Loading...</Text>
</Item>
)
}

LoadingItem.displayName = 'TreeView.LoadingItem'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this. I'm going to start doing this instead of keeping the name of the const.


// ----------------------------------------------------------------------------
// 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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do we need flexbox for? Is it just a vertical alignment thing? Or are we actually laying out multiple children?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you render an SVG (Octicon) in the leading visual slot, it adds unwanted extra space unless the container is a flex container.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I figured it was something like that. I've dealt with that before, and I don't understand why it works 🙈

I think it also works for grid

</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