UNPKG

woby

Version:

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

618 lines (496 loc) 17.8 kB
# Custom Element Practical Guide: From Basics to Advanced Patterns This guide provides a practical, hands-on approach to creating custom elements in Woby, based on real-world examples and patterns demonstrated in the counter demo. It covers everything from basic setup to advanced features with detailed explanations and code examples. ## Table of Contents 1. [Introduction to Custom Elements](#introduction-to-custom-elements) 2. [Basic Custom Element Creation](#basic-custom-element-creation) 3. [The Defaults Pattern Explained](#the-defaults-pattern-explained) 4. [Handling Functions in Custom Elements](#handling-functions-in-custom-elements) 5. [Object and Date Serialization](#object-and-date-serialization) 6. [Context Propagation in Custom Elements](#context-propagation-in-custom-elements) 7. [Nested Properties Deep Dive](#nested-properties-deep-dive) 8. [Style Properties Handling](#style-properties-handling) 9. [HTML vs JSX Usage Patterns](#html-vs-jsx-usage-patterns) 10. [TypeScript Integration](#typescript-integration) 11. [Best Practices and Common Pitfalls](#best-practices-and-common-pitfalls) ## Introduction to Custom Elements Custom elements in Woby are standard web components that can be used directly in HTML or JSX. They provide two-way synchronization between HTML attributes and component props, making them incredibly powerful for building reusable components. Unlike ordinary React components, custom elements in Woby: - Can be used directly in HTML without any JavaScript - Automatically synchronize attributes with component props - Handle type conversion between HTML strings and JavaScript values - Support nested properties and complex data structures - Integrate seamlessly with Woby's reactivity system ## Basic Custom Element Creation Let's start with a simple counter component to understand the basics: ```typescript import { $, customElement, defaults } from 'woby' // Define the default props function function def() { return { value: $(0, { type: 'number' } as const), title: $('Counter') } } // Create the component using defaults const Counter = defaults(def, (props) => { const { value, title } = props const increment = () => value($$(value) + 1) const decrement = () => value($$(value) - 1) return ( <div> <h2>{title}</h2> <p>Count: {value}</p> <button onClick={increment}>+</button> <button onClick={decrement}>-</button> </div> ) }) // Register as a custom element customElement('simple-counter', Counter) ``` ### Key Points: 1. **def() function**: Defines default props with proper type information 2. **defaults() wrapper**: Enables two-way synchronization 3. **Type annotations**: `{ type: 'number' }` ensures proper conversion 4. **customElement() registration**: Makes the component available as HTML element ## The Defaults Pattern Explained The `defaults` pattern is crucial for custom elements. It's not just about providing default values—it's about enabling synchronization between HTML attributes and component props. ### Why Use Defaults? ```typescript // ❌ Without defaults - no synchronization const BadCounter = (props) => { // Props from HTML won't be converted properly // Changes to props won't update HTML attributes } // ✅ With defaults - full synchronization const GoodCounter = defaults(def, (props) => { // Props from HTML are converted to proper types // Changes to props update HTML attributes }) ``` ### How Defaults Works The `defaults` function: 1. Takes a `def` function that returns default props 2. Takes a component function that receives merged props 3. Handles the merging of HTML attributes with default values 4. Manages two-way synchronization between props and attributes ```typescript // The def function provides the structure and type information function def() { return { // Observable with type information for synchronization count: $(0, { type: 'number' } as const), // Regular string (no synchronization needed) label: 'Counter', // Function hidden from HTML attributes increment: $([() => {}], { toHtml: o => undefined }) } } // The component receives properly merged props const Counter = defaults(def, (props) => { const { count, label, increment } = props // count is an observable number (converted from HTML string if needed) // label is a string // increment is a function array [function, observable] }) ``` ## Handling Functions in Custom Elements Functions present a unique challenge in custom elements because: 1. HTML attributes are always strings 2. Functions cannot be serialized to strings and back 3. Functions need to work in both HTML and JSX contexts ### The Array Notation Solution ```typescript function def() { const value = $(0, { type: 'number' } as const) return { value, // Store function in observable array to hide it from HTML attributes increment: $([() => { value($$(value) + 1) }], { toHtml: o => undefined }) } } const Counter = defaults(def, (props) => { const { value, increment: inc } = props // Access the function from the observable array const increment = $$(inc)[0] return ( <div> <p>Value: {value}</p> <button onClick={increment}>+</button> </div> ) }) ``` ### Why This Pattern? 1. **Array Storage**: `$([function])` stores the function in an observable array 2. **HTML Hiding**: `{ toHtml: o => undefined }` prevents the function from appearing in HTML attributes 3. **Access Pattern**: `$$(observable)[0]` retrieves the function from the array ## Object and Date Serialization Complex objects and dates require special handling for HTML attribute synchronization: ### Object Serialization ```typescript function def() { return { // Object with custom serialization config: $({ theme: 'light', size: 'medium' }, { toHtml: o => JSON.stringify(o), fromHtml: o => JSON.parse(o) }) } } const Component = defaults(def, (props) => { const { config } = props // config is an observable containing the parsed object return ( <div> <p>Theme: {$$(config).theme}</p> <p>Size: {$$(config).size}</p> </div> ) }) ``` ### Date Serialization ```typescript function def() { return { // Date with custom serialization createdAt: $(new Date(), { toHtml: o => o.toISOString(), fromHtml: o => new Date(o) }) } } const Component = defaults(def, (props) => { const { createdAt } = props // createdAt is an observable containing a Date object return ( <div> <p>Created: {$$(createdAt).toString()}</p> </div> ) }) ``` ### Serialization Options Explained - **toHtml**: Converts the JavaScript value to a string for HTML attributes - **fromHtml**: Converts the HTML attribute string back to a JavaScript value - **Both required**: For proper two-way synchronization ## Context Propagation in Custom Elements Custom elements automatically propagate context to child elements, but require special handling: ### Creating Context ```typescript import { createContext, useContext } from 'woby' // Create a context const CounterContext = createContext(0) // Custom hook for using the context const useCounterContext = () => useContext(CounterContext) ``` ### Using Context in Custom Elements ```typescript const ContextValue = defaults(() => ({}), (props) => { // Use useMountedContext for proper context propagation in custom elements const context = useMountedContext(CounterContext) return <span>Context Value: {context}</span> }) customElement('context-value', ContextValue) ``` ### Providing Context ```typescript const Counter = defaults(def, (props) => { const { value, title } = props return ( <div> <h2>{title}</h2> <p>Value: {value}</p> {/* Provide context to children */} <CounterContext.Provider value={value}> {props.children} </CounterContext.Provider> </div> ) }) ``` ### Context Propagation in HTML ```html <!-- Parent counter provides context --> <counter-element value="5"> <!-- Child can access parent's context --> <context-value></context-value> <!-- Nested counter can also access context --> <counter-element> <context-value></context-value> </counter-element> </counter-element> ``` ## Nested Properties Deep Dive Nested properties allow you to organize related props in a structured way: ### Defining Nested Properties ```typescript function def() { return { value: $(0, { type: 'number' } as const), // Nested configuration object config: { theme: $('light'), limits: { min: $(-10, { type: 'number' } as const), max: $(100, { type: 'number' } as const) } } } } ``` ### HTML Usage Patterns In HTML, nested properties can be accessed using two notations: ```html <!-- Dot notation --> <counter-element config.theme="dark" config.limits.min="-50" config.limits.max="200"> </counter-element> <!-- Alternative notation (also works) --> <counter-element config-theme="dark" config-limits-min="-50" config-limits-max="200"> </counter-element> ``` ### JSX Usage Patterns In JSX, nested properties use the dash notation: ```tsx <counter-element config-theme="dark" config-limits-min={-50} config-limits-max={200}> </counter-element> ``` ### Component Implementation ```typescript const Counter = defaults(def, (props) => { const { value, config } = props return ( <div className={`counter counter-${$$($$(config)?.theme)}`}> <p>Value: {value}</p> <p>Min: {$$($$($$(config)?.limits)?.min)}</p> <p>Max: {$$($$($$(config)?.limits)?.max)}</p> </div> ) }) ``` ## Style Properties Handling Style properties have special handling for both HTML and JSX usage: ### HTML Style Properties ```html <!-- Dot notation for styles --> <counter-element style.color="red" style.font-size="1.5em" style.background-color="lightblue"> </counter-element> <!-- Alternative notation --> <counter-element style$color="red" style$font-size="1.5em" style$background-color="lightblue"> </counter-element> ``` ### JSX Style Properties ```tsx <counter-element style$color="red" style$font-size="1.5em" style$background-color="lightblue"> </counter-element> ``` ### Component Implementation ```typescript const Counter = defaults(def, (props) => { const { ...restProps } = props return ( <div {...restProps} style={{ border: '1px solid red' }}> {/* Content */} </div> ) }) ``` ## HTML vs JSX Usage Patterns Custom elements work differently in HTML versus JSX contexts: ### HTML Usage (String Attributes) ```html <!-- All attributes are strings in HTML --> <counter-element value="5" disabled="true" obj='{"nested":{"text":"abc"}}' date="2023-01-01T00:00:00.000Z"> </counter-element> ``` ### JSX Usage (JavaScript Values) ```tsx // In JSX, you can pass JavaScript values directly <counter-element value={$(5, { type: 'number' as const })} disabled={$(true, { type: 'boolean' as const })} 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) })}> </counter-element> ``` ### Key Differences | Aspect | HTML Usage | JSX Usage | |--------|------------|-----------| | Attribute Types | Always strings | JavaScript values | | Type Conversion | Automatic | Direct usage | | Function Props | Not supported | Supported | | Complex Objects | Requires serialization | Direct observables | ## TypeScript Integration Proper TypeScript integration ensures type safety and better developer experience: ### Extending JSX Namespace ```typescript // Extend JSX namespace for TypeScript support declare module 'woby' { namespace JSX { interface IntrinsicElements { 'counter-element': ElementAttributes<typeof Counter> 'context-value': ElementAttributes<typeof ContextValue> } } } ``` ### Type Definitions ```typescript import type { ObservableReadonly } from 'soby' interface CounterProps { value?: ObservableReadonly<number> title?: ObservableReadonly<string> disabled?: ObservableReadonly<boolean> obj?: ObservableReadonly<{ nested: { text: string } }> date?: ObservableReadonly<Date> increment?: ObservableReadonly<readonly [() => void]> } ``` ### Using Types in Components ```typescript const Counter = defaults(def, (props: CounterProps) => { const { value, title, disabled, obj, date, increment: inc } = props // TypeScript provides proper type checking for all props }) ``` ## Best Practices and Common Pitfalls ### Best Practices 1. **Always use `defaults`** for custom elements 2. **Provide type information** for non-string observables 3. **Hide functions from HTML** using `toHtml: o => undefined` 4. **Use proper serialization** for complex objects and dates 5. **Extend JSX namespace** for TypeScript support 6. **Use `useMountedContext`** for context in custom elements 7. **Organize props with nested objects** for better structure 8. **Test both HTML and JSX usage** to ensure compatibility ### Common Pitfalls 1. **Not using `defaults`** - Breaks synchronization 2. **Missing type information** - Causes incorrect type conversion 3. **Functions in HTML attributes** - Won't work as expected 4. **Complex objects without serialization** - Won't sync properly 5. **Regular `useContext` instead of `useMountedContext`** - Context won't propagate 6. **Inline parameter defaults** - Can conflict with `def()` pattern ### Pitfall Examples ```typescript // ❌ Missing type information function def() { return { value: $(0) // Will be treated as string } } // ✅ With type information function def() { return { value: $(0, { type: 'number' } as const) // Will be treated as number } } // ❌ Functions in HTML attributes function def() { return { increment: $([() => {}]) // Will appear as "[object Object]" in HTML } } // ✅ Functions hidden from HTML function def() { return { increment: $([() => {}], { toHtml: o => undefined }) // Hidden from HTML } } ``` ## Complete Example: Advanced Counter Here's a complete example that demonstrates all the concepts: ```typescript import { $, $$, useMemo, customElement, createContext, useContext, defaults, useMountedContext, type ElementAttributes } from 'woby' import type { ObservableReadonly } from 'soby' // Create context const CounterContext = createContext(0) // Define defaults function def() { const value = $(0, { type: 'number' } as const) return { value, title: $('Counter'), disabled: $(false, { type: 'boolean' } as const), increment: $([() => { value($$(value) + 1) }], { toHtml: o => undefined }), config: { theme: $('light'), step: $(1, { type: 'number' } as const) }, metadata: $({ createdAt: new Date() }, { toHtml: o => JSON.stringify(o), fromHtml: o => JSON.parse(o) }) } } // Create component const Counter = defaults(def, (props) => { const { value, title, disabled, increment: inc, config, metadata } = props const increment = $$(inc)[0] const decrement = () => value($$(value) - $$(config.step)) const displayValue = useMemo(() => { return `${$$($$(config)?.theme)}: ${$$(value)}` }) return ( <div className={`counter counter-${$$($$(config)?.theme)}`}> <h2>{title}</h2> <p>Value: {displayValue}</p> <p>Created: {$$(metadata).createdAt.toString()}</p> <p>Context: {useMountedContext(CounterContext)}</p> <button disabled={disabled} onClick={increment}>+</button> <button disabled={disabled} onClick={decrement}>-</button> <CounterContext.Provider value={value}> {props.children} </CounterContext.Provider> </div> ) }) // Register custom element customElement('advanced-counter', Counter) // Extend JSX namespace declare module 'woby' { namespace JSX { interface IntrinsicElements { 'advanced-counter': ElementAttributes<typeof Counter> } } } ``` This comprehensive guide covers all the essential aspects of creating custom elements in Woby, from basic concepts to advanced patterns, with detailed explanations and practical examples.