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/lovely-shirts-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

TreeView: Adds indication of no nodes in a tree item, allows for `aria-expanded even if the item is empty.
3 changes: 2 additions & 1 deletion packages/react/src/TreeView/TreeView.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ AsyncError.args = {
}

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

React.useEffect(() => {
Expand All @@ -597,6 +597,7 @@ export const EmptyDirectories: StoryFn = () => {
<TreeView.Item
id="src"
onExpandedChange={expanded => {
setState('loading')
if (expanded) {
timeoutId.current = setTimeout(() => {
setState('done')
Expand Down
46 changes: 43 additions & 3 deletions packages/react/src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ describe('Markup', () => {
expect(treeitem).not.toHaveAttribute('aria-expanded')

await user.click(getByText(/Item 2/))
expect(treeitem).not.toHaveAttribute('aria-expanded')
expect(treeitem).toHaveAttribute('aria-expanded', 'true')
})

it('should render with containIntrinsicSize', () => {
Expand Down Expand Up @@ -1537,7 +1537,7 @@ describe('Asyncronous loading', () => {
expect(parentItem).toHaveAttribute('aria-expanded', 'true')
})

it('should remove `aria-expanded` if no content is loaded in', async () => {
it('should update `aria-expanded` if no content is loaded in', async () => {
function Example() {
const [state, setState] = React.useState<SubTreeState>('loading')
const timeoutId = React.useRef<ReturnType<typeof setTimeout> | null>(null)
Expand Down Expand Up @@ -1584,6 +1584,46 @@ describe('Asyncronous loading', () => {
jest.runAllTimers()
})

expect(treeitem).not.toHaveAttribute('aria-expanded')
expect(treeitem).toHaveAttribute('aria-expanded', 'true')
expect(getByLabelText('No items found')).toBeInTheDocument()
})

it('should have `aria-expanded` when directory is empty', async () => {
const {getByRole} = renderWithTheme(
<TreeView aria-label="Files changed">
<TreeView.Item id="src" defaultExpanded>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Parent
<TreeView.SubTree>
<TreeView.Item id="src/Avatar.tsx">child</TreeView.Item>
<TreeView.Item id="src/Button.tsx" current>
child current
</TreeView.Item>
<TreeView.Item id="src/Box.tsx">
empty child
<TreeView.SubTree />
</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
</TreeView>,
)

const parentItem = getByRole('treeitem', {name: 'Parent'})

// Parent item should be expanded
expect(parentItem).toHaveAttribute('aria-expanded', 'true')

// Current child should not have `aria-expanded`
expect(getByRole('treeitem', {name: 'child current'})).not.toHaveAttribute('aria-expanded')

// Empty child should not have `aria-expanded` when closed
expect(getByRole('treeitem', {name: 'empty child'})).not.toHaveAttribute('aria-expanded')

fireEvent.click(getByRole('treeitem', {name: 'empty child'}))

// Empty child should have `aria-expanded` when opened
expect(getByRole('treeitem', {name: 'empty child'})).toHaveAttribute('aria-expanded')
})
})
15 changes: 12 additions & 3 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,7 @@ export type TreeViewItemProps = {
containIntrinsicSize?: string
current?: boolean
defaultExpanded?: boolean
expanded?: boolean
expanded?: boolean | null
onExpandedChange?: (expanded: boolean) => void
onSelect?: (event: React.MouseEvent<HTMLElement> | React.KeyboardEvent<HTMLElement>) => void
className?: string
Expand Down Expand Up @@ -401,7 +401,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
// If defaultExpanded is not provided, we default to false unless the item
// is the current item, in which case we default to true.
defaultValue: () => expandedStateCache.current?.get(itemId) ?? defaultExpanded ?? isCurrentItem,
value: expanded,
value: expanded === null ? false : expanded,
onChange: onExpandedChange,
})
const {level} = React.useContext(ItemContext)
Expand Down Expand Up @@ -482,7 +482,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
aria-labelledby={ariaLabel ? undefined : ariaLabelledby || labelId}
aria-describedby={`${leadingVisualId} ${trailingVisualId}`}
aria-level={level}
aria-expanded={isSubTreeEmpty ? undefined : isExpanded}
aria-expanded={(isSubTreeEmpty && (!isExpanded || !hasSubTree)) || expanded === null ? undefined : isExpanded}
aria-current={isCurrentItem ? 'true' : undefined}
aria-selected={isFocused ? 'true' : 'false'}
data-has-leading-action={slots.leadingAction ? true : undefined}
Expand Down Expand Up @@ -697,6 +697,7 @@ const SubTree: React.FC<TreeViewSubTreeProps> = ({count, state, children}) => {
ref={ref}
>
{state === 'loading' ? <LoadingItem ref={loadingItemRef} count={count} /> : children}
{isSubTreeEmpty && state !== 'loading' ? <EmptyItem /> : null}
</ul>
)
}
Expand Down Expand Up @@ -785,6 +786,14 @@ const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>(({count}, re
)
})

const EmptyItem = React.forwardRef<HTMLElement>((props, ref) => {
return (
<Item expanded={null} id={useId()} ref={ref}>
<Text sx={{color: 'fg.muted'}}>No items found</Text>
</Item>
)
})

function useSubTree(children: React.ReactNode) {
return React.useMemo(() => {
const subTree = React.Children.toArray(children).find(
Expand Down
Loading