Skip to content

Commit

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

This improves JSX types for Custom Elements in Solid JSX, React JSX, and Preact JSX,
especially in React/Preact JSX whereas previously the React/Preact JSX prop types only accepted
string values 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

Co-authored-by: bigmistqke <[email protected]>
  • Loading branch information
trusktr and bigmistqke committed Oct 8, 2024
1 parent 1811a92 commit babd555
Show file tree
Hide file tree
Showing 18 changed files with 356 additions and 43 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
24 changes: 21 additions & 3 deletions dist/LumeElement.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,14 +204,32 @@ 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> = {
[Property in keyof Type]: NonNullable<Type[Property]> extends string ? Type[Property] : Type[Property] | string;
export type WithStringValues<Type extends object> = {
[Property in keyof Type]: PickFromUnion<Type[Property], string> extends never ? // if the type does not include a type assignable to string
Type[Property] | string : Type[Property];
};
type StringKeysOnly<T extends PropertyKey> = OmitFromUnion<T, number | symbol>;
type OmitFromUnion<T, TypeToOmit> = T extends TypeToOmit ? never : T;
type PickFromUnion<T, TypeToPick> = T extends TypeToPick ? T : never;
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.

17 changes: 17 additions & 0 deletions dist/jsx-types-react.test.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ReactElementAttributes } from './react.js';
declare class SomeElement extends HTMLElement {
someProp: 'true' | 'false' | boolean;
get otherProp(): number;
set otherProp(_: this['__set__otherProp']);
/** do not use this property, its only for JSX types */
__set__otherProp: number | 'foo';
}
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'some-element': ReactElementAttributes<SomeElement, 'someProp' | 'otherProp'>;
}
}
}
export {};
//# sourceMappingURL=jsx-types-react.test.d.ts.map
1 change: 1 addition & 0 deletions dist/jsx-types-react.test.d.ts.map

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

32 changes: 32 additions & 0 deletions dist/jsx-types-react.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* @jsxImportSource react */
class SomeElement extends HTMLElement {
someProp = true;
get otherProp() {
return 0;
}
set otherProp(_) { }
/** do not use this property, its only for JSX types */
__set__otherProp;
}
SomeElement;
describe('JSX types with ReactElementAttributes', () => {
it('derives JSX types from classes', () => {
;
<>
<some-element someProp="false" otherProp="foo"/>
<some-element someProp="false" otherProp="foo"/>
<some-element someProp={false} otherProp={123}/>
{/* @ts-expect-error good, number is invalid */}
<some-element someProp={123}/>
{/* @ts-expect-error good, 'blah' is invalid */}
<some-element otherProp="blah"/>

{/* Additionally TypeScript will allow unknown dash-case props (as we didn't not define JS properties with these exact dash-cased names, React 19+ will set the element attributes, useful for setting the attributes but React has no way to specify to set attributes for names without dashes) */}
<some-element some-prop="false" other-prop="foo"/>
{/* @ts-expect-error foo doesn't exist. TypeScript will only check existence of properties without dashes */}
<some-element foo="false"/>
</>;
});
});
export {};
//# sourceMappingURL=jsx-types-react.test.jsx.map
1 change: 1 addition & 0 deletions dist/jsx-types-react.test.jsx.map

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

17 changes: 17 additions & 0 deletions dist/jsx-types-solid.test.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { ElementAttributes } from './LumeElement.js';
declare class SomeElement extends HTMLElement {
someProp: 'true' | 'false' | boolean;
get otherProp(): number;
set otherProp(_: this['__set__otherProp']);
/** do not use this property, its only for JSX types */
__set__otherProp: number | 'foo';
}
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'some-element': ElementAttributes<SomeElement, 'someProp' | 'otherProp'>;
}
}
}
export {};
//# sourceMappingURL=jsx-types-solid.test.d.ts.map
1 change: 1 addition & 0 deletions dist/jsx-types-solid.test.d.ts.map

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

32 changes: 32 additions & 0 deletions dist/jsx-types-solid.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/* @jsxImportSource solid-js */
class SomeElement extends HTMLElement {
someProp = true;
get otherProp() {
return 0;
}
set otherProp(_) { }
/** do not use this property, its only for JSX types */
__set__otherProp;
}
SomeElement;
describe('JSX types with ElementAttributes', () => {
it('derives JSX types from classes', () => {
;
<>
<some-element some-prop="false" other-prop="foo"/>
<some-element some-prop="false" other-prop="foo"/>
<some-element some-prop={false} other-prop={123}/>
{/* @ts-expect-error good, number is invalid */}
<some-element some-prop={123}/>
{/* @ts-expect-error good, 'blah' is invalid */}
<some-element other-prop="blah"/>

{/* Additionally TypeScript will allow unknown dash-case props (the attr: can be used here to tell Solid to set the element attributes instead of the JS properties) */}
<some-element attr:some-prop="false" attr:other-prop="foo"/>
{/* @ts-expect-error foo doesn't exist. TypeScript will only check existence of properties without dashes */}
<some-element foo="false"/>
</>;
});
});
export {};
//# sourceMappingURL=jsx-types-solid.test.jsx.map
Loading

0 comments on commit babd555

Please sign in to comment.