From dea9ea7908d3d9fe935c42311952fa5cbd7a17e4 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 28 May 2026 14:17:34 -0700 Subject: [PATCH 1/3] as --- .changeset/treeview-item-as-prop.md | 8 ++ .../TreeView/TreeView.features.stories.tsx | 43 ++++++++ packages/react/src/TreeView/TreeView.test.tsx | 98 +++++++++++++++++++ packages/react/src/TreeView/TreeView.tsx | 45 +++++---- 4 files changed, 177 insertions(+), 17 deletions(-) create mode 100644 .changeset/treeview-item-as-prop.md diff --git a/.changeset/treeview-item-as-prop.md b/.changeset/treeview-item-as-prop.md new file mode 100644 index 00000000000..463f618140a --- /dev/null +++ b/.changeset/treeview-item-as-prop.md @@ -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. diff --git a/packages/react/src/TreeView/TreeView.features.stories.tsx b/packages/react/src/TreeView/TreeView.features.stories.tsx index ff027a604a0..1c2ee3a61e5 100644 --- a/packages/react/src/TreeView/TreeView.features.stories.tsx +++ b/packages/react/src/TreeView/TreeView.features.stories.tsx @@ -1074,4 +1074,47 @@ export const MultilineItems: StoryFn = () => ( ) +const CustomRouterLink = React.forwardRef< + HTMLAnchorElement, + React.AnchorHTMLAttributes & {to: string} +>(({to, children, ...props}, ref) => ( + + {children} + +)) +CustomRouterLink.displayName = 'CustomRouterLink' + +export const AsProp: StoryFn = () => ( + +) + export default meta diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index d52cfb7f76c..395769263f1 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -369,6 +369,104 @@ describe('Markup', () => { }) expect(item1).toHaveFocus() }) + + describe('as prop', () => { + it('renders as an `li` by default', () => { + render( + + Item 1 + , + ) + + const item = screen.getByRole('treeitem', {name: /Item 1/}) + expect(item.tagName).toBe('LI') + }) + + it('renders as the element specified by `as`', () => { + render( + + + Item 1 + + , + ) + + 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 & {custom: boolean} + >(({children, custom, ...props}, ref) => ( + + {children} + + )) + CustomLink.displayName = 'CustomLink' + + render( + + + Docs + + , + ) + + 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( + + + Item 1 + + , + ) + + 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() + render( + + {/* @ts-expect-error ref typing for polymorphic */} + + Item 1 + + , + ) + + 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( + + + Item 1 + + , + ) + + const item = screen.getByRole('treeitem', {name: /Item 1/}) + fireEvent.click(item) + expect(onSelect).toHaveBeenCalledTimes(1) + }) + }) }) describe('Keyboard interactions', () => { diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index fc41913a4c1..75ae9967c38 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -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 PolymorphicProps} from '../utils/modern-polymorphic' // ---------------------------------------------------------------------------- // Context @@ -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 @@ -215,8 +216,10 @@ export type TreeViewItemProps = { secondaryActions?: TreeViewSecondaryActions[] } -const Item = React.forwardRef( - ( +export type TreeViewItemProps = PolymorphicProps + +const ItemImpl = fixedForwardRef( + ( { id: itemId, containIntrinsicSize, @@ -230,9 +233,17 @@ const Item = React.forwardRef( 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, secondaryActions, - }, - ref, + as: Component, + ...restProps + }: TreeViewItemProps, + ref: React.ForwardedRef, ) => { + // Note: when `as` swaps the element from `
  • `, the resulting markup + // (e.g. `` as a direct child of `
      `) 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, @@ -263,7 +274,6 @@ const Item = React.forwardRef( // 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) @@ -353,9 +363,9 @@ const Item = React.forwardRef( }} > {/* @ts-ignore Box doesn't have type support for `ref` used in combination with `as` */} -
    • } + ref={ref as React.ForwardedRef} tabIndex={0} id={itemId} role="treeitem" @@ -372,7 +382,7 @@ const Item = React.forwardRef( aria-selected={isFocused ? 'true' : 'false'} data-has-leading-action={slots.leadingAction ? true : undefined} onKeyDown={handleKeyDown} - onFocus={event => { + onFocus={(event: React.FocusEvent) => { // 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) @@ -384,7 +394,7 @@ const Item = React.forwardRef( event.stopPropagation() }} onBlur={() => setIsFocused(false)} - onClick={event => { + onClick={(event: React.MouseEvent) => { if (onSelect) { onSelect(event) } else { @@ -392,12 +402,13 @@ const Item = React.forwardRef( } event.stopPropagation() }} - onAuxClick={event => { + onAuxClick={(event: React.MouseEvent) => { if (onSelect && event.button === 1) { onSelect(event) } event.stopPropagation() }} + {...restProps} >
      ( ) : null}
      {subTree} -
    • + ) }, ) +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 ( @@ -474,8 +487,6 @@ const LevelIndicatorLines: React.FC<{level: number}> = ({level}) => { ) } -Item.displayName = 'TreeView.Item' - // ---------------------------------------------------------------------------- // TreeView.SubTree @@ -645,7 +656,7 @@ const LoadingItem = React.forwardRef(({count}, re if (count) { return ( - + }> {Array.from({length: count}).map((_, i) => { return })} @@ -655,7 +666,7 @@ const LoadingItem = React.forwardRef(({count}, re } return ( - + }> @@ -666,7 +677,7 @@ const LoadingItem = React.forwardRef(({count}, re const EmptyItem = React.forwardRef((props, ref) => { return ( - + }> No items found ) From 5b8af687fb429970f511de9ad2409ad45d8928d1 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 28 May 2026 14:34:08 -0700 Subject: [PATCH 2/3] fix ci --- packages/react/src/TreeView/TreeView.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/react/src/TreeView/TreeView.test.tsx b/packages/react/src/TreeView/TreeView.test.tsx index 395769263f1..13ccc5c0431 100644 --- a/packages/react/src/TreeView/TreeView.test.tsx +++ b/packages/react/src/TreeView/TreeView.test.tsx @@ -441,7 +441,6 @@ describe('Markup', () => { const ref = React.createRef() render( - {/* @ts-expect-error ref typing for polymorphic */} Item 1 From 81d647ba2819143586375a915d4d449d68f86613 Mon Sep 17 00:00:00 2001 From: Liu Liu Date: Thu, 28 May 2026 15:06:31 -0700 Subject: [PATCH 3/3] use DistributiveOmit --- packages/react/src/TreeView/TreeView.tsx | 17 +++++++++++++++-- packages/react/src/utils/modern-polymorphic.ts | 2 +- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/react/src/TreeView/TreeView.tsx b/packages/react/src/TreeView/TreeView.tsx index 75ae9967c38..a9841388204 100644 --- a/packages/react/src/TreeView/TreeView.tsx +++ b/packages/react/src/TreeView/TreeView.tsx @@ -30,7 +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 PolymorphicProps} from '../utils/modern-polymorphic' +import {fixedForwardRef, type DistributiveOmit} from '../utils/modern-polymorphic' // ---------------------------------------------------------------------------- // Context @@ -216,7 +216,20 @@ type TreeViewItemBaseProps = { secondaryActions?: TreeViewSecondaryActions[] } -export type TreeViewItemProps = PolymorphicProps +// 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`), preserving +// backward compatibility with consumers that pass `Ref` rather than the +// intrinsic element's ref type (e.g. `Ref` for the default `
    • `). +// - Our custom `onSelect` (typed against `HTMLElement`) replaces the native +// `ReactEventHandler` that would otherwise be intersected in +// and conflict with existing consumers. +// `DistributiveOmit` keeps the omit distributing over `as` union types. +export type TreeViewItemProps = DistributiveOmit< + React.ComponentPropsWithoutRef, + 'as' | 'onSelect' +> & + TreeViewItemBaseProps & {as?: As} const ItemImpl = fixedForwardRef( ( diff --git a/packages/react/src/utils/modern-polymorphic.ts b/packages/react/src/utils/modern-polymorphic.ts index d29e9e14f24..62ca5ea264b 100644 --- a/packages/react/src/utils/modern-polymorphic.ts +++ b/packages/react/src/utils/modern-polymorphic.ts @@ -6,7 +6,7 @@ import type {ComponentPropsWithRef, ElementType} from 'react' /** * Distributive Omit utility type that works correctly with union types */ -type DistributiveOmit = T extends unknown ? Omit : never +export type DistributiveOmit = T extends unknown ? Omit : never /** * Fixed version of forwardRef that provides better type inference for polymorphic components