Skip to content
14 changes: 14 additions & 0 deletions .changeset/treeview-item-as-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@primer/react': minor
---

Add support for the `as` prop on `TreeView.Item`. This enables rendering the
treeitem as a different element (e.g. `as="a"` for native anchors, or a custom
router-link component) while preserving all existing keyboard, focus, and ARIA
behavior.

When `as` is omitted, the existing markup is unchanged: an `<li>` is rendered
as the `role="treeitem"` element. When `as` is provided, the polymorphic
element is rendered as the treeitem and is wrapped in an `<li role="none">`
so the markup remains valid (a `<ul role="tree">` may only directly contain
`<li>` elements).
8 changes: 7 additions & 1 deletion packages/react/src/TreeView/TreeView.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@
{
"name": "ref",
"type": "React.Ref<HTMLElement>"
},
{
"name": "as",
"type": "React.ElementType",
"defaultValue": "'li'",
"description": "The element type to render the tree item as. When set to a different element type or component, the polymorphic element is rendered with `role=\"treeitem\"` and is wrapped in an `li` element with `role=\"none\"` so the markup remains valid inside a `ul` with `role=\"tree\"`."
}
]
},
Expand Down Expand Up @@ -226,4 +232,4 @@
]
}
]
}
}
43 changes: 43 additions & 0 deletions packages/react/src/TreeView/TreeView.features.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1101,4 +1101,47 @@ export const MultilineItems: StoryFn = () => (
</nav>
)

const CustomRouterLink = React.forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & {to: string}
>(({to, children, ...props}, ref) => (
<a ref={ref} href={to} data-custom-link {...props}>
{children}
</a>
))
CustomRouterLink.displayName = 'CustomRouterLink'

export const AsProp: StoryFn = () => (
<nav aria-label="Docs">
<TreeView aria-label="Docs">
<TreeView.Item id="overview" as="a" href="#overview">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
Overview (native anchor)
</TreeView.Item>
<TreeView.Item id="guides" defaultExpanded>
<TreeView.LeadingVisual>
<TreeView.DirectoryIcon />
</TreeView.LeadingVisual>
Guides
<TreeView.SubTree>
<TreeView.Item id="guides/install" as={CustomRouterLink} to="/guides/install">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
Install (router link)
</TreeView.Item>
<TreeView.Item id="guides/setup" as={CustomRouterLink} to="/guides/setup">
<TreeView.LeadingVisual>
<FileIcon />
</TreeView.LeadingVisual>
Setup (router link)
</TreeView.Item>
</TreeView.SubTree>
</TreeView.Item>
</TreeView>
</nav>
)

export default meta
128 changes: 128 additions & 0 deletions packages/react/src/TreeView/TreeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,134 @@ describe('Markup', () => {
})
expect(item1).toHaveFocus()
})

describe('as prop', () => {
it('renders as an `li` by default', () => {
render(
<TreeView aria-label="Test tree">
<TreeView.Item id="item-1">Item 1</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Item 1/})
expect(item.tagName).toBe('LI')
})

it('renders as the element specified by `as`', () => {
render(
<TreeView aria-label="Test tree">
<TreeView.Item as="a" href="#item-1" id="item-1">
Item 1
</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Item 1/})
expect(item.tagName).toBe('A')
expect(item).toHaveAttribute('href', '#item-1')
})

it('wraps the polymorphic element in an `li role="none"` to keep markup valid', () => {
render(
<TreeView aria-label="Test tree">
<TreeView.Item as="a" href="#item-1" id="item-1">
Item 1
</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Item 1/})
const wrapper = item.parentElement
expect(wrapper?.tagName).toBe('LI')
expect(wrapper).toHaveAttribute('role', 'none')
})

it('does not add an extra `li role="none"` wrapper when `as="li"`', () => {
render(
<TreeView aria-label="Test tree">
<TreeView.Item as="li" id="item-1">
Item 1
</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Item 1/})
expect(item.tagName).toBe('LI')
// The treeitem should be a direct child of the tree (`<ul>`), not wrapped
// in an outer `<li role="none">`.
expect(item.parentElement).toHaveAttribute('role', 'tree')
})

it('supports polymorphic Item with custom component via `as`', () => {
const CustomLink = React.forwardRef<
HTMLAnchorElement,
React.AnchorHTMLAttributes<HTMLAnchorElement> & {custom: boolean}
>(({children, custom, ...props}, ref) => (
<a ref={ref} data-custom-link={custom} {...props}>
{children}
</a>
))
CustomLink.displayName = 'CustomLink'

render(
<TreeView aria-label="Test tree">
<TreeView.Item as={CustomLink} href="#docs" custom={true} id="item-docs">
Docs
</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Docs/})
expect(item.tagName).toBe('A')
expect(item).toHaveAttribute('href', '#docs')
expect(item).toHaveAttribute('data-custom-link', 'true')
})

it('preserves treeitem role, tabIndex, and aria attributes when `as` is provided', () => {
render(
<TreeView aria-label="Test tree">
<TreeView.Item as="a" href="#item-1" id="item-1" current>
Item 1
</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Item 1/})
expect(item).toHaveAttribute('role', 'treeitem')
expect(item).toHaveAttribute('tabindex', '0')
expect(item).toHaveAttribute('aria-current', 'true')
expect(item).toHaveAttribute('aria-level', '1')
})

it('forwards ref to the element specified by `as`', () => {
const ref = React.createRef<HTMLAnchorElement>()
render(
<TreeView aria-label="Test tree">
<TreeView.Item as="a" href="#item-1" id="item-1" ref={ref}>
Item 1
</TreeView.Item>
</TreeView>,
)

expect(ref.current).not.toBeNull()
expect(ref.current?.tagName).toBe('A')
})

it('calls onSelect when the polymorphic element is clicked', () => {
const onSelect = vi.fn()
render(
<TreeView aria-label="Test tree">
<TreeView.Item as="a" href="#item-1" id="item-1" onSelect={onSelect}>
Item 1
</TreeView.Item>
</TreeView>,
)

const item = screen.getByRole('treeitem', {name: /Item 1/})
fireEvent.click(item)
expect(onSelect).toHaveBeenCalledTimes(1)
})
})
})

describe('Keyboard interactions', () => {
Expand Down
Loading
Loading