Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/polymorphic-prop-inference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': patch
---

Polymorphic components: Fix TypeScript inference for component-specific props while preserving `as` support.
46 changes: 46 additions & 0 deletions packages/react/src/ActionList/ActionList.types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react'
import {ActionList} from './index'

export function shouldAcceptLinkItemAs() {
return (
<ActionList.LinkItem
as="button"
active
variant="danger"
size="small"
disabled
ref={React.createRef<HTMLButtonElement>()}
onClick={event => {
type Event = Expect<Equal<typeof event, React.MouseEvent<HTMLButtonElement>>>
}}
>
Action
</ActionList.LinkItem>
)
}

const CustomLink = React.forwardRef<HTMLAnchorElement, {to: string; children?: React.ReactNode}>(
({to, children}, ref) => {
return (
<a href={to} ref={ref}>
{children}
</a>
)
},
)

export function shouldAcceptCustomLinkItemAs() {
return <ActionList.LinkItem as={CustomLink} to="/example" ref={React.createRef<HTMLAnchorElement>()} />
}

export function shouldRequireCustomLinkItemProps() {
// @ts-expect-error the custom component requires to
return <ActionList.LinkItem as={CustomLink} />
}

export function shouldAcceptListAs() {
return <ActionList as="ol" start={2} ref={React.createRef<HTMLOListElement>()} />
}

type Expect<T extends true> = T
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
2 changes: 1 addition & 1 deletion packages/react/src/ActionList/LinkItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const LinkItemComponent = fixedForwardRef(
forwardedRef: ForwardedRef<unknown>,
) => {
return (
<Item
<Item<'li'>
className={className}
active={active}
inactiveText={inactiveText}
Expand Down
1 change: 0 additions & 1 deletion packages/react/src/ActionList/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ const UnwrappedList = <As extends React.ElementType = 'ul'>(
return (
<ListContext.Provider value={listContextValue}>
{slots.heading}
{/* @ts-expect-error ref needs a non nullable ref */}
<Component
className={clsx(classes.ActionList, className)}
role={listRole}
Expand Down
42 changes: 42 additions & 0 deletions packages/react/src/Link/__tests__/Link.types.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react'
import Link from '../Link'

export function shouldAcceptCallWithNoProps() {
Expand All @@ -8,3 +9,44 @@ export function shouldNotAcceptSystemProps() {
// @ts-expect-error system props should not be accepted
return <Link backgroundColor="mistyrose" />
}

export function shouldAcceptButtonProps() {
return (
<Link
as="button"
muted
disabled
ref={React.createRef<HTMLButtonElement>()}
onClick={event => {
type Event = Expect<Equal<typeof event, React.MouseEvent<HTMLButtonElement>>>
}}
/>
)
}

const CustomLink = React.forwardRef<HTMLAnchorElement, {to: string; children?: React.ReactNode}>(
({to, children}, ref) => {
return (
<a href={to} ref={ref}>
{children}
</a>
)
},
)

export function shouldAcceptCustomComponentProps() {
return <Link as={CustomLink} to="/example" inline ref={React.createRef<HTMLAnchorElement>()} />
}

export function shouldRequireCustomComponentProps() {
// @ts-expect-error the custom component requires to
return <Link as={CustomLink} />
}

export function shouldRejectIncorrectRef() {
// @ts-expect-error a button requires a button ref
return <Link as="button" ref={React.createRef<HTMLAnchorElement>()} />
}

type Expect<T extends true> = T
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
8 changes: 5 additions & 3 deletions packages/react/src/utils/modern-polymorphic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,13 @@ export const fixedForwardRef = forwardRef as FixedForwardRef
/**
* Simplified polymorphic props type that handles the common pattern of
* `DistributiveOmit<ComponentPropsWithRef<ElementType extends As ? DefaultElement : As>, 'as'>`
* Keep own props separate so their types remain available in generic components.
*/
export type PolymorphicProps<
TAs extends ElementType,
TDefaultElement extends ElementType = 'div',
Props = object,
> = DistributiveOmit<ComponentPropsWithRef<ElementType extends TAs ? TDefaultElement : TAs> & Props, 'as'> & {
as?: TAs
}
> = DistributiveOmit<ComponentPropsWithRef<ElementType extends TAs ? TDefaultElement : TAs>, 'as'> &
DistributiveOmit<Props, 'as'> & {
as?: TAs
}
55 changes: 55 additions & 0 deletions packages/react/src/utils/modern-polymorphic.types.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import React from 'react'
import type {PolymorphicProps} from './modern-polymorphic'

export function shouldPreserveOwnPropTypes<As extends React.ElementType>(
props: PolymorphicProps<
As,
'div',
{
delayMs?: number
politeness?: 'assertive' | 'polite'
title: string
}
>,
) {
const {delayMs, politeness, title} = props

type Delay = Expect<Equal<typeof delayMs, number | undefined>>
type Politeness = Expect<Equal<typeof politeness, 'assertive' | 'polite' | undefined>>
type Title = Expect<Equal<typeof title, string>>
}

export function shouldUsePolymorphicAsInsteadOfOwnAs() {
const props: PolymorphicProps<'button', 'a', {as?: 'a'; muted?: boolean}> = {
as: 'button',
muted: true,
disabled: true,
ref: React.createRef<HTMLButtonElement>(),
onClick: event => {
type Event = Expect<Equal<typeof event, React.MouseEvent<HTMLButtonElement>>>
},
}
return props
}

export function shouldPreserveIntersectingPropTypes() {
const props: PolymorphicProps<'input', 'div', {size?: 'small' | 'large'}> = {
as: 'input',
// @ts-expect-error own props do not override conflicting element props
size: 'small',
}
return props
}

export function shouldPreserveOwnPropUnions(
props: PolymorphicProps<'div', 'div', {kind: 'text'; text: string} | {kind: 'count'; count: number}>,
) {
if (props.kind === 'text') {
type Text = Expect<Equal<typeof props.text, string>>
} else {
type Count = Expect<Equal<typeof props.count, number>>
}
}

type Expect<T extends true> = T
type Equal<X, Y> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2 ? true : false
Loading