diff --git a/.changeset/lovely-shirts-hope.md b/.changeset/lovely-shirts-hope.md new file mode 100644 index 00000000000..d628f02357e --- /dev/null +++ b/.changeset/lovely-shirts-hope.md @@ -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. diff --git a/packages/react/src/TreeView/TreeView.features.stories.tsx b/packages/react/src/TreeView/TreeView.features.stories.tsx index b4d9a8181b4..b608bc51fb2 100644 --- a/packages/react/src/TreeView/TreeView.features.stories.tsx +++ b/packages/react/src/TreeView/TreeView.features.stories.tsx @@ -580,7 +580,7 @@ AsyncError.args = { } export const EmptyDirectories: StoryFn = () => { - const [state, setState] = React.useState('loading') + const [state, setState] = React.useState('initial') const timeoutId = React.useRef | null>(null) React.useEffect(() => { @@ -597,6 +597,7 @@ export const EmptyDirectories: StoryFn = () => { { + setState('loading') if (expanded) { timeoutId.current = setTimeout(() => { setState('done') diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index 056b6714acb..78bfceae3ba 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -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', () => { @@ -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('loading') const timeoutId = React.useRef | null>(null) @@ -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( + + + + + + Parent + + child + + child current + + + empty child + + + + + , + ) + + 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') }) }) diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index b891aa97a74..71f41042722 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -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 | React.KeyboardEvent) => void className?: string @@ -401,7 +401,7 @@ const Item = React.forwardRef( // 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) @@ -482,7 +482,7 @@ const Item = React.forwardRef( 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} @@ -697,6 +697,7 @@ const SubTree: React.FC = ({count, state, children}) => { ref={ref} > {state === 'loading' ? : children} + {isSubTreeEmpty && state !== 'loading' ? : null} ) } @@ -785,6 +786,14 @@ const LoadingItem = React.forwardRef(({count}, re ) }) +const EmptyItem = React.forwardRef((props, ref) => { + return ( + + No items found + + ) +}) + function useSubTree(children: React.ReactNode) { return React.useMemo(() => { const subTree = React.Children.toArray(children).find(