Skip to content

Commit

Permalink
**feat:** improve the JSX type helpers:
Browse files Browse the repository at this point in the history
- `on*` event properties can now be specified to define event props for JSX. The
  README has an example, plus the `jsx-types-*.test.tsx` files show all the type
  test cases.
  - They will be mapped without string values, accepting only functions.
- For Solid specifically, all the `on*` properties are also mapped to `on:*` JSX
  props with the same types.
- Any boolean JSX props will also accept "true" and "false" string values.
- Any number JSX props will also accept number strings, f.e. "1.23".
- For Solid specifically, automatically map prop types for `attr:`, `prop:`, and
  `bool:` prefixed JSX props.
  - `attr:` props will accept only strings.
    - `attr:` props mapped from boolean JS properties will specifically accept
      "true" and "false" strings.
    - `attr:` props mapped from number JS properties will specifically accept
      number strings, f.e. "1.23".
  - Only boolean JS properties will map to `bool:` JSX props, and f.e. `bool:`
    will not be available for any number props.
    - `bool:` props will accept only booleans, and not the "true" or "false"
      strings (otherwise Solid will set the attribute to always exist regardless
      of the value, and this is not an issue with React props because React props
      always map to JS properties never attributes when it comes to Lume
      Elements).
    - The `attr:` props will accept only "true" or "false" strings.
    - The non-prefixed JSX props, and `prop:` props, will accept both booleans
      and "true" or "false" strings.
  - Number JS properties are mapped to JSX props that accept numbers as well as
    number strings, but similar to boolean properties:
    - The `attr:` props accept only number strings.
    - And there are no `bool:` props mapped for number properties.
  - **POSSIBLY BREAKING:**: This update adds JSX types for event properties, which
    has a tiny chance of being breaking. If you had a typed JSX prop like
    `onnotevent` for an element whose JSX types you defined with
    `ElementAttributes`, there might be a type error, but this scenario is unlikely.
    To migrate, use the `prop:` prefix to set the JS property instead, for example
    `prop:onnotevent={someValue}`. The additional prefixed props could also cause an
    `@ts-expect-error` somewhere to start erroring, or a type conflict, in which
    case some care is needed.

**feat:** add a new `dispatchEventWithCall(event)` method

This is similar to `dispatchEvent()`, but useful for dispatching a non-builtin
event and causing any `on*` method for that event to also be called if it
exists.

With builtin events, for example, when the builtin `click` event is dispatched,
the element's `.onclick()` method is called automatically if it exists. Now we
can achieve the same behavior with custom events, so that for example
`dispatchEventWithCall(new Event('myevent'))` will also cause `.onmyevent()`
to be called if it exists.

Note, avoid calling this method with an event that is not a custom event, or
you'll trigger the respective builtin `on*` method twice.
  • Loading branch information
trusktr committed Oct 13, 2024
1 parent 6270241 commit 37b7269
Show file tree
Hide file tree
Showing 19 changed files with 908 additions and 134 deletions.
146 changes: 76 additions & 70 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -604,9 +604,6 @@ Modifying the very first example from above for TypeScript, it would look
like the following.
```tsx
/* @jsxImportSource solid-js */
// ^ Alternatively, configure this in tsconfig.json instead of per-file.
import {createSignal} from 'solid-js'
import {div} from '@lume/element/dist/type-helpers.js'
Expand Down Expand Up @@ -643,7 +640,6 @@ The main differences from plain JS are
> `<menu>` element then we need to use the `menu()` helper like follows.

```tsx
/* @jsxImportSource solid-js */
import {createSignal} from 'solid-js'
import {menu} from '@lume/element/dist/type-helpers.js'

Expand All @@ -663,7 +659,6 @@ type `HTMLDivElement` despite the fact that at runtime we will be have an
`HTMLMenuElement` instance.
```tsx
/* @jsxImportSource solid-js */
import {div, button} from '@lume/element/dist/type-helpers.js'

// GOOD.
Expand All @@ -680,8 +675,6 @@ following to have the proper types, but note that the following is also not type
safe:
```tsx
/* @jsxImportSource solid-js */

// GOOD.
const el = (<menu>...</menu>) as any as HTMLMenuElement

Expand All @@ -695,33 +688,41 @@ const el2 = (<menu>...</menu>) as any as HTMLDivElement
### With Solid JSX
To give your Custom Elements type checking for use with DOM APIs, and type
checking in Solid JSX, we can add the element type definition to `JSX.IntrinsicElements`:
checking in Solid JSX, we can add the element type definition to
`JSX.IntrinsicElements`. We can use the `ElementAttributes` helper to specify
which attributes/properties should be exposed in the JSX type:
```tsx
/* @jsxImportSource solid-js */

// We already use @jsxImportSource above, but if you need to reference JSX
// 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'

// Define the attributes that your element accepts
export interface CoolElementAttributes extends JSX.HTMLAttributes<CoolElement> {
'cool-type'?: 'beans' | 'hair'
'cool-factor'?: number
// ^ NOTE: These should be dash-case versions of your class's attribute properties.
}
import type {ElementAttributes} from '@lume/element'
import {Element, element, stringAttribute, numberAttribute} from '@lume/element'

// List the properties that should be picked from the class type for JSX props.
// Note! Make sure that the properties listed are either decorated with
// attribute decorators, or that they are on* event properties.
export type CoolElementAttributes = 'coolType' | 'coolFactor' | 'oncoolness'

@element('cool-element')
class CoolElement extends Element {
export class CoolElement extends Element {
@stringAttribute coolType: 'beans' | 'hair' = 'beans'
@numberAttribute coolFactor = 100
// ^ NOTE: These are the camelCase equivalents of the attributes defined above.

// Define the event prop by defining a method with the event name prefixed with 'on'.
oncoolness: ((event: SomeEvent) => void) | null = null

// This property will not appear in the JSX types because it is not listed in
// the CoolElementAttributes that are passed to ElementAttributes below.
notJsxProp = 123

// ... Define your class as described above. ...
}

export {CoolElement}
/** This an event that our element emits. */
class SomeEvent extends Event {
constructor() {
super('someevent', {...})
}
}

// Add your element to the list of known HTML elements. This makes it possible
// for browser APIs to have the expected return type. For example, the return
Expand All @@ -732,36 +733,14 @@ declare global {
}
}

// Also register the element name in the JSX types for TypeScript to recognize
// the element as a valid JSX tag.
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'cool-element': CoolElementAttributes
}
}
}
```
> :bulb:**TIP:**
>
> To make code less redundant, use the `ElementAttributes` helper to
> pluck the types of properties directly from your custom element class for the
> attribute types:
```ts
import type {ElementAttributes} from '@lume/element'

// This definition is now shorter than before, automatically maps the property
// names to dash-case, and automatically picks up the property types from the
// class.
export type CoolElementAttributes = ElementAttributes<CoolElement, 'coolType' | 'coolFactor'>

// The same as before:
declare module 'solid-js' {
namespace JSX {
interface IntrinsicElements {
'cool-element': CoolElementAttributes
// This automatically maps the property names from camelCase to dash-case,
// automatically picks up the property types from the class, and also
// defines additional types for attr:, prop:, and bool: prefixed props.
'cool-element': ElementAttributes<CoolElement, CoolElementAttributes>
}
}
}
Expand All @@ -772,8 +751,11 @@ Now when you use `<cool-element>` in Solid JSX, it will be type checked:
```jsx
return (
<cool-element
cool-type={123} // Type error: number is not assignable to 'beans' | 'hair'
cool-factor={'foo'} // Type error: string is not assignable to number
// cool-type={123} // Type error: number is not assignable to 'beans' | 'hair'
// cool-factor={'foo'} // Type error: string is not assignable to number
cool-type="hair" // ok
cool-factor="200" // ok
oncoolness={() = console.log('someevent')} // ok
></cool-element>
)
```
Expand All @@ -794,16 +776,7 @@ Defining the types of custom elements for React JSX is similar as for Solid JSX
```
```ts
import type {HTMLAttributes} from 'react'

// Define the attributes that your element accepts, almost the same as before:
export interface CoolElementAttributes extends HTMLAttributes<CoolElement> {
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.
}
import type {ReactElementAttributes} from '@lume/element/dist/react.js'

// Add your element to the list of known HTML elements, like before.
declare global {
Expand Down Expand Up @@ -832,14 +805,15 @@ declare global {
```ts
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'>
// ... same as before ...

// The same as before:
declare global {
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'cool-element': CoolElementAttributes
// Similar as before, with ReactElementAttributes instead of
// ElementAttributes, and props will remain camelCase, not mapped to
// dash-case:
'cool-element': ReactElementAttributes<CoolElement, CoolElementAttributes>
}
}
}
Expand All @@ -850,8 +824,11 @@ 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
// coolType={123} // Type error: number is not assignable to 'beans' | 'hair'
// coolFactor={'foo'} // Type error: string is not assignable to number
coolType="hair" // ok
coolFactor="200" // ok
oncoolness={() = console.log('someevent')} // ok
></cool-element>
)
```
Expand All @@ -860,8 +837,7 @@ return (
> 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!).
> yet, but you can manually augment JSX in that case, contributions welcome!).
### With Preact JSX
Expand All @@ -884,6 +860,8 @@ layer:
}
```
The rest is the same.
## API
### `Element`
Expand Down Expand Up @@ -1664,6 +1642,34 @@ element's style sheet into the `ShadowRoot` conflicts with how DOM is created in
`ShadowRoot` content, or etc, then the user may want to place the stylesheet
somewhere else).
#### `dispatchEventWithCall(event)`
This is similar to `dispatchEvent()`, but useful for dispatching a non-builtin
event and causing any `on*` method for that event to also be called if it
exists.
With builtin events, for example, when the builtin `click` event is dispatched,
the element's `.onclick()` method is called automatically if it exists. Now we
can achieve the same behavior with custom events, so that for example
`dispatchEventWithCall(new Event('myevent'))` will also cause `.onmyevent()`
to be called if it exists.
Note, avoid calling this method with an event that is not a custom event, or
you'll trigger the respective builtin `on*` method twice.
```ts
import {element, Element} from '@lume/element'

@element('my-el')
class MyEl extends Element {
onfoo: ((event: Event) => void) | null = null
}

const el = new MyEl()
el.onfoo = () => console.log('foo')
el.dispatchEventWithCall(new Event('foo')) // logs "foo"
```
### Decorators
Using decorators (if available in your build, or natively in your JS engine)
Expand Down
76 changes: 69 additions & 7 deletions dist/LumeElement.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,21 @@ declare class LumeElement extends LumeElement_base {
disconnectedCallback(): void;
attributeChangedCallback?(name: string, oldVal: string | null, newVal: string | null): void;
adoptedCallback(): void;
/**
* This is similar to `dispatchEvent()`, but useful for dispatching a
* non-builtin event and causing any `on*` method for that event to also be
* called if it exists.
*
* With builtin events, for example, when the builtin `click` event is
* dispatched, the element's `.onclick()` method is called automatically if
* it exists. Now we can achieve the same behavior with custom events, so
* that for example `dispatchEventWithCall(new Event('myevent'))` will
* also cause `.onmyevent()` to be called if it exists.
*
* Note, avoid calling this method with an event that is not a custom event,
* or you'll trigger the respective builtin `on*` method twice.
*/
dispatchEventWithCall(event: Event): boolean;
}
export { LumeElement as Element };
export type AttributeHandlerMap = Record<string, AttributeHandler>;
Expand Down Expand Up @@ -204,23 +219,70 @@ type Template = TemplateContent | (() => TemplateContent);
* let coolEl = <cool-element foo={'foo'} bar={null} lorem-ipsum={456}></cool-element>
* ```
*/
export type ElementAttributes<ElementType extends HTMLElement, SelectedProperties extends keyof RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>, AdditionalProperties extends object = {}> = Omit<JSX.HTMLAttributes<ElementType>, SelectedProperties | keyof AdditionalProperties | 'onerror'> & {
export type ElementAttributes<El, SelectedProperties extends keyof RemoveSetterPrefixes<RemoveAccessors<El>, SetterTypePrefix>, AdditionalProperties extends object = {}> = Omit<JSX.HTMLAttributes<El>, SelectedProperties | keyof AdditionalProperties | 'onerror'> & {
onerror?: ((error: ErrorEvent) => void) | null;
} & Partial<DashCasedProps<WithStringValues<Pick<RemovePrefixes<RemoveAccessors<ElementType>, SetterTypePrefix>, SelectedProperties>>>> & AdditionalProperties;
} & Partial<DashCasedProps<WithStringValues<NonNumberProps<NonBooleanProps<NonOnProps<El, SelectedProperties>>>>>> & Partial<AsValues<NonFunctionsOnly<EventProps<El, SelectedProperties>>, never>> & Partial<PrefixProps<'prop:', WithStringValues<NonNumberProps<NonBooleanProps<NonEventProps<El, SelectedProperties>>>>>> & Partial<PrefixProps<'attr:', DashCasedProps<AsStringValues<NonNumberProps<NonBooleanProps<NonEventProps<El, SelectedProperties>>>>>>> & Partial<DashCasedProps<WithBooleanStringValues<BooleanProps<Pick<RemapSetters<El>, SelectedProperties>>>> & PrefixProps<'bool:', DashCasedProps<AsBooleanValues<BooleanProps<Pick<RemapSetters<El>, SelectedProperties>>>>> & PrefixProps<'prop:', WithBooleanStringValues<BooleanProps<Pick<RemapSetters<El>, SelectedProperties>>>> & PrefixProps<'attr:', DashCasedProps<AsBooleanStringValues<BooleanProps<Pick<RemapSetters<El>, SelectedProperties>>>>>> & Partial<DashCasedProps<WithNumberStringValues<NumberProps<Pick<RemapSetters<El>, SelectedProperties>>>> & PrefixProps<'prop:', WithNumberStringValues<NumberProps<Pick<RemapSetters<El>, SelectedProperties>>>> & PrefixProps<'attr:', DashCasedProps<AsNumberStringValues<NumberProps<Pick<RemapSetters<El>, SelectedProperties>>>>>> & Partial<FunctionsOnly<EventProps<El, SelectedProperties>>> & Partial<AddDelimitersToEventKeys<FunctionsOnly<EventProps<El, SelectedProperties>>>> & AdditionalProperties;
type RemapSetters<El> = RemoveSetterPrefixes<RemoveAccessors<El>, SetterTypePrefix>;
type NonOnProps<El, K extends keyof RemapSetters<El>> = Pick<RemapSetters<El>, OmitFromUnion<K, EventKeys<K>>>;
export type NonEventProps<El, K extends keyof RemoveSetterPrefixes<RemoveAccessors<El>, SetterTypePrefix>> = NonOnProps<El, K> & NonFunctionsOnly<EventProps<El, K>>;
export type EventProps<T, Keys extends keyof T> = Pick<T, EventKeys<OmitFromUnion<Keys, symbol | number>>>;
export type NonBooleanProps<T> = Omit<T, keyof BooleanProps<T>>;
export type BooleanProps<T> = {
[K in keyof T as T[K] extends boolean | 'true' | 'false' ? K : never]: T[K];
};
export type NonNumberProps<T> = Omit<T, keyof NumberProps<T>>;
export type NumberProps<T> = {
[K in keyof T as T[K] extends number ? K : never]: T[K];
};
export type FunctionsOnly<T> = {
[K in keyof T as NonNullable<T[K]> extends (...args: any[]) => any ? K : never]: T[K];
};
export type NonFunctionsOnly<T> = {
[K in keyof T as ((...args: any[]) => any) extends NonNullable<T[K]> ? never : K]: T[K];
};
/**
* 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`)
*/
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];
export type WithStringValues<T extends object> = {
[K in keyof T]: PickFromUnion<T[K], string> extends never ? // if the type does not include a type assignable to string
T[K] | string : T[K];
};
export type WithBooleanStringValues<T extends object> = {
[K in keyof T]: T[K] | 'true' | 'false';
};
export type AsBooleanStringValues<T extends object> = {
[K in keyof T]: 'true' | 'false';
};
export type AsBooleanValues<T extends object> = {
[K in keyof T]: boolean;
};
export type WithNumberStringValues<T extends object> = {
[K in keyof T]: T[K] | `${number}`;
};
export type AsNumberStringValues<T extends object> = {
[K in keyof T]: `${number}`;
};
export type AsValues<T extends object, V> = {
[K in keyof T]: V;
};
type AsStringValues<T extends object> = {
[K in keyof T]: PickFromUnion<T[K], string> extends never ? string : T[K];
};
type StringKeysOnly<T extends PropertyKey> = OmitFromUnion<T, number | symbol>;
type OmitFromUnion<T, TypeToOmit> = T extends TypeToOmit ? never : T;
export 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> = {
export type EventKeys<T extends string> = T extends `on${infer _}` ? T : never;
type AddDelimitersToEventKeys<T extends object> = {
[K in keyof T as K extends string ? AddDelimiters<K, ':'> : never]: T[K];
};
type AddDelimiters<T extends string, Delimiter extends string> = T extends `${'on'}${infer Right}` ? `${'on'}${Delimiter}${Right}` : T;
type PrefixProps<Prefix extends string, T> = {
[K in keyof T as K extends string ? `${Prefix}${K}` : K]: T[K];
};
export type RemoveSetterPrefixes<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;
Expand Down
Loading

0 comments on commit 37b7269

Please sign in to comment.