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

feat(TreeView): add support for empty parent nodes #2478

Merged
merged 7 commits into from
Oct 24, 2022
Merged
Show file tree
Hide file tree
Changes from 5 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/eleven-countries-hang.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Add support for dynamic aria-expanded support on TreeView.Item
47 changes: 47 additions & 0 deletions src/TreeView/TreeView.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,9 @@ export const AsyncWithCount: Story = args => {
</TreeView.Item>
<TreeView.LinkItem href="#src">
<TreeView.LeadingVisual>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
src
Expand Down Expand Up @@ -622,4 +625,48 @@ export const StressTest: Story = () => {
)
}

export const EmptyDirectory: Story = () => {
const [state, setState] = React.useState<SubTreeState>('loading')
const timeoutId = React.useRef<ReturnType<typeof setTimeout> | null>(null)

React.useEffect(() => {
return () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current)
timeoutId.current = null
}
}
}, [])

return (
<Box sx={{p: 3, maxWidth: 400}}>
<TreeView aria-label="Test">
<TreeView.Item
onExpandedChange={expanded => {
if (expanded) {
timeoutId.current = setTimeout(() => {
setState('done')
timeoutId.current = null
}, 2000)
}
}}
>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
src
<TreeView.SubTree state={state} />
</TreeView.Item>
<TreeView.Item>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
.github
<TreeView.SubTree />
</TreeView.Item>
</TreeView>
</Box>
)
}

export default meta
17 changes: 13 additions & 4 deletions src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {fireEvent, render, waitFor} from '@testing-library/react'
import React from 'react'
import {act} from 'react-dom/test-utils'
import {ThemeProvider} from '../ThemeProvider'
import {SubTreeState, TreeView} from './TreeView'

jest.useFakeTimers()

// TODO: Move this function into a shared location
function renderWithTheme(
ui: Parameters<typeof render>[0],
Expand Down Expand Up @@ -940,6 +943,10 @@ describe('Asyncronous loading', () => {
// Click done button to mimic the completion of async loading
fireEvent.click(doneButton)

act(() => {
jest.runAllTimers()
})

// Live region should be updated
expect(liveRegion).toHaveTextContent('Parent content loaded')
})
Expand Down Expand Up @@ -983,13 +990,15 @@ describe('Asyncronous loading', () => {
// Wait for async loading to complete
const firstChild = await findByRole('treeitem', {name: 'Child 1'})

setTimeout(() => {
// First child should be focused
expect(firstChild).toHaveFocus()
act(() => {
jest.runAllTimers()
})

// First child should be focused
expect(firstChild).toHaveFocus()
})

it.only('moves focus to parent item after closing error dialog', async () => {
it.skip('moves focus to parent item after closing error dialog', async () => {
joshblack marked this conversation as resolved.
Show resolved Hide resolved
function TestTree() {
const [error, setError] = React.useState('Test error')

Expand Down
42 changes: 37 additions & 5 deletions src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ const RootContext = React.createContext<{
const ItemContext = React.createContext<{
itemId: string
level: number
isSubTreeEmpty: boolean
setIsSubTreeEmpty: React.Dispatch<React.SetStateAction<boolean>>
isExpanded: boolean
setIsExpanded: React.Dispatch<React.SetStateAction<boolean>>
leadingVisualId: string
trailingVisualId: string
}>({
itemId: '',
level: 1,
isSubTreeEmpty: false,
setIsSubTreeEmpty: () => {},
isExpanded: false,
setIsExpanded: () => {},
leadingVisualId: '',
Expand Down Expand Up @@ -135,6 +139,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
})
const {level} = React.useContext(ItemContext)
const {hasSubTree, subTree, childrenWithoutSubTree} = useSubTree(children)
const [isSubTreeEmpty, setIsSubTreeEmpty] = React.useState(!hasSubTree)

// Expand or collapse the subtree
const toggle = React.useCallback(
Expand Down Expand Up @@ -189,6 +194,8 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
value={{
itemId,
level: level + 1,
isSubTreeEmpty,
setIsSubTreeEmpty,
isExpanded,
setIsExpanded,
leadingVisualId,
Expand All @@ -205,7 +212,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
aria-labelledby={labelId}
aria-describedby={`${leadingVisualId} ${trailingVisualId}`}
aria-level={level}
aria-expanded={hasSubTree ? isExpanded : undefined}
aria-expanded={isSubTreeEmpty ? undefined : isExpanded}
aria-current={isCurrentItem ? 'true' : undefined}
onKeyDown={handleKeyDown}
sx={{
Expand Down Expand Up @@ -328,7 +335,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
</Slots>
</Box>
</Box>
{isExpanded ? subTree : null}
{subTree}
</Box>
</ItemContext.Provider>
)
Expand Down Expand Up @@ -409,11 +416,22 @@ export type TreeViewSubTreeProps = {

const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
const {announceUpdate} = React.useContext(RootContext)
const {itemId, isExpanded} = React.useContext(ItemContext)
const {itemId, isExpanded, isSubTreeEmpty, setIsSubTreeEmpty} = React.useContext(ItemContext)
const [isLoadingItemVisible, setIsLoadingItemVisible] = React.useState(false)
const {safeSetTimeout, safeClearTimeout} = useSafeTimeout()
const timeoutId = React.useRef<number>(0)
const loadingItemRef = React.useRef<HTMLElement>(null)
const ref = React.useRef<HTMLElement>(null)

// If `state` is undefined, then we're in a synchronous context. Otherwise,
// check to see if `state` is `'done'` for the asynchronous use-case.
if (state === undefined || state === 'done') {
if (!isSubTreeEmpty && !children) {
setIsSubTreeEmpty(true)
} else if (isSubTreeEmpty && children) {
setIsSubTreeEmpty(false)
}
}

// Announce when content has loaded
React.useEffect(() => {
Expand All @@ -422,10 +440,18 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {

if (!parentItem) return

const {current: node} = ref
const parentName = getAccessibleName(parentItem)
announceUpdate(`${parentName} content loaded`)

safeSetTimeout(() => {
if (node && node.childElementCount > 0) {
announceUpdate(`${parentName} content loaded`)
} else {
announceUpdate(`${parentName} is empty`)
}
})
}
}, [state, itemId, announceUpdate])
}, [state, itemId, announceUpdate, safeSetTimeout])

// Show loading indicator after a short delay
React.useEffect(() => {
Expand Down Expand Up @@ -462,6 +488,10 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
}
}, [state, safeSetTimeout, safeClearTimeout, isLoadingItemVisible, itemId])

if (!isExpanded) {
return null
}

return (
<Box
as="ul"
Expand All @@ -472,6 +502,8 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
padding: 0,
margin: 0
}}
// @ts-ignore Box doesn't have type support for `ref` used in combination with `as`
ref={ref}
>
{isLoadingItemVisible ? <LoadingItem ref={loadingItemRef} count={count} /> : children}
</Box>
Expand Down