Skip to content

Commit

Permalink
TreeView: Improve accessibility of async items (#2429)
Browse files Browse the repository at this point in the history
* Create AsyncSubTree component

* Handle loading item focus

* Add displayNames

* Add tests

* Document AsyncSubTree props

* Create light-coats-mate.md

* Use union type instead of enum

* Combine AsyncSubTree and SubTree

* Update tests

* Update state prop documentation

* Update state docs

* Move loading focus mangement to SubTree

* Update tests

* Create fresh-frogs-smoke.md
  • Loading branch information
colebemis committed Oct 14, 2022
1 parent a08997b commit e7802ed
Show file tree
Hide file tree
Showing 9 changed files with 292 additions and 75 deletions.
5 changes: 5 additions & 0 deletions .changeset/fresh-frogs-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

TreeView: Add `state` prop to `TreeView.SubTree` component
5 changes: 5 additions & 0 deletions .changeset/light-coats-mate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

TreeView: Improve accessibility of async items
31 changes: 31 additions & 0 deletions docs/content/TreeView.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,37 @@ 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={
<>
<p>Specify a state if items in the subtree are loaded asynchronously.</p>
<p>
An asynchronous subtree can be in one of the following states:
<ul>
<li>
<InlineCode>initial</InlineCode>: Items are neither loaded nor loading.
</li>
<li>
<InlineCode>loading</InlineCode>: Currently loading items. The subtree will render a loading indicator
when in this state (after a 300ms delay).
</li>
<li>
<InlineCode>done</InlineCode>: Done loading items. Screen readers will announce when a subtree enters this
state.
</li>
<li>
<InlineCode>error</InlineCode>: An error occured while loading items.
</li>
</ul>
</p>
</>
}
/>
{/* <PropsTableSxRow /> */}
</PropsTable>

Expand Down
35 changes: 24 additions & 11 deletions src/TreeView/TreeView.stories.tsx
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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('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'), 400)
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)
})
})
})
Loading

0 comments on commit e7802ed

Please sign in to comment.