Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .changeset/treeview-item-as-prop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@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. Defaults to `'li'`, so existing usage is unchanged.
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 @@ -1074,4 +1074,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
97 changes: 97 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,103 @@ 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('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
58 changes: 41 additions & 17 deletions packages/react/src/TreeView/TreeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {Tooltip} from '../TooltipV2'
import {isSlot} from '../utils/is-slot'
import type {FCWithSlotMarker} from '../utils/types'
import {AriaStatus} from '../live-region'
import {fixedForwardRef, type DistributiveOmit} from '../utils/modern-polymorphic'

// ----------------------------------------------------------------------------
// Context
Expand Down Expand Up @@ -200,7 +201,7 @@ Root.displayName = 'TreeView'
// ----------------------------------------------------------------------------
// TreeView.Item

export type TreeViewItemProps = {
type TreeViewItemBaseProps = {
'aria-label'?: React.AriaAttributes['aria-label']
'aria-labelledby'?: React.AriaAttributes['aria-labelledby']
id: string
Expand All @@ -215,8 +216,23 @@ export type TreeViewItemProps = {
secondaryActions?: TreeViewSecondaryActions[]
}

const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
(
// We build this type manually (instead of using `PolymorphicProps`) so we can omit
// `ref` and `onSelect` from the underlying element's props:
// - `ref` is provided solely by `fixedForwardRef` (typed as `Ref<unknown>`), preserving
// backward compatibility with consumers that pass `Ref<HTMLElement>` rather than the
// intrinsic element's ref type (e.g. `Ref<HTMLLIElement>` for the default `<li>`).
// - Our custom `onSelect` (typed against `HTMLElement`) replaces the native
// `ReactEventHandler<HTMLLIElement>` that would otherwise be intersected in
// and conflict with existing consumers.
// `DistributiveOmit` keeps the omit distributing over `as` union types.
export type TreeViewItemProps<As extends React.ElementType = 'li'> = DistributiveOmit<
React.ComponentPropsWithoutRef<React.ElementType extends As ? 'li' : As>,
'as' | 'onSelect'
> &
TreeViewItemBaseProps & {as?: As}

const ItemImpl = fixedForwardRef(
<As extends React.ElementType = 'li'>(
{
id: itemId,
containIntrinsicSize,
Expand All @@ -230,9 +246,17 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
secondaryActions,
},
ref,
as: Component,
...restProps
}: TreeViewItemProps<As>,
ref: React.ForwardedRef<unknown>,
) => {
// Note: when `as` swaps the element from `<li>`, the resulting markup
// (e.g. `<a>` as a direct child of `<ul role="tree">`) is technically
// not valid HTML. This mirrors the trade-off made by other polymorphic
// Primer components (e.g. `Breadcrumbs.Item`, `ActionList.LinkItem`)
// to support router-link integrations.
const ItemElement = (Component ?? 'li') as React.ElementType
const [slots, rest] = useSlots(children, {
leadingAction: LeadingAction,
leadingVisual: LeadingVisual,
Expand Down Expand Up @@ -263,7 +287,6 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(

// Set the expanded state and cache it
const setIsExpandedWithCache = React.useCallback(
// eslint-disable-next-line react-hooks/preserve-manual-memoization
(newIsExpanded: boolean) => {
setIsExpanded(newIsExpanded)
expandedStateCache.current?.set(itemId, newIsExpanded)
Expand Down Expand Up @@ -353,9 +376,9 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
}}
>
{/* @ts-ignore Box doesn't have type support for `ref` used in combination with `as` */}
<li
<ItemElement
className={clsx('PRIVATE_TreeView-item', className, classes.TreeViewItem)}
ref={ref as React.ForwardedRef<HTMLLIElement>}
ref={ref as React.ForwardedRef<HTMLElement>}
tabIndex={0}
id={itemId}
role="treeitem"
Expand All @@ -372,7 +395,7 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
aria-selected={isFocused ? 'true' : 'false'}
data-has-leading-action={slots.leadingAction ? true : undefined}
onKeyDown={handleKeyDown}
onFocus={event => {
onFocus={(event: React.FocusEvent<HTMLElement>) => {
// Defer scroll to the next animation frame so that rapid keyboard
// navigation (held key) coalesces into a single reflow per frame
scrollElementIntoView(event.currentTarget.firstElementChild)
Expand All @@ -384,20 +407,21 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
event.stopPropagation()
}}
onBlur={() => setIsFocused(false)}
onClick={event => {
onClick={(event: React.MouseEvent<HTMLElement>) => {
if (onSelect) {
onSelect(event)
} else {
toggle(event)
}
event.stopPropagation()
}}
onAuxClick={event => {
onAuxClick={(event: React.MouseEvent<HTMLElement>) => {
if (onSelect && event.button === 1) {
onSelect(event)
}
event.stopPropagation()
}}
{...restProps}
>
<div
className={clsx('PRIVATE_TreeView-item-container', classes.TreeViewItemContainer)}
Expand Down Expand Up @@ -457,12 +481,14 @@ const Item = React.forwardRef<HTMLElement, TreeViewItemProps>(
) : null}
</div>
{subTree}
</li>
</ItemElement>
</ItemContext.Provider>
)
},
)

const Item = Object.assign(ItemImpl, {displayName: 'TreeView.Item'})

/** Lines to indicate the depth of an item in a TreeView */
const LevelIndicatorLines: React.FC<{level: number}> = ({level}) => {
return (
Expand All @@ -474,8 +500,6 @@ const LevelIndicatorLines: React.FC<{level: number}> = ({level}) => {
)
}

Item.displayName = 'TreeView.Item'

// ----------------------------------------------------------------------------
// TreeView.SubTree

Expand Down Expand Up @@ -645,7 +669,7 @@ const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>(({count}, re

if (count) {
return (
<Item id={itemId} ref={ref}>
<Item id={itemId} ref={ref as React.Ref<HTMLLIElement>}>
{Array.from({length: count}).map((_, i) => {
return <SkeletonItem aria-hidden={true} key={i} />
})}
Expand All @@ -655,7 +679,7 @@ const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>(({count}, re
}

return (
<Item id={itemId} ref={ref}>
<Item id={itemId} ref={ref as React.Ref<HTMLLIElement>}>
<LeadingVisual>
<Spinner size="small" />
</LeadingVisual>
Expand All @@ -666,7 +690,7 @@ const LoadingItem = React.forwardRef<HTMLElement, LoadingItemProps>(({count}, re

const EmptyItem = React.forwardRef<HTMLElement>((props, ref) => {
return (
<Item expanded={null} id={useId()} ref={ref}>
<Item expanded={null} id={useId()} ref={ref as React.Ref<HTMLLIElement>}>
<Text className="fgColor-muted">No items found</Text>
</Item>
)
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/utils/modern-polymorphic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {ComponentPropsWithRef, ElementType} from 'react'
/**
* Distributive Omit utility type that works correctly with union types
*/
type DistributiveOmit<T, TOmitted extends PropertyKey> = T extends unknown ? Omit<T, TOmitted> : never
export type DistributiveOmit<T, TOmitted extends PropertyKey> = T extends unknown ? Omit<T, TOmitted> : never

/**
* Fixed version of forwardRef that provides better type inference for polymorphic components
Expand Down
Loading