Item 1
@@ -73,6 +88,110 @@ describe('Markup', () => {
expect(root).toHaveAttribute('aria-activedescendant', firstItem?.id)
})
+
+ it('uses aria-current', () => {
+ const {getByRole} = renderWithTheme(
+
+ Item 1
+ Item 2
+ Item 3
+
+ )
+
+ const currentItem = getByRole('treeitem', {name: 'Item 2'})
+
+ expect(currentItem).toHaveAttribute('aria-current', 'true')
+ })
+
+ it('expands the path to the current item by default', () => {
+ const {getByRole} = renderWithTheme(
+
+
+ Item 1
+
+ Item 1.1
+
+
+
+ Item 2
+
+ Item 2.1
+
+ Item 2.2
+
+ Item 2.2.1
+
+
+
+
+ Item 3
+
+ )
+
+ const item1 = getByRole('treeitem', {name: 'Item 1'})
+ const item2 = getByRole('treeitem', {name: 'Item 2'})
+ const item22 = getByRole('treeitem', {name: 'Item 2.2'})
+ const item221 = getByRole('treeitem', {name: 'Item 2.2.1'})
+
+ // Item 1 should not be expanded because it is not the parent of the current item
+ expect(item1).toHaveAttribute('aria-expanded', 'false')
+
+ // Item 2 should be expanded because it is the parent of the current item
+ expect(item2).toHaveAttribute('aria-expanded', 'true')
+
+ // Item 2.2 should be expanded because it is the current item
+ expect(item22).toHaveAttribute('aria-expanded', 'true')
+
+ // Item 2.2 should have an aria-current value of true
+ expect(item22).toHaveAttribute('aria-current', 'true')
+
+ // Item 2.2.1 should be visible because it is a child of the current item
+ expect(item221).toBeVisible()
+ })
+
+ it('expands the path to the current item when the current item is changed', () => {
+ function TestTree() {
+ const [current, setCurrent] = React.useState('item1')
+ return (
+
+
+
+ Item 1
+
+ Item 2
+
+ Item 2.1
+
+
+ Item 3
+
+
+ )
+ }
+
+ const {getByRole, getByText} = renderWithTheme()
+
+ const item1 = getByRole('treeitem', {name: 'Item 1'})
+ const item2 = getByRole('treeitem', {name: 'Item 2'})
+
+ // Item 1 should have an aria-current value of true
+ expect(item1).toHaveAttribute('aria-current', 'true')
+
+ // Item 2 should not be expanded because it is not the current item or the parent of the current item
+ expect(item2).toHaveAttribute('aria-expanded', 'false')
+
+ // Click the button to change the current item to Item 2
+ fireEvent.click(getByText('Jump to Item 2'))
+
+ // Item 1 should not have an aria-current value
+ expect(item1).not.toHaveAttribute('aria-current')
+
+ // Item 2 should be expanded because it is the current item
+ expect(item2).toHaveAttribute('aria-expanded', 'true')
+
+ // Item 2.1 should be visible because it is a child of the current item
+ expect(getByRole('treeitem', {name: 'Item 2.1'})).toBeVisible()
+ })
})
describe('Keyboard interactions', () => {
diff --git a/src/TreeView/TreeView.tsx b/src/TreeView/TreeView.tsx
index c74e5e00234..4c1c7c97839 100644
--- a/src/TreeView/TreeView.tsx
+++ b/src/TreeView/TreeView.tsx
@@ -36,19 +36,29 @@ export type TreeViewProps = {
const UlBox = styled.ul(sx)
const Root: React.FC = ({'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, children}) => {
+ const rootRef = React.useRef(null)
const [activeDescendant, setActiveDescendant] = React.useState('')
React.useEffect(() => {
- // Initialize the active descendant to the first item in the tree
- if (!activeDescendant) {
- const firstItem = document.querySelector('[role="treeitem"]')
- if (firstItem) setActiveDescendant(firstItem.id)
+ if (rootRef.current && !activeDescendant) {
+ const currentItem = rootRef.current.querySelector('[role="treeitem"][aria-current="true"]')
+ const firstItem = rootRef.current.querySelector('[role="treeitem"]')
+
+ // If current item exists, use it as the initial value for active descendant
+ if (currentItem) {
+ setActiveDescendant(currentItem.id)
+ }
+ // Otherwise, initialize the active descendant to the first item in the tree
+ else if (firstItem) {
+ setActiveDescendant(firstItem.id)
+ }
}
- }, [activeDescendant])
+ }, [rootRef, activeDescendant])
return (
| KeyboardEvent) => void
onToggle?: (isExpanded: boolean) => void
}
-const Item: React.FC = ({defaultExpanded = false, onSelect, onToggle, children}) => {
+const Item: React.FC = ({
+ current: isCurrent = false,
+ defaultExpanded = false,
+ onSelect,
+ onToggle,
+ children
+}) => {
const {setActiveDescendant} = React.useContext(RootContext)
const itemId = useSSRSafeId()
const labelId = useSSRSafeId()
@@ -250,6 +267,13 @@ const Item: React.FC = ({defaultExpanded = false, onSelect, o
[isExpanded, onToggle]
)
+ // Expand item if it is the current item or contains the current item
+ React.useLayoutEffect(() => {
+ if (isCurrent || itemRef.current?.querySelector('[aria-current=true]')) {
+ setIsExpanded(true)
+ }
+ }, [itemRef, isCurrent, subTree])
+
React.useEffect(() => {
const element = itemRef.current
@@ -271,11 +295,11 @@ const Item: React.FC = ({defaultExpanded = false, onSelect, o
break
case 'ArrowRight':
- if (!isExpanded) setIsExpanded(true)
+ if (!isExpanded) toggle()
break
case 'ArrowLeft':
- if (isExpanded) setIsExpanded(false)
+ if (isExpanded) toggle()
break
}
}
@@ -293,6 +317,7 @@ const Item: React.FC = ({defaultExpanded = false, onSelect, o
aria-labelledby={labelId}
aria-level={level}
aria-expanded={hasSubTree ? isExpanded : undefined}
+ aria-current={isCurrent ? 'true' : undefined}
>
{
@@ -304,6 +329,7 @@ const Item: React.FC = ({defaultExpanded = false, onSelect, o
}
}}
sx={{
+ position: 'relative',
display: 'grid',
gridTemplateColumns: `calc(${level - 1} * 8px) 16px 1fr`,
gridTemplateAreas: `"spacer toggle content"`,
@@ -319,6 +345,19 @@ const Item: React.FC = ({defaultExpanded = false, onSelect, o
},
[`[role=tree][aria-activedescendant="${itemId}"]:focus-visible &`]: {
boxShadow: (theme: Theme) => `0 0 0 2px ${theme.colors.accent.emphasis}`
+ },
+ '[role=treeitem][aria-current=true] > &': {
+ bg: 'actionListItem.default.selectedBg',
+ '&::after': {
+ position: 'absolute',
+ top: 'calc(50% - 12px)',
+ left: -2,
+ width: '4px',
+ height: '24px',
+ content: '""',
+ bg: 'accent.fg',
+ borderRadius: 2
+ }
}
}}
>