Skip to content

Commit

Permalink
**feat:** mprove the ElementAttributes and ReactElementAttributes
Browse files Browse the repository at this point in the history
… JSX type helpers.

This greatly improves JSX types for Custom Elements in React JSX,
whereas before they only accepted string types for dash-cased
attributes.

If you have a `get`ter/`set`ter property in your element class, you can
now define a dummy property prefixed with `__set__<name-of-the-setter>`
to specify the type of the `set`ter, and this will be picked up and lead
to improved types in JSX. For example, you can start using like so:

Before:

```js
@element('some-element')
class SomeElement extends Element {
    @Attribute get someProp(): number {...}
    @Attribute set someProp(n: number | 'foo' | 'bar') {...}
}

declare module 'react' {
    namespace JSX {
        interface IntrinsicElements {
            'some-element': ReactElementAttributes<SomeElement, 'someProp'>
        }
    }
}
```

and this JSX would have a type error:

```jsx
return <some-element some-prop={'foo'} /> // Error: string is not assignable to number
```

After:

```js
@element('some-element')
class SomeElement extends Element {
    @Attribute get someProp(): number {...}
    @Attribute set someProp(n: this['__set__someProp']) {...}

    /** don't use this property, it is for JSX types. */
    __set__someProp!: number | 'foo' | 'bar'
}

// ... the same React JSX definition as before ...
```

and now JSX prop types will allow setting the *setter* types:

```jsx
return <some-element someProp={'foo'} /> // No error, yay!
```

Note, the property is camelCase instead of dash-case now.

**BREAKING:** This may introduce type errors into existing JSX templates,
tested with React 19 (not tested with React 18 or below), but it is an
inevitable upgrade for the better.
To migrate, there's likely nothing to do in Solid JSX, but in React JSX
the selected properties are no longer converted to dash-case, so you'll
want to use the original JS property names in React JSX templates. For example this,

```jsx
return <some-element some-prop={...} />
```

becomes

```jsx
return <some-element someProp={...} />
```

If you have any issues, please reach out on GitHub or Discord! https://discord.gg/VmvkFcWrsx
  • Loading branch information
trusktr committed Oct 8, 2024
1 parent 1811a92 commit d4cb151
Show file tree
Hide file tree
Showing 8 changed files with 156 additions and 41 deletions.
70 changes: 60 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ npm install --save-dev @babel/cli @babel/core @babel/plugin-proposal-decorators

If using TypeScript, set `allowJs` in `tsconfig.json` to allow compiling JS files, f.e.:

```json
```js
{
"compilerOptions": {
"allowJs": true,
Expand All @@ -247,7 +247,8 @@ If using TypeScript, set `allowJs` in `tsconfig.json` to allow compiling JS file
}
```

and running `npx tsc`.
and running `npx tsc`. See the [TypeScript](#typescript) section below for configuring JSX
types for various frameworks (Solid, React, Preact, etc).

If using Babel, add the decorators plugin to `.babelrc`, f.e.

Expand Down Expand Up @@ -581,9 +582,13 @@ Load the required JSX types in one of two ways:
project, but if you have files with different types of JSX, you'll want to
use option 1 instead).

```json
```js
{
"compilerOptions": {
/* Solid.js Config */
// Note, you need to use an additional tool such as Babel, Vite, etc, to
// compile Solid JSX. `npm create solid` will scaffold things for you.
"jsx": "preserve",
"jsxImportSource": "solid-js"
}
}
Expand Down Expand Up @@ -687,8 +692,10 @@ const el2 = (<menu>...</menu>) as any as HTMLDivElement
#### Type definitions for custom elements
### With Solid JSX
To give your Custom Elements type checking for use with DOM APIs, and type
checking in JSX, use the following template.
checking in Solid JSX, we can add the element type definition to `JSX.IntrinsicElements`:
```tsx
/* @jsxImportSource solid-js */
Expand All @@ -697,11 +704,9 @@ checking in JSX, use the following template.
// anywhere in non-JSX parts of the code, you also need to import it from
// solid-js:
import {Element, element, stringAttribute, numberAttribute, /*...,*/ JSX} from 'solid-js'
// ^ We imported JSX so that...

// Define the attributes that your element accepts
export interface CoolElementAttributes extends JSX.HTMLAttributes<CoolElement> {
// ^ ...we can use it in this non-JSX code.
'cool-type'?: 'beans' | 'hair'
'cool-factor'?: number
// ^ NOTE: These should be dash-case versions of your class's attribute properties.
Expand Down Expand Up @@ -777,14 +782,27 @@ return (
Defining the types of custom elements for React JSX is similar as for Solid JSX above, but with some small differences for React JSX:
```js
// tsconfig.json
{
"compilerOptions": {
/* React Config */
"jsx": "react-jsx",
"jsxImportSource": "react" // React >=19 (Omit for React <=18)
}
}
```
```ts
import type {HTMLAttributes} from 'react'

// Define the attributes that your element accepts, almost the same as before:
export interface CoolElementAttributes extends HTMLAttributes<CoolElement> {
'cool-type'?: 'beans' | 'hair'
'cool-factor'?: number
// ^ NOTE: These should be dash-case versions of your class's attribute properties.
coolType?: 'beans' | 'hair'
coolFactor?: number
// ^ NOTE: These are the names of the class's properties verbatim, not
// dash-cased as with Solid. React works differently than Solid's: it will
// map the exact prop name to the JS property.
}

// Add your element to the list of known HTML elements, like before.
Expand Down Expand Up @@ -812,7 +830,7 @@ declare global {
> attribute types:
```ts
import type {ReactElementAttributes} from '@lume/element/src/react'
import type {ReactElementAttributes} from '@lume/element/dist/react'

// This definition is now shorter than before, and automatically maps the property names to dash-case.
export type CoolElementAttributes = ReactElementAttributes<CoolElement, 'coolType' | 'coolFactor'>
Expand All @@ -827,13 +845,45 @@ declare global {
}
```
Now when you use `<cool-element>` in React JSX, it will be type checked:
```jsx
return (
<cool-element
coolType={123} // Type error: number is not assignable to 'beans' | 'hair'
coolFactor={'foo'} // Type error: string is not assignable to number
></cool-element>
)
```
> [!Note]
> You may want to define React JSX types for your elements in separate files, and
> have only React users import those files if they need the types, and similar if you make
> JSX types for Vue, Svelte, etc (we don't have helpers for those other fameworks
> yet, but you can manually augment JSX as in the examples above on a
> per-framework basis, contributions welcome!).
### With Preact JSX
It works the same as the previous section for React JSX. Define the element
types with the same `ReactElementAttributes` helper as described above. In your
TypeScript `compilerOptions` make sure you link to the React compatibility
layer:
```json
{
"compilerOptions": {
/* Preact Config */
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
}
}
}
```
## API
### `Element`
Expand Down
20 changes: 18 additions & 2 deletions dist/LumeElement.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,30 @@ type Template = TemplateContent | (() => TemplateContent);
* let coolEl = <cool-element foo={'foo'} bar={null} lorem-ipsum={456}></cool-element>
* ```
*/
export type ElementAttributes<ElementType, SelectedProperties extends keyof ElementType, AdditionalProperties extends object = {}> = WithStringValues<DashCasedProps<Partial<Pick<ElementType, SelectedProperties>>>> & AdditionalProperties & Omit<JSX.HTMLAttributes<ElementType>, SelectedProperties | keyof AdditionalProperties>;
export type ElementAttributes<ElementType extends HTMLElement, SelectedProperties extends keyof RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>, AdditionalProperties extends object = {}> = Omit<JSX.HTMLAttributes<ElementType>, SelectedProperties | keyof AdditionalProperties | 'onerror'> & {
onerror?: ((error: ErrorEvent) => void) | null;
} & Partial<DashCasedProps<WithStringValues<Pick<RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>, SelectedProperties>>>> & AdditionalProperties;
/**
* Make all non-string properties union with |string because they can all
* receive string values from string attributes like opacity="0.5" (those values
* are converted to the types of values they should be, f.e. reading a
* `@numberAttribute` property always returns a `number`)
*/
type WithStringValues<Type extends object> = {
export type WithStringValues<Type extends object> = {
[Property in keyof Type]: NonNullable<Type[Property]> extends string ? Type[Property] : Type[Property] | string;
};
type StringKeysOnly<T extends PropertyKey> = UnionWithout<T, number | symbol>;
type UnionWithout<T, TypeToRemove> = T extends TypeToRemove ? never : T;
export type RemovePrefixes<T, Prefix extends string> = {
[K in keyof T as K extends string ? RemovePrefix<K, Prefix> : K]: T[K];
};
type RemovePrefix<T extends string, Prefix extends string> = T extends `${Prefix}${infer Rest}` ? Rest : T;
export type RemoveAccessors<T> = {
[K in keyof T as K extends RemovePrefix<StringKeysOnly<SetterTypeKeysFor<T>>, SetterTypePrefix> ? never : K]: T[K];
};
type SetterTypeKeysFor<T> = keyof PrefixPick<T, SetterTypePrefix>;
type PrefixPick<T, Prefix extends string> = {
[K in keyof T as K extends `${Prefix}${string}` ? K : never]: T[K];
};
export type SetterTypePrefix = '__set__';
//# sourceMappingURL=LumeElement.d.ts.map
2 changes: 1 addition & 1 deletion dist/LumeElement.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 5 additions & 6 deletions dist/react.d.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import type { HTMLAttributes as ReactHTMLAttributes, DetailedHTMLProps as ReactDetailedHTMLProps } from 'react';
import type { DashCasedProps } from './utils';
import type { RemoveAccessors, RemovePrefixes, SetterTypePrefix, WithStringValues } from './LumeElement.js';
/**
* Similar to ElementAttributes, but for defining element attribute types for
* React JSX. See LUME Element's [TypeScript
* docs](https://docs.lume.io/#/guide/making-elements?id=typescript) for
* details.
*/
export type ReactElementAttributes<ElementType, SelectedProperties extends keyof ElementType> = ReactDetailedHTMLProps<DashCasedProps<Partial<ToStringValues<Pick<ElementType, SelectedProperties>>>> & ReactHTMLAttributes<ElementType>, ElementType>;
type ToStringValues<Type extends object> = {
[Property in keyof Type]: Type[Property] extends string ? Type[Property] : Type[Property] extends boolean ? boolean | string : string;
};
export {};
export type ReactElementAttributes<ElementType extends HTMLElement, SelectedProperties extends keyof RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>, AdditionalProperties extends object = {}> = Omit<ReactDetailedHTMLProps<ReactHTMLAttributes<ElementType>, ElementType>, SelectedProperties | keyof AdditionalProperties> & {
/** The 'has' attribute from the 'element-behaviors' package. If element-behaviors is installed and imported (it is if you're using `lume` 3D elements) then this specifies which behaviors to instantiate on the given element. */
has?: string;
} & Partial<WithStringValues<Pick<RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>, SelectedProperties>>> & AdditionalProperties;
//# sourceMappingURL=react.d.ts.map
2 changes: 1 addition & 1 deletion dist/react.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,19 @@
},
"devDependencies": {
"@lume/cli": "^0.14.0",
"@types/react": "^17.0.0",
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc",
"ncp": "^2.0.0",
"prettier": "3.0.3",
"typescript": "^5.0.0"
},
"peerDependencies": {
"@types/react": "*"
},
"overrides": {
"@types/react": "npm:types-react@rc",
"@types/react-dom": "npm:types-react-dom@rc"
},
"repository": {
"type": "git",
"url": "git+ssh://[email protected]/lume/element.git"
Expand Down
55 changes: 47 additions & 8 deletions src/LumeElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,13 +456,12 @@ export {LumeElement as Element}

export type AttributeHandlerMap = Record<string, AttributeHandler>

// This is TypeScript-specific. Eventually Hegel would like to have better
// support for JSX. We'd need to figure how to supports types for both systems.
import type {JSX} from './jsx-runtime'
type JSXOrDOM = JSX.Element | globalThis.Element
type TemplateContent = JSXOrDOM | JSXOrDOM[]
type Template = TemplateContent | (() => TemplateContent)

// prettier-ignore
/**
* A helper for defining the JSX types of an element's attributes.
*
Expand Down Expand Up @@ -503,19 +502,59 @@ type Template = TemplateContent | (() => TemplateContent)
* ```
*/
export type ElementAttributes<
ElementType,
SelectedProperties extends keyof ElementType,
ElementType extends HTMLElement,
SelectedProperties extends keyof RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>,
AdditionalProperties extends object = {},
> = WithStringValues<DashCasedProps<Partial<Pick<ElementType, SelectedProperties>>>> &
AdditionalProperties &
Omit<JSX.HTMLAttributes<ElementType>, SelectedProperties | keyof AdditionalProperties>
> = Omit<
JSX.HTMLAttributes<ElementType>,
SelectedProperties | keyof AdditionalProperties | 'onerror'
>
& {
// Fixes the onerror JSX prop type (https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1821)
onerror?: ((error: ErrorEvent) => void) | null
}

& Partial<
DashCasedProps<
WithStringValues<
Pick<
RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>,
SelectedProperties
>
>
>
>

& AdditionalProperties

/**
* Make all non-string properties union with |string because they can all
* receive string values from string attributes like opacity="0.5" (those values
* are converted to the types of values they should be, f.e. reading a
* `@numberAttribute` property always returns a `number`)
*/
type WithStringValues<Type extends object> = {
export type WithStringValues<Type extends object> = {
[Property in keyof Type]: NonNullable<Type[Property]> extends string ? Type[Property] : Type[Property] | string
}

type StringKeysOnly<T extends PropertyKey> = UnionWithout<T, number | symbol>

type UnionWithout<T, TypeToRemove> = T extends TypeToRemove ? never : T

export type RemovePrefixes<T, Prefix extends string> = {
[K in keyof T as K extends string ? RemovePrefix<K, Prefix> : K]: T[K]
}

type RemovePrefix<T extends string, Prefix extends string> = T extends `${Prefix}${infer Rest}` ? Rest : T

export type RemoveAccessors<T> = {
[K in keyof T as K extends RemovePrefix<StringKeysOnly<SetterTypeKeysFor<T>>, SetterTypePrefix> ? never : K]: T[K]
}

type SetterTypeKeysFor<T> = keyof PrefixPick<T, SetterTypePrefix>

type PrefixPick<T, Prefix extends string> = {
[K in keyof T as K extends `${Prefix}${string}` ? K : never]: T[K]
}

export type SetterTypePrefix = '__set__'
Loading

0 comments on commit d4cb151

Please sign in to comment.