diff --git a/.changeset/polymorphic-prop-inference.md b/.changeset/polymorphic-prop-inference.md new file mode 100644 index 00000000000..9b3b269e8fa --- /dev/null +++ b/.changeset/polymorphic-prop-inference.md @@ -0,0 +1,5 @@ +--- +'@primer/react': patch +--- + +Polymorphic components: Fix TypeScript inference for component-specific props while preserving `as` support. diff --git a/packages/react/src/ActionList/ActionList.types.test.tsx b/packages/react/src/ActionList/ActionList.types.test.tsx new file mode 100644 index 00000000000..c572e297165 --- /dev/null +++ b/packages/react/src/ActionList/ActionList.types.test.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import {ActionList} from './index' + +export function shouldAcceptLinkItemAs() { + return ( + ()} + onClick={event => { + type Event = Expect>> + }} + > + Action + + ) +} + +const CustomLink = React.forwardRef( + ({to, children}, ref) => { + return ( + + {children} + + ) + }, +) + +export function shouldAcceptCustomLinkItemAs() { + return ()} /> +} + +export function shouldRequireCustomLinkItemProps() { + // @ts-expect-error the custom component requires to + return +} + +export function shouldAcceptListAs() { + return ()} /> +} + +type Expect = T +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false diff --git a/packages/react/src/ActionList/LinkItem.tsx b/packages/react/src/ActionList/LinkItem.tsx index 02cd952ea34..88c03422677 100644 --- a/packages/react/src/ActionList/LinkItem.tsx +++ b/packages/react/src/ActionList/LinkItem.tsx @@ -38,7 +38,7 @@ const LinkItemComponent = fixedForwardRef( forwardedRef: ForwardedRef, ) => { return ( - className={className} active={active} inactiveText={inactiveText} diff --git a/packages/react/src/ActionList/List.tsx b/packages/react/src/ActionList/List.tsx index 0abeb7e4722..11c00a37db7 100644 --- a/packages/react/src/ActionList/List.tsx +++ b/packages/react/src/ActionList/List.tsx @@ -109,7 +109,6 @@ const UnwrappedList = ( return ( {slots.heading} - {/* @ts-expect-error ref needs a non nullable ref */} } + +export function shouldAcceptButtonProps() { + return ( + ()} + onClick={event => { + type Event = Expect>> + }} + /> + ) +} + +const CustomLink = React.forwardRef( + ({to, children}, ref) => { + return ( + + {children} + + ) + }, +) + +export function shouldAcceptCustomComponentProps() { + return ()} /> +} + +export function shouldRequireCustomComponentProps() { + // @ts-expect-error the custom component requires to + return +} + +export function shouldRejectIncorrectRef() { + // @ts-expect-error a button requires a button ref + return ()} /> +} + +type Expect = T +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false diff --git a/packages/react/src/utils/modern-polymorphic.ts b/packages/react/src/utils/modern-polymorphic.ts index 62ca5ea264b..2044ffe5ba1 100644 --- a/packages/react/src/utils/modern-polymorphic.ts +++ b/packages/react/src/utils/modern-polymorphic.ts @@ -25,11 +25,13 @@ export const fixedForwardRef = forwardRef as FixedForwardRef /** * Simplified polymorphic props type that handles the common pattern of * `DistributiveOmit, '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 & Props, 'as'> & { - as?: TAs -} +> = DistributiveOmit, 'as'> & + DistributiveOmit & { + as?: TAs + } diff --git a/packages/react/src/utils/modern-polymorphic.types.test.tsx b/packages/react/src/utils/modern-polymorphic.types.test.tsx new file mode 100644 index 00000000000..fbaffefade3 --- /dev/null +++ b/packages/react/src/utils/modern-polymorphic.types.test.tsx @@ -0,0 +1,55 @@ +import React from 'react' +import type {PolymorphicProps} from './modern-polymorphic' + +export function shouldPreserveOwnPropTypes( + props: PolymorphicProps< + As, + 'div', + { + delayMs?: number + politeness?: 'assertive' | 'polite' + title: string + } + >, +) { + const {delayMs, politeness, title} = props + + type Delay = Expect> + type Politeness = Expect> + type Title = Expect> +} + +export function shouldUsePolymorphicAsInsteadOfOwnAs() { + const props: PolymorphicProps<'button', 'a', {as?: 'a'; muted?: boolean}> = { + as: 'button', + muted: true, + disabled: true, + ref: React.createRef(), + onClick: event => { + type Event = Expect>> + }, + } + 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> + } else { + type Count = Expect> + } +} + +type Expect = T +type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 ? true : false