Skip to content
This repository was archived by the owner on Oct 23, 2023. It is now read-only.

feat: add ref typings for react, preact and solid #327

Merged
merged 10 commits into from
Apr 1, 2023
Merged
7 changes: 7 additions & 0 deletions .changeset/dry-badgers-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@polymorphic-factory/react': minor
---

Removed the member `defaultProps` from the type `ComponentWithAs` to support React 18.3.0.

**This is possibly a breaking change for TypeScript users.**
12 changes: 12 additions & 0 deletions .changeset/eleven-cameras-bake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@polymorphic-factory/react': minor
---

When using the `as` prop, the `ref` will now be typed accordingly.

**This is possibly a breaking change for TypeScript users.**

```tsx
const ref = useRef<HTMLAnchorElement>(null)
return <poly.button as="a" ref={ref} />
```
12 changes: 12 additions & 0 deletions .changeset/gold-seahorses-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@polymorphic-factory/solid': minor
---

When using the `as` prop, the `ref` will now be typed accordingly.

**This is possibly a breaking change for TypeScript users.**

```tsx
let ref: HTMLAnchorElement = undefined
return <poly.button as="a" ref={ref} />
```
12 changes: 12 additions & 0 deletions .changeset/good-rabbits-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@polymorphic-factory/preact': minor
---

When using the `as` prop, the `ref` will now be typed accordingly.

**This is possibly a breaking change for TypeScript users.**

```tsx
const ref = useRef<HTMLAnchorElement>(null)
return <poly.button as="a" ref={ref} />
```
6 changes: 0 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,6 @@ const App = () => (
)
```

> **Known drawbacks for the type definitions:**
>
> Event handlers are not typed correctly when using the `as` prop.
>
> This is a deliberate decision to keep the usage as simple as possible.

This monorepo uses [pnpm](https://pnpm.io) as a package manager. It includes the following packages:

## Packages
Expand Down
20 changes: 13 additions & 7 deletions packages/preact/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,9 @@

Create polymorphic Preact components with a customizable `styled` function.

A polymorphic component is a component that can be rendered with a different element.

> **Known drawbacks for the type definitions:**
>
> Event handlers are not typed correctly when using the `as` prop.
>
> This is a deliberate decision to keep the usage as simple as possible.
A polymorphic component is a component that can be rendered with a different element. This is useful
for component libraries that want to provide a consistent API for their users and want to allow them
to customize the underlying element.

## Installation

Expand Down Expand Up @@ -106,6 +102,16 @@ It still supports the `as` prop, which would replace the `OriginalComponent`.
<MyComponent as="div" />
// renders <div />
```
## Refs

You can use `ref` on the component, and it will have the correct typings.

```tsx
const App = () => {
const ref = useRef<HTMLAnchorElement>(null)
return <poly.button as="a" ref={ref} />
}
```

## Types

Expand Down
1 change: 1 addition & 0 deletions packages/preact/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
"@types/testing-library__jest-dom": "5.14.5",
"@preact/preset-vite": "2.5.0",
"@vitest/coverage-c8": "0.29.8",
"csstype": "3.1.1",
"clean-package": "2.2.0",
"jsdom": "21.1.1",
"preact": "10.13.2",
Expand Down
75 changes: 45 additions & 30 deletions packages/preact/src/forwardRef.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,40 +10,62 @@ export type PropsOf<T extends ElementType> = ComponentProps<T> & {
as?: ElementType
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DistributiveOmit<T, K extends keyof any> = T extends any ? Omit<T, K> : never

/**
* Assign property types from right to left.
* Think `Object.assign` for types.
*
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
*/
export type Assign<Target, Source> = Omit<Target, keyof Source> & Source

export type OmitCommonProps<
Target,
OmitAdditionalProps extends string | number | symbol = never,
> = Omit<Target, 'transition' | 'as' | 'color' | OmitAdditionalProps>

type AssignCommon<
SourceProps extends Record<string, unknown> = Record<never, never>,
OverrideProps extends Record<string, unknown> = Record<never, never>,
> = Assign<OmitCommonProps<SourceProps>, OverrideProps>
export type Assign<A, B> = DistributiveOmit<A, keyof B> & B

type MergeWithAs<
ComponentProps extends Record<string, unknown>,
AsProps extends Record<string, unknown>,
AdditionalProps extends Record<string, unknown> = Record<never, never>,
AsComponent extends ElementType = ElementType,
> = AssignCommon<ComponentProps, AdditionalProps> &
AssignCommon<AsProps, AdditionalProps> & {
as?: AsComponent
}
Default extends ElementType,
Component extends ElementType,
PermanentProps extends Record<never, never>,
DefaultProps extends Record<never, never>,
ComponentProps extends Record<never, never>,
> =
/**
* The following code is copied from the library react-polymorphed by nasheomirro.
* Thank your for creating this TypeScript gold!
*
* doing this makes sure typescript infers events. Without the
* extends check typescript won't do an additional inference phase,
* but somehow we can trick typescript into doing so. Note that the check needs to be relating
* to the generic for this to work.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any extends Component
? /**
* Merge<ComponentProps, OwnProps & { as?: Component }> looks sufficient,
* but typescript won't be able to infer events on components that haven't
* explicitly provided a value for the As generic or haven't provided an `as` prop.
* We could do the same trick again like the above but discriminating unions should be
* enough as we don't have to compute the value for the default.
*
* Also note that Merging here is needed not just for the purpose of
* overriding props but also because somehow it is needed to get the props correctly,
* Merge does clone the first object so that might have something to do with it.
*/
| Assign<DefaultProps, PermanentProps & { as?: Default }>
| Assign<ComponentProps, PermanentProps & { as?: Component }>
: never

export type ComponentWithAs<
Component extends ElementType,
Props extends Record<string, unknown> = Record<never, never>,
Props extends Record<never, never> = Record<never, never>,
> = {
<AsComponent extends ElementType = Component>(
props: MergeWithAs<ComponentProps<Component>, ComponentProps<AsComponent>, Props, AsComponent>,
props: MergeWithAs<
Component,
AsComponent,
Props,
ComponentProps<Component>,
ComponentProps<AsComponent>
>,
): JSX.Element

displayName?: string
Expand All @@ -52,15 +74,8 @@ export type ComponentWithAs<
id?: string
}

export function forwardRef<
Component extends ElementType,
Props extends Record<string, unknown> = Record<never, never>,
>(
component: ForwardFn<
AssignCommon<PropsOf<Component>, Props> & {
as?: ElementType
}
>,
export function forwardRef<Component extends ElementType, Props extends object = object>(
component: ForwardFn<Props & { as?: ElementType }>,
) {
return forwardRefPreact(component) as unknown as ComponentWithAs<Component, Props>
}
10 changes: 4 additions & 6 deletions packages/preact/src/polymorphic-factory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import type { JSX } from 'preact'

type DOMElements = keyof JSX.IntrinsicElements

export type HTMLPolymorphicComponents<
Props extends Record<string, unknown> = Record<never, never>,
> = {
export type HTMLPolymorphicComponents<Props extends Record<never, never> = Record<never, never>> = {
[Tag in DOMElements]: ComponentWithAs<Tag, Props>
}

Expand All @@ -14,10 +12,10 @@ export type HTMLPolymorphicProps<T extends ElementType> = Omit<PropsOf<T>, 'ref'
}

type PolymorphFactory<
Props extends Record<string, unknown> = Record<never, never>,
Props extends Record<never, never> = Record<never, never>,
Options = never,
> = {
<T extends ElementType, P extends Record<string, unknown> = Props>(
<T extends ElementType, P extends Record<never, never> = Props>(
component: T,
option?: Options,
): ComponentWithAs<T, P>
Expand All @@ -33,7 +31,7 @@ function defaultStyled(originalComponent: ElementType) {

interface PolyFactoryParam<
Component extends ElementType,
Props extends Record<string, unknown>,
Props extends Record<never, never>,
Options,
> {
styled?: (component: Component, options?: Options) => ComponentWithAs<Component, Props>
Expand Down
4 changes: 1 addition & 3 deletions packages/preact/test/forward-ref.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ describe('forwardRef', () => {
<poly.div {...props} ref={ref} />
))

// known issue: with the `as` prop refs are not inherited correctly
// workaround:
const ref = createRef<HTMLDivElement & HTMLFormElement>()
const ref = createRef<HTMLFormElement>()
render(<ComponentUnderTest as="form" ref={ref} />)
expect(ref.current).toBeInstanceOf(HTMLFormElement)
})
Expand Down
20 changes: 19 additions & 1 deletion packages/preact/test/polymorphic-factory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { render, screen } from '@testing-library/preact'
import { createRef } from 'preact'
import { type HTMLPolymorphicProps, polymorphicFactory } from '../src'
import type { Properties } from 'csstype'

describe('Polymorphic Factory', () => {
describe('with default styled function', () => {
Expand All @@ -17,6 +19,17 @@ describe('Polymorphic Factory', () => {
expect(element.nodeName).toBe('MAIN')
})

it('should have the correct ref typings when using the as prop', () => {
const ref = createRef<HTMLAnchorElement>()

// @ts-expect-error - button ref is not an anchor ref
render(<poly.button ref={ref} />)

render(<poly.button data-testid="poly" as="a" ref={ref} />)
const element = screen.getByTestId('poly')
expect(element.nodeName).toBe('A')
})

it('should render an element with the factory', () => {
const Aside = poly('aside')
render(<Aside data-testid="poly" />)
Expand All @@ -26,7 +39,7 @@ describe('Polymorphic Factory', () => {
})

describe('with custom styled function', () => {
const customPoly = polymorphicFactory<Record<never, never>, { customOption: string }>({
const customPoly = polymorphicFactory<Record<never, never>, { customOption?: string }>({
styled: (component, options) => (props) => {
const Component = props.as || component
return <Component data-custom-styled data-options={JSON.stringify(options)} {...props} />
Expand Down Expand Up @@ -86,5 +99,10 @@ describe('Polymorphic Factory', () => {
// @ts-expect-error Property 'customProp' is missing
render(<CustomComponent />)
})

it('should handle many additional props', () => {
const poly = polymorphicFactory<Properties & { 'data-some-other': 'prop' }>()
render(<poly.div display="flex" data-some-other="prop" />)
})
})
})
21 changes: 14 additions & 7 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,9 @@

Create polymorphic React components with a customizable `styled` function.

A polymorphic component is a component that can be rendered with a different element.

> **Known drawbacks for the type definitions:**
>
> Event handlers are not typed correctly when using the `as` prop.
>
> This is a deliberate decision to keep the usage as simple as possible.
A polymorphic component is a component that can be rendered with a different element. This is useful
for component libraries that want to provide a consistent API for their users and want to allow them
to customize the underlying element.

## Installation

Expand Down Expand Up @@ -107,6 +103,17 @@ It still supports the `as` prop, which would replace the `OriginalComponent`.
// renders <div />
```

## Refs

You can use `ref` on the component, and it will have the correct typings.

```tsx
const App = () => {
const ref = React.useRef<HTMLAnchorElement>(null)
return <poly.button as="a" ref={ref} />
}
```

## Types

```ts
Expand Down
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@vitejs/plugin-react": "3.1.0",
"@vitest/coverage-c8": "0.29.8",
"clean-package": "2.2.0",
"csstype": "3.1.1",
"jsdom": "21.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
Expand Down
Loading