UNPKG

woby

Version:

A high-performance framework with fine-grained observable/signal-based reactivity for building rich applications.

589 lines (453 loc) 19.2 kB
# Type Synchronization Between HTML Attributes and Component Props This document explains how Woby synchronizes types between HTML string-only attributes and typed function component props, enabling seamless two-way binding for custom elements. It includes both a comprehensive approach and a simpler approach to type synchronization. ## Overview In web components, HTML attributes are always strings, but JavaScript/TypeScript components often need typed values (numbers, booleans, objects). Woby provides mechanisms to automatically convert attribute strings to the appropriate types based on observable declarations. This document covers both the comprehensive type synchronization mechanism and a simpler approach. For information about how the `merge` function handles different prop sources, see [Component Defaults](./CUSTOM_ELEMENTS.md#component-defaults-and-two-way-synchronization). ## How It Works ### 1. Typed Observables The key to type synchronization is using typed observables in your default props: ```typescript function def() { return { value: $(0, { type: 'number' } as const), // Typed as number disabled: $(false, { type: 'boolean' } as const), // Typed as boolean label: $('', { type: 'string' } as const) // Typed as string (default) } } ``` ### 2. Attribute to Prop Conversion When an HTML attribute changes, Woby automatically converts the string value to the appropriate type based on the observable's type option: ```html <!-- HTML --> <my-counter value="5" disabled="true" label="Counter"></my-counter> ``` Conversion process: - `value="5"` `value: $(5)` (string "5" converted to number 5) - `disabled="true"` `disabled: $(true)` (string "true" converted to boolean true) - `label="Counter"` `label: $("Counter")` (string remains string) ### 3. Type Conversion Rules Woby supports automatic conversion for several built-in types: | Type Option | HTML Attribute | Converted Value | Notes | |-------------|----------------|-----------------|-------| | `'number'` | `"5"` | `5` | Uses `Number(value)` | | `'boolean'` | `"true"`, `"1"`, `""` | `true` | Special handling | | `'boolean'` | `"false"`, `"0"`, other | `false` | | | `'bigint'` | `"123"` | `123n` | Uses `BigInt(value)` | | `'object'` | `'{"a":1}'` | `{a:1}` | Uses `JSON.parse(value)` | | `'string'` | `"text"` | `"text"` | No conversion (default) | | `'undefined'` | `"any"` | `undefined` | Always converts to undefined | ### 4. Special Boolean Handling Boolean conversion follows HTML standards: - Truthy: `"true"`, `"1"`, or empty string `""` - Falsy: `"false"`, `"0"`, or any other value ```html <!-- All of these set disabled to $(true) --> <my-element disabled="true"></my-element> <my-element disabled="1"></my-element> <my-element disabled=""></my-element> <!-- All of these set disabled to $(false) --> <my-element disabled="false"></my-element> <my-element disabled="0"></my-element> <my-element disabled="anything"></my-element> ``` ## HTML Utility Types Woby provides a set of HTML utility types that make it easier to work with common HTML attribute patterns. These utilities implement the `ObservableOptions` interface and provide consistent conversion between JavaScript values and HTML attributes. ### HtmlBoolean Handles boolean values with automatic conversion from HTML string attributes: ```tsx import { HtmlBoolean } from 'woby' const def = () => ({ enabled: $(false, HtmlBoolean) }) ``` ### HtmlNumber Handles numeric values with automatic conversion from HTML string attributes: ```tsx import { HtmlNumber } from 'woby' const def = () => ({ value: $(0, HtmlNumber) }) ``` ### HtmlDate Handles Date values with ISO string serialization: ```tsx import { HtmlDate } from 'woby' const def = () => ({ selectedDate: $(new Date(), HtmlDate) }) ``` ### HtmlBigInt Handles BigInt values with automatic conversion: ```tsx import { HtmlBigInt } from 'woby' const def = () => ({ largeNumber: $(BigInt(0), HtmlBigInt) }) ``` ### HtmlObject Handles Object values with JSON serialization: ```tsx import { HtmlObject } from 'woby' const def = () => ({ config: $({} as any, HtmlObject) }) ``` ### HtmlLength Handles CSS length values (px, em, rem, %, etc.): ```tsx import { HtmlLength } from 'woby' const def = () => ({ width: $('100px', HtmlLength), height: $('auto', HtmlLength) }) ``` ### HtmlBox Handles CSS box values (margin, padding, border, etc.): ```tsx import { HtmlBox } from 'woby' const def = () => ({ margin: $('10px', HtmlBox), padding: $('5px 10px', HtmlBox) }) ``` ### HtmlColor Handles CSS color values (hex, rgb, etc.): ```tsx import { HtmlColor } from 'woby' const def = () => ({ backgroundColor: $('#ffffff', HtmlColor), textColor: $('rgb(0, 0, 0)', HtmlColor) }) ``` ### HtmlStyle Handles CSS style values (objects and strings): ```tsx import { HtmlStyle } from 'woby' const def = () => ({ customStyles: $({} as any, HtmlStyle) }) ``` ### Benefits of HTML Utility Types 1. **Type Safety**: Each utility provides proper type conversion between HTML attributes and JavaScript values 2. **Consistency**: All utilities follow the same pattern and behavior 3. **Automatic Serialization**: Complex values are automatically serialized to/from HTML attributes 4. **Error Handling**: Utilities handle edge cases and invalid values gracefully 5. **Empty String Handling**: All utilities treat empty strings as `undefined` for consistent behavior 6. **Equality Checking**: Each utility implements proper equality checking for value comparison ## HTML Attribute Serialization Custom elements support custom serialization of observable values to and from HTML attributes using the `toHtml` and `fromHtml` options: ### Hiding Properties from HTML Attributes To prevent a property from appearing in HTML attributes, use the `toHtml` option with a function that returns `undefined`: ```typescript function def() { return { value: $(0, { type: 'number' } as const), increment: $([() => { value($$(value) + 10) }], { toHtml: o => undefined }), //hide this from html attributes } } ``` ### Object and Date Serialization To serialize complex objects and dates to and from HTML attributes, use the `toHtml` and `fromHtml` options: ```typescript function def() { return { obj: $({ nested: { text: 'abc' } }, { toHtml: o => JSON.stringify(o), fromHtml: o => JSON.parse(o) }), date: $(new Date(), { toHtml: o => o.toISOString(), fromHtml: o => new Date(o) }) } } ``` These serialization options allow complex JavaScript objects and Date instances to be properly converted to and from HTML attribute strings, enabling two-way synchronization between HTML attributes and component props. ## Handling Different Prop Sources ### Props from HTML Attributes When props come from HTML attributes, they are always strings and must be converted to the appropriate types: ```html <my-component count="42" enabled="false" data='{"key":"value"}'></my-component> ``` The conversion process: 1. String values are parsed from HTML attributes 2. Type information from observables determines conversion rules 3. Values are converted and set on observables ### Props from JSX/TSX When props come from JSX/TSX, they are already typed JavaScript values: ```tsx const App = () => { const count = $(42) const enabled = $(false) const data = $({key: "value"}) return <MyComponent count={count} enabled={enabled} data={data} /> } ``` In this case: 1. Values are already properly typed 2. No conversion is needed 3. The `merge` function combines them with defaults appropriately ### Inline Parameter Initialization Using inline parameter initialization can interfere with type synchronization: ```typescript // ❌ Potential issues with type synchronization const MyComponent = defaults(def, ({ count = $(0) }: MyComponentProps): JSX.Element => { // The inline default $(0) may not have type information // This can cause issues with HTML attribute synchronization // ... }) // ✅ Recommended approach const MyComponent = defaults(def, (props: MyComponentProps): JSX.Element => { // The merge function properly handles type synchronization const mergedProps = merge(props, def()) const { count } = mergedProps // ... }) ``` ## Implementation Details ### In Custom Elements The type conversion happens in the `setObservableValue` function in `custom_element.ts`: ```typescript const setObservableValue = (obj: any, key: string, value: string) => { if (isObservable(obj[key])) { // Cast value according to observable options type const observable = obj[key] as Observable<any> const options = (observable[SYMBOL_OBSERVABLE_WRITABLE]).options as ObservableOptions<any> | undefined const { type, fromHtml } = options ?? {} if (type) { switch (type) { case 'number': obj[key](fromHtml ? fromHtml(value) : Number(value)) break case 'boolean': // Handle various boolean representations if (fromHtml) { obj[key](fromHtml(value)) } else { const lowerValue = value?.toLowerCase() obj[key](lowerValue === 'true' || lowerValue === '1' || lowerValue === '') } break case 'bigint': if (fromHtml) { obj[key](fromHtml(value)) } else { try { obj[key](BigInt(value)) } catch (e) { // If parsing fails, fallback to string obj[key](value) } } break case 'object': if (fromHtml) { obj[key](fromHtml(value)) } else { try { obj[key](JSON.parse(value)) } catch (e) { // If parsing fails, fallback to string obj[key](value) } } break case 'function': // For function types, we can't really convert from string // This would typically be handled by the component itself obj[key](fromHtml ? fromHtml(value) : value) break case 'symbol': // For symbol types, create a symbol from the string obj[key](fromHtml ? fromHtml(value) : Symbol(value)) break case 'undefined': obj[key](fromHtml ? fromHtml(value) : undefined) break default: // For constructor types or other custom types, treat as string // since HTML attributes are always strings and we can't instantiate // arbitrary constructors from strings obj[key](fromHtml ? fromHtml(value) : value) break } } else { obj[key](fromHtml ? fromHtml(value) : value) } } else { obj[key] = value } } ``` ### In Component Defaults When defining defaults, always specify the `type` option for non-string values: ```typescript // ✅ Correct - with type information function def() { return { count: $(0, { type: 'number' } as const), enabled: $(true, { type: 'boolean' } as const), data: $({} as any, { type: 'object' } as const) } } // ❌ Incorrect - no type information function def() { return { count: $(0), // Will be treated as string enabled: $(true), // Will be treated as string data: $({}) // Will be treated as string } } ``` ## Complete Example Here's a complete example showing type synchronization in action: ```typescript import { $, defaults, merge, customElement } from 'woby' import type { Observable } from 'woby' interface MyComponentProps { count?: Observable<number> enabled?: Observable<boolean> data?: Observable<{[key: string]: any}> label?: string // Non-observable, treated as string obj?: Observable<{ nested: { text: string } }> date?: Observable<Date> } function def() { return { count: $(0, { type: 'number' } as const), enabled: $(true, { type: 'boolean' } as const), data: $({} as any, { type: 'object' } as const), label: $(''), obj: $({ nested: { text: 'abc' } }, { toHtml: o => JSON.stringify(o), fromHtml: o => JSON.parse(o) }), date: $(new Date(), { toHtml: o => o.toISOString(), fromHtml: o => new Date(o) }) } } const MyComponent = defaults(def, (props: MyComponentProps): JSX.Element => { const mergedProps = merge(props, def()) const { count, enabled, data, label, obj, date } = mergedProps return ( <div> <p>Count: {count}</p> <p>Enabled: {enabled ? 'Yes' : 'No'}</p> <p>Data: {JSON.stringify(data)}</p> <p>Label: {label}</p> <p>Object: {() => JSON.stringify($$(obj))}</p> <p>Date: {() => $$(date).toString()}</p> </div> ) }) customElement('my-component', MyComponent) ``` Usage: ```html <!-- HTML usage with automatic type conversion --> <my-component count="42" enabled="false" data='{"key":"value"}' label="My Component" obj='{"nested":{"text":"xyz"}}' date="2023-01-01T00:00:00.000Z"> </my-component> ``` In this example: - `count="42"` becomes `count: $(42)` (number) - `enabled="false"` becomes `enabled: $(false)` (boolean) - `data='{"key":"value"}'` becomes `data: $({key: "value"})` (object) - `label="My Component"` becomes `label: "My Component"` (string) - `obj='{"nested":{"text":"xyz"}}'` becomes `obj: $({nested: {text: "xyz"}})` (object with custom serialization) - `date="2023-01-01T00:00:00.000Z"` becomes `date: $(new Date("2023-01-01T00:00:00.000Z"))` (date with custom serialization) ## Best Practices 1. **Always Specify Types**: For non-string props, always use the `type` option in your defaults 2. **Use `as const`**: Cast type options to `as const` for better TypeScript inference 3. **Handle Edge Cases**: Consider what should happen with invalid values (e.g., `"not-a-number"` for a number type) 4. **Test Both Directions**: Verify that both HTML attribute changes and prop changes work correctly 5. **Use `merge(props, def())`**: Always use the merge pattern for proper handling of different prop sources 6. **Use `toHtml` and `fromHtml`**: For complex objects and dates, use these options for proper serialization 7. **Hide Functions from HTML**: Use `toHtml: () => undefined` to prevent functions from appearing in HTML attributes 8. **Store Functions in Array Notation**: Use `$([() => { /* function body */ }])` to store functions in observables for custom elements ## Simple Type Synchronization Approach For those who prefer a more straightforward approach to type synchronization, Woby also provides a simpler mechanism. ### Basic Implementation The simplest way to achieve type synchronization is by declaring observables with explicit type information using the `{ type: '...' }` option: ```typescript const def = () => ({ // Number type count: $(0, { type: 'number' } as const), // Boolean type enabled: $(true, { type: 'boolean' } as const), // String type (default) label: $('', { type: 'string' } as const), // Object type config: $({} as MyConfig, { type: 'object' } as const) }) ``` ### How It Works 1. **Declaration**: When defining observables, specify the expected type using the `type` option 2. **Attribute Binding**: HTML attributes automatically bind to these typed observables 3. **Automatic Conversion**: The framework converts string attributes to the declared types #### Example Component ```typescript import { $, component, defaults } from 'woby' const def = () => ({ value: $(0, { type: 'number' } as const), disabled: $(false, { type: 'boolean' } as const), label: $('', { type: 'string' } as const) }) const Counter = (props: ReturnType<typeof def>) => { const { value, disabled, label } = defaults(def, props) // value is typed as number // disabled is typed as boolean // label is typed as string return ( <div> <span>{label}</span> <button disabled={disabled} onclick={() => value(prev => prev + 1)}> Count: {value} </button> </div> ) } component('my-counter', Counter, def) ``` #### HTML Usage ```html <!-- The framework automatically converts these string attributes --> <my-counter value="5" disabled="false" label="My Counter"></my-counter> ``` In this example: - `"5"` (string) gets converted to `5` (number) - `"false"` (string) gets converted to `false` (boolean) - `"My Counter"` remains as string ### Supported Types The simple type synchronization supports these types: | Type | Conversion Rule | Example | |----------|--------------------------------------------|-------------------| | number | `Number(attributeValue)` | "42" 42 | | boolean | `"true"/"1"/""` `true`, otherwise false | "false" false | | string | No conversion (default) | "text" "text" | | object | `JSON.parse(attributeValue)` | '{"a":1}' {a:1} | ### Key Benefits 1. **Automatic**: No manual parsing required in components 2. **Type-safe**: TypeScript knows the correct types 3. **Declarative**: Type information is declared with the observable 4. **Bidirectional**: Works for both HTML attributes and programmatic updates ### Best Practices 1. Always use `as const` with the type option for proper TypeScript inference: ```typescript $(0, { type: 'number' } as const) // ✅ Good $(0, { type: 'number' }) // ❌ Less precise typing ``` 2. Declare all attributes that should participate in type synchronization with explicit types 3. Use sensible defaults that match your expected types This simple approach eliminates the need for manual attribute parsing while maintaining full TypeScript type safety. ## Limitations 1. **Constructor Types**: Custom constructor types cannot be automatically instantiated from strings 2. **Function Types**: Functions cannot be meaningfully created from strings