woby
Version:
A high-performance framework with fine-grained observable/signal-based reactivity for building rich applications.
340 lines (273 loc) • 11.3 kB
Markdown
# Counter Demo: Complete Guide to Custom Elements in Woby
This document provides a comprehensive walkthrough of creating custom elements in Woby using the Counter demo as a practical example. It covers all aspects of custom element creation, from basic setup to advanced features like context propagation and serialization.
## Overview
The Counter demo showcases several important concepts:
1. Custom element creation with proper defaults
2. HTML attribute serialization using `toHtml` and `fromHtml` options
3. Function storage in observables using array notation
4. Object and Date serialization
5. Context usage in custom elements
6. Differences between HTML and JSX usage of custom elements
## Complete Example Breakdown
### Component Definition
```typescript
import { $, $$, useMemo, render, customElement, isObservable, createContext, useContext, useEffect, defaults, SYMBOL_DEFAULT, useMountedContext, type ElementAttributes } from 'woby'
import type { ObservableReadonly } from 'soby'
// Create a context for sharing values between parent and child elements
const CounterContext = createContext(null)
const useCounterContext = () => useContext(CounterContext)
// Define default props using the def function pattern
const def = () => {
const value = $(0, { type: 'number' } as const)
return {
title: $('Counter'),
// Store function in observable array to hide it from HTML attributes
increment: $([() => { value($$(value) + 10) }], { toHtml: o => undefined }), //hide this from html attributes
value,
nested: { nested: { text: $('abc') } },
// Object with custom serialization
obj: $({ nested: { text: 'abc' } }, { toHtml: o => JSON.stringify(o), fromHtml: o => JSON.parse(o) }),
// Date with custom serialization
date: $(new Date(), { toHtml: o => o.toISOString(), fromHtml: o => new Date(o) }),
disabled: $(false, { type: 'boolean' } as const),
children: undefined
}
}
// Create the component using defaults for proper two-way synchronization
const Counter = defaults(def, (props) => {
const {
title,
increment: inc,
// decrement,
value,
nested,
disabled,
obj,
date,
children,
...restProps
} = props
// Use mounted context for proper context propagation in custom elements
const context = useMountedContext(CounterContext)
// Access the function from the observable array
const increment = $$(inc)[0] ?? (() => { value($$(value) + 1) })
const decrement = () => { value($$(value) - 1) }
const v = useMemo(() => $$($$($$(nested)?.nested)?.text))
const m = useMemo(() => {
return $$(value) + '' + $$(v)
})
return (
<div {...restProps} style={{ border: '1px solid red' }}>
<h1>{title}</h1>
<p>Value: <b>{value}</b></p>
<p>Memo: <b>{m}</b></p>
<p>Parent Context (TSX): <b>{context}</b></p>
<p>Object: {() => JSON.stringify($$(obj))}</p>
<p>Date: {() => $$(date).toString()}</p>
<button disabled={disabled} onClick={increment}>+</button>
<button disabled={disabled} onClick={decrement}>-</button>
{() => $$(children) ?
<div style={{ border: '1px solid gray', padding: '10px' }}>
<CounterContext.Provider value={value}>
{children}
</CounterContext.Provider>
</div>
: null}
<p>------------{title} compoent end-------------</p>
</div>
)
})
```
### Context Value Component
```typescript
// Simple context value display component
const ContextValue = defaults(() => ({}), (props) => {
const context = useMountedContext(CounterContext) //direct use
return <span >(Context Value = <b>{context}</b>)</span>
})
// Processed context value component
const ProcessedContextValue = defaults(() => ({}), (props) => {
const [context, m] = useMountedContext(CounterContext)
return <span >(Pcocessed Context Value = <b>{useMemo(() => $$($$(context)) + ' Processed')}</b>){m}</span>
})
```
### Custom Element Registration
```typescript
// Register components as custom elements
customElement('counter-element', Counter)
customElement('context-value', ContextValue)
customElement('my-上下문-값', ContextValue)
customElement('processed-context-value', ProcessedContextValue)
```
### JSX Namespace Extension
```typescript
// Extend JSX namespace to include custom elements for TypeScript support
declare module 'woby' {
namespace JSX {
interface IntrinsicElements {
'counter-element': ElementAttributes<typeof Counter>
'context-value': ElementAttributes<typeof ContextValue>
'my-上下문-값': ElementAttributes<typeof ContextValue>
'processed-context-value': ElementAttributes<typeof ProcessedContextValue>
}
}
}
```
## HTML Usage
Custom elements can be used directly in HTML with automatic attribute synchronization:
```html
<html>
<head>
<meta charset="UTF-8">
<title>Counter</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./index.tsx"></script>
<h1>HTML-created Custom Elements with Context</h1>
<!-- Parent counter element with a value -->
<counter-element title="Parent Counter" value="5" style.color="blue" style.font-size="1.5em"
class="border-2 border-blue-500 border-solid bg-amber-400 p-4 m-2">
<p>Parent Counter Content</p>
<p>Parent Context Value: <context-value /></p>
<p>Parent my-上下문-값: <my-上下문-값 /></p>
Child counter element that should inherit context
<counter-element title="Child Counter" value="10" style$color="green" style$font-size="1em"
obj='{"name": "John", "age": 30}' class="border-2 border-green-500 border-solid bg-yellow-200 p-2 m-2">
<p>Child Context Value: <context-value /></p>
<p>Child my-上下문-값: <my-上下문-값 /></p>
</counter-element>
</counter-element>
</body>
</html>
```
## JSX/TSX Usage
Custom elements can also be used in JSX/TSX with full TypeScript support:
```typescript
const App = () => {
const value = $(0)
const increment = () => value(prev => prev + 1)
const decrement = () => value(prev => prev - 1)
return <>
<h1>Custom element<br /></h1>
<h1><counter-element> - <counter-element>:<br /></h1>
<counter-element title={'Custom element in TSX'}
style$color={'red'}
style$font-size='1.1em'
nested$nested$text='xyz'
nested={{ nested: { text: $('abc') } }}
obj={$({ nested: { text: 'this obj will be serialized and deserialized to html attribute' } }, { toHtml: obj => JSON.stringify(obj), fromHtml: obj => JSON.parse(obj) })}
class={'border-2 border-black border-solid bg-amber-400'}>
<context-value />
<ContextValue />
<ProcessedContextValue />
<processed-context-value />
<h2>Nested Custom <counter-element>:<br /></h2>
<counter-element title={'counter-element Nested'}
style$color={'orange'}
style$font-size='1em'
nested$nested$text=' nested context'
nested={{ nested: { text: $(' nested context') } }}
class={'border-2 border-black border-solid bg-amber-400 m-10'}>
<context-value />
<ContextValue />
</counter-element>
</counter-element>
</>
}
```
## Key Concepts Explained
### 1. Defaults Pattern
The `defaults` function is essential for custom elements because it:
- Enables two-way synchronization between HTML attributes and component props
- Provides default values for all props
- Handles proper merging of props from different sources
```typescript
const Counter = defaults(def, (props) => {
// Component implementation
})
```
### 2. Function Storage in Observables
Functions cannot be directly stored in observables for HTML usage. Instead, they are stored in arrays:
```typescript
increment: $([() => { value($$(value) + 10) }], { toHtml: o => undefined })
```
The `toHtml: o => undefined` option prevents the function from appearing as an HTML attribute.
### 3. Object and Date Serialization
Complex objects and dates require custom serialization:
```typescript
// Object serialization
obj: $({ nested: { text: 'abc' } }, {
toHtml: o => JSON.stringify(o),
fromHtml: o => JSON.parse(o)
})
// Date serialization
date: $(new Date(), {
toHtml: o => o.toISOString(),
fromHtml: o => new Date(o)
})
```
### 4. Context Propagation
Custom elements automatically propagate context to child elements:
```typescript
const context = useMountedContext(CounterContext)
```
### 5. Nested Properties
Nested properties can be accessed using dot notation in HTML and dash notation in JSX:
```html
<!-- HTML usage -->
<counter-element nested.nested.text="value"></counter-element>
```
```tsx
// JSX usage
<counter-element nested-nested-text="value"></counter-element>
```
### 6. Style Properties
Style properties can be set using dot notation in HTML and dash notation in JSX:
```html
<!-- HTML usage -->
<counter-element style.color="red" style.font-size="1.5em"></counter-element>
```
```tsx
// JSX usage
<counter-element style$color="red" style$font-size="1.5em"></counter-element>
```
## Best Practices
1. **Always use `defaults`** for custom elements to enable proper synchronization
2. **Store functions in arrays** with `toHtml: o => undefined` to hide them from HTML
3. **Use `useMountedContext`** for context in custom elements
4. **Provide custom serialization** for complex objects and dates
5. **Extend JSX namespace** for TypeScript support
6. **Use proper type annotations** for all props
## Common Patterns
### Simple Counter Component
```typescript
function def() {
const value = $(0, { type: 'number' } as const)
return {
value,
increment: $([() => { value($$(value) + 1) }], { toHtml: o => undefined })
}
}
const SimpleCounter = defaults(def, (props) => {
const { value, increment } = props
return (
<div>
<span>{value}</span>
<button onClick={() => increment[0]()}>+</button>
</div>
)
})
customElement('simple-counter', SimpleCounter)
```
### Component with Context
```typescript
const MyContext = createContext(null)
const ContextDisplay = defaults(() => ({}), (props) => {
const context = useMountedContext(MyContext)
return <span>Context Value: {context}</span>
})
customElement('context-display', ContextDisplay)
```
This comprehensive guide demonstrates how to create powerful, flexible custom elements in Woby that work seamlessly in both HTML and JSX contexts.