Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TreeView: Improve accessibility of async items #2429

Merged
merged 18 commits into from Oct 14, 2022
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/light-coats-mate.md
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

TreeView: Improve accessibility of async items
13 changes: 13 additions & 0 deletions docs/content/TreeView.mdx
Expand Up @@ -278,6 +278,19 @@ See [Storybook](https://primer.style/react/storybook?path=/story/components-tree

<PropsTable>
<PropsTableRow name="children" type="React.ReactNode" />
<PropsTableRow
name="state"
type={`| 'initial'
| 'loading'
| 'done'
| 'error'`}
description={
<>
Specify the state if items in the subtree are loaded asynchronously. The subtree will render a loading indicator
if the state is <InlineCode>loading</InlineCode>.
</>
}
/>
{/* <PropsTableSxRow /> */}
</PropsTable>

Expand Down
35 changes: 24 additions & 11 deletions src/TreeView/TreeView.stories.tsx
Expand Up @@ -7,7 +7,7 @@ import Box from '../Box'
import {Button} from '../Button'
import {ConfirmationDialog} from '../Dialog/ConfirmationDialog'
import StyledOcticon from '../StyledOcticon'
import {TreeView} from './TreeView'
import {SubTreeState, TreeView} from './TreeView'

const meta: Meta = {
title: 'Components/TreeView',
Expand Down Expand Up @@ -409,20 +409,26 @@ export const AsyncSuccess: 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) {
// Show loading indicator after a short delay
const timeout = setTimeout(() => setIsLoading(true), 300)
setIsLoading(true)

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

clearTimeout(timeout)
setIsLoading(false)
setAsyncItems(items)
}
Expand All @@ -432,8 +438,7 @@ export const AsyncSuccess: Story = args => {
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Directory with async items
<TreeView.SubTree>
{isLoading ? <TreeView.LoadingItem /> : null}
<TreeView.SubTree state={state}>
{asyncItems.map(item => (
<TreeView.Item key={item}>
<TreeView.LeadingVisual>
Expand Down Expand Up @@ -466,18 +471,27 @@ export const AsyncError: Story = args => {
const [asyncItems, setAsyncItems] = React.useState<string[]>([])
const [error, setError] = React.useState<Error | null>(null)

let state: SubTreeState = 'initial'

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

async function loadItems() {
if (asyncItems.length === 0) {
// Show loading indicator after a short delay
const timeout = setTimeout(() => setIsLoading(true), 300)
setIsLoading(true)

try {
// Try to load items
const items = await alwaysFails(args.responseTime)
setAsyncItems(items)
} catch (error) {
setError(error as Error)
} finally {
clearTimeout(timeout)
setIsLoading(false)
}
}
Expand All @@ -501,8 +515,7 @@ export const AsyncError: Story = args => {
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Directory with async items
<TreeView.SubTree>
{isLoading ? <TreeView.LoadingItem /> : null}
<TreeView.SubTree state={state}>
{error ? (
<ConfirmationDialog
title="Error"
Expand Down
87 changes: 86 additions & 1 deletion src/TreeView/TreeView.test.tsx
@@ -1,7 +1,7 @@
import {fireEvent, render} from '@testing-library/react'
import React from 'react'
import {ThemeProvider} from '../ThemeProvider'
import {TreeView} from './TreeView'
import {SubTreeState, TreeView} from './TreeView'

// TODO: Move this function into a shared location
function renderWithTheme(
Expand Down Expand Up @@ -1025,3 +1025,88 @@ describe('Controlled state', () => {
expect(child).not.toBeVisible()
})
})

describe.only('Asyncronous loading', () => {
it('updates aria live region when loading is done', () => {
function TestTree() {
const [state, setState] = React.useState<SubTreeState>('loading')

return (
<div>
{/* Mimic the completion of async loading by clicking the button */}
<button onClick={() => setState('done')}>Done</button>
<TreeView aria-label="Test tree">
<TreeView.Item defaultExpanded>
Parent
<TreeView.SubTree state={state}>
<TreeView.Item>Child</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
</TreeView>
</div>
)
}
const {getByRole} = renderWithTheme(<TestTree />)

const doneButton = getByRole('button', {name: 'Done'})
const liveRegion = getByRole('status')

// Live region should be empty
expect(liveRegion).toHaveTextContent('')

// Click done button to mimic the completion of async loading
fireEvent.click(doneButton)

// Live region should be updated
expect(liveRegion).toHaveTextContent('Parent content loaded')
})

it('moves active descendant from loading item to first child', async () => {
function TestTree() {
const [state, setState] = React.useState<SubTreeState>('loading')

React.useEffect(() => {
const timer = setTimeout(() => setState('done'), 1000)
return () => clearTimeout(timer)
}, [])

return (
<TreeView aria-label="Test tree">
<TreeView.Item defaultExpanded>
Parent
<TreeView.SubTree state={state}>
<TreeView.Item>Child 1</TreeView.Item>
<TreeView.Item>Child 2</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
</TreeView>
)
}

const {getByRole, findByRole} = renderWithTheme(<TestTree />)

const root = getByRole('tree')
const parentItem = getByRole('treeitem', {name: 'Parent'})
const loadingItem = await findByRole('treeitem', {name: 'Loading...'})

// Focus tree
root.focus()

// aria-activedescendant should be set to parent item
expect(root).toHaveAttribute('aria-activedescendant', parentItem.id)

// Press ↓ to move aria-activedescendant to loading item
fireEvent.keyDown(document.activeElement || document.body, {key: 'ArrowDown'})

// aria-activedescendant should now be set to loading item
expect(root).toHaveAttribute('aria-activedescendant', loadingItem.id)

// Wait for async loading to complete
const firstChild = await findByRole('treeitem', {name: 'Child 1'})

setTimeout(() => {
// aria-activedescendant should now be set to first child
expect(root).toHaveAttribute('aria-activedescendant', firstChild.id)
})
})
})