diff --git a/.changeset/tidy-beans-punch.md b/.changeset/tidy-beans-punch.md new file mode 100644 index 00000000000..b446a178723 --- /dev/null +++ b/.changeset/tidy-beans-punch.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Add a `current` prop to `TreeView.Item` and `TreeView.LinkItem` diff --git a/docs/content/TreeView.mdx b/docs/content/TreeView.mdx index 81b407839f2..2612c20203d 100644 --- a/docs/content/TreeView.mdx +++ b/docs/content/TreeView.mdx @@ -19,7 +19,9 @@ description: A hierarchical list of items where nested items can be expanded and Button - Button.tsx + + Button.tsx + Button.test.tsx @@ -46,7 +48,7 @@ description: A hierarchical list of items where nested items can be expanded and src Avatar.tsx - + Button Button.tsx @@ -80,6 +82,12 @@ description: A hierarchical list of items where nested items can be expanded and + ( src Avatar.tsx - + Button Button.tsx @@ -45,37 +45,41 @@ export const FileTreeWithDirectoryLinks: Story = () => ( ) -export const FileTreeWithoutDirectoryLinks: Story = () => ( - - - -) +export const FileTreeWithoutDirectoryLinks: Story = () => { + return ( + + + + ) +} export default meta diff --git a/src/TreeView/TreeView.test.tsx b/src/TreeView/TreeView.test.tsx index 336aa20b331..877b2742ace 100644 --- a/src/TreeView/TreeView.test.tsx +++ b/src/TreeView/TreeView.test.tsx @@ -59,7 +59,22 @@ describe('Markup', () => { expect(subtree).toBeNull() }) - it('initializes aria-activedescendant to the first item by default', () => { + it('initializes aria-activedescendant to the current item by default', () => { + const {queryByRole} = renderWithTheme( + + Item 1 + Item 2 + Item 3 + + ) + + const root = queryByRole('tree') + const currentItem = queryByRole('treeitem', {name: 'Item 2'}) + + expect(root).toHaveAttribute('aria-activedescendant', currentItem?.id) + }) + + it('initializes aria-activedescendant to the first item if there is no current item', () => { const {queryByRole} = renderWithTheme( 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 + } } }} >