@dooboostore/simple-web-component 1.0.9 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +946 -63
- package/dist/cjs/decorators/elementDefine.js +47 -20
- package/dist/cjs/decorators/elementDefine.js.map +2 -2
- package/dist/cjs/decorators/lifecycles.js +27 -1
- package/dist/cjs/decorators/lifecycles.js.map +2 -2
- package/dist/cjs/index.js +45 -0
- package/dist/cjs/index.js.map +2 -2
- package/dist/esm/decorators/elementDefine.js +48 -21
- package/dist/esm/decorators/elementDefine.js.map +2 -2
- package/dist/esm/decorators/lifecycles.js +27 -1
- package/dist/esm/decorators/lifecycles.js.map +2 -2
- package/dist/esm/index.js +41 -0
- package/dist/esm/index.js.map +2 -2
- package/dist/esm-bundle/dooboostore-simple-web-component.esm.js +5618 -22
- package/dist/esm-bundle/dooboostore-simple-web-component.esm.js.map +4 -4
- package/dist/types/decorators/elementDefine.d.ts +1 -0
- package/dist/types/decorators/elementDefine.d.ts.map +1 -1
- package/dist/types/decorators/lifecycles.d.ts +26 -0
- package/dist/types/decorators/lifecycles.d.ts.map +1 -1
- package/dist/types/index.d.ts +18 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/umd-bundle/dooboostore-simple-web-component.umd.js +5618 -22
- package/dist/umd-bundle/dooboostore-simple-web-component.umd.js.map +4 -4
- package/package.json +3 -2
- package/src/decorators/elementDefine.ts +87 -87
- package/src/decorators/lifecycles.ts +61 -0
- package/src/index.ts +57 -0
package/README.md
CHANGED
|
@@ -1,124 +1,1007 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dooboostore/simple-web-component
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
A lightweight UI library built on top of the native Web Components API with a **TypeScript decorator-based reactive system**.
|
|
4
|
+
Updates only the specific text nodes and attributes bound to data — no Virtual DOM, no diffing.
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
##
|
|
8
|
+
## Installation
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add @dooboostore/simple-web-component reflect-metadata
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Add this at the very top of your entry point:
|
|
15
|
+
|
|
16
|
+
```typescript
|
|
17
|
+
import 'reflect-metadata';
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
> **Safari Support** — The `is` attribute requires a polyfill:
|
|
21
|
+
> ```bash
|
|
22
|
+
> pnpm add @ungap/custom-elements
|
|
23
|
+
> ```
|
|
24
|
+
> ```typescript
|
|
25
|
+
> import '@ungap/custom-elements';
|
|
26
|
+
> import 'reflect-metadata';
|
|
27
|
+
> ```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quick Start
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { elementDefine, innerHtml, state, addEventListener } from '@dooboostore/simple-web-component';
|
|
35
|
+
|
|
36
|
+
@elementDefine('my-counter')
|
|
37
|
+
class MyCounter extends HTMLElement {
|
|
38
|
+
@state count = 0;
|
|
39
|
+
|
|
40
|
+
@innerHtml({ useShadow: true })
|
|
41
|
+
render() {
|
|
42
|
+
return `<button id="btn">Clicked: {{count}} times</button>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@addEventListener({ type: 'click', query: '#btn' })
|
|
46
|
+
onClick() {
|
|
47
|
+
this.count++;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
```html
|
|
53
|
+
<my-counter></my-counter>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
---
|
|
11
57
|
|
|
12
|
-
|
|
13
|
-
Forget full-tree diffing. SWC uses a **Proxy-based reactivity engine** that maps data changes directly to specific **Text Nodes** or **Attributes**. When a property changes, only that tiny piece of the DOM updates. It's not just fast; it's surgically precise.
|
|
58
|
+
## Decorator API
|
|
14
59
|
|
|
15
|
-
###
|
|
16
|
-
SWC is built on **Customized Built-in Elements**. Use the native `is` attribute to supercharge standard tags like `<ul>`, `<table>`, or `<template>`. Your HTML remains semantic, accessible, and SEO-friendly.
|
|
60
|
+
### `@elementDefine(config)`
|
|
17
61
|
|
|
18
|
-
|
|
19
|
-
|
|
62
|
+
Registers a Custom Element.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// Tag name only
|
|
66
|
+
@elementDefine('my-component')
|
|
67
|
+
class MyComponent extends HTMLElement {}
|
|
68
|
+
|
|
69
|
+
// With options
|
|
70
|
+
@elementDefine({
|
|
71
|
+
name: 'my-component',
|
|
72
|
+
extends: 'div', // Extend a built-in tag (used with `is` attribute)
|
|
73
|
+
observedAttributes: ['data-id'], // Additional observed attributes
|
|
74
|
+
autoRemoveEventListeners: true, // Auto-remove listeners on disconnect (default: true)
|
|
75
|
+
window: w, // Custom window injection (SSR, multi-window)
|
|
76
|
+
})
|
|
77
|
+
class MyComponent extends HTMLElement {}
|
|
78
|
+
```
|
|
20
79
|
|
|
21
80
|
---
|
|
22
81
|
|
|
23
|
-
|
|
82
|
+
### `@innerHtml(options?)`
|
|
24
83
|
|
|
25
|
-
|
|
84
|
+
Defines the component template. Use `{{varName}}` syntax for data binding.
|
|
26
85
|
|
|
27
86
|
```typescript
|
|
28
|
-
@elementDefine('
|
|
29
|
-
class
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
87
|
+
@elementDefine('my-card')
|
|
88
|
+
class MyCard extends HTMLElement {
|
|
89
|
+
@state title = 'Hello';
|
|
90
|
+
@state count = 0;
|
|
91
|
+
|
|
92
|
+
// Light DOM (default)
|
|
93
|
+
@innerHtml
|
|
94
|
+
render() {
|
|
95
|
+
return `<h1>{{title}}</h1><p>Count: {{count}}</p>`;
|
|
35
96
|
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// Shadow DOM
|
|
102
|
+
@innerHtml({ useShadow: true })
|
|
103
|
+
render() {
|
|
104
|
+
return `
|
|
105
|
+
<style>h1 { color: blue; }</style>
|
|
106
|
+
<h1>{{title}}</h1>
|
|
107
|
+
`;
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// Shadow DOM + Light DOM simultaneously
|
|
113
|
+
@innerHtml({ useShadow: true })
|
|
114
|
+
shadowContent() {
|
|
115
|
+
return `<style>:host { display: block; }</style><slot></slot>`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@innerHtml
|
|
119
|
+
lightContent() {
|
|
120
|
+
return `<p>Light DOM: {{name}}</p>`;
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Async templates are supported:
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
@innerHtml
|
|
128
|
+
async render() {
|
|
129
|
+
const data = await fetch('/api/data').then(r => r.json());
|
|
130
|
+
return `<p>${data.message}</p>`;
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
### `@state(alias?)`
|
|
137
|
+
|
|
138
|
+
Declares a reactive property. When the value changes, only the bound DOM nodes are surgically updated.
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
@elementDefine('user-card')
|
|
142
|
+
class UserCard extends HTMLElement {
|
|
143
|
+
@state name = 'Kim';
|
|
144
|
+
@state age = 30;
|
|
145
|
+
@state('userName') displayName = 'Kim'; // Access as {{userName}} in template
|
|
146
|
+
|
|
147
|
+
@innerHtml
|
|
148
|
+
render() {
|
|
149
|
+
return `
|
|
150
|
+
<p>Name: {{name}}</p>
|
|
151
|
+
<p>Age: {{age}}</p>
|
|
152
|
+
<p>Display: {{userName}}</p>
|
|
153
|
+
`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Objects and arrays are also reactive:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
@state user = { name: 'Kim', age: 30 };
|
|
162
|
+
@state items = ['a', 'b', 'c'];
|
|
163
|
+
|
|
164
|
+
// Nested property changes are detected
|
|
165
|
+
this.user.name = 'Lee'; // DOM auto-updates
|
|
166
|
+
this.items.push('d'); // DOM auto-updates
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
### `@attribute(options?)`
|
|
172
|
+
|
|
173
|
+
Syncs an HTML attribute with a JS property bidirectionally.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
@elementDefine('my-input')
|
|
177
|
+
class MyInput extends HTMLElement {
|
|
178
|
+
@attribute() label = ''; // attribute name: label
|
|
179
|
+
@attribute('data-count') count = 0; // attribute name: data-count
|
|
180
|
+
@attribute({ type: Number }) size = 10; // type conversion
|
|
181
|
+
@attribute({ type: Boolean }) disabled = false;
|
|
182
|
+
@attribute({ type: Object }) config = {};
|
|
183
|
+
@attribute({ disableReflect: true }) secret = ''; // JS→HTML reflection disabled
|
|
184
|
+
|
|
185
|
+
@innerHtml
|
|
186
|
+
render() {
|
|
187
|
+
return `<label>{{label}}: {{count}}</label>`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
```html
|
|
193
|
+
<my-input label="Name" data-count="5" disabled></my-input>
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Combine with `@state` for reactive attribute handling:
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
@state @attribute({ type: Number }) count = 0;
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
### `@query(selector?)` / `@queryAll(selector?)`
|
|
205
|
+
|
|
206
|
+
Lazily binds DOM elements.
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
@elementDefine('form-component')
|
|
210
|
+
class FormComponent extends HTMLElement {
|
|
211
|
+
// Property getter
|
|
212
|
+
@query('input[name="email"]') emailInput!: HTMLInputElement;
|
|
213
|
+
@queryAll('li') listItems!: NodeListOf<HTMLLIElement>;
|
|
214
|
+
|
|
215
|
+
// Method — called once after connectedCallback
|
|
216
|
+
@query('input')
|
|
217
|
+
onInputReady(el: HTMLInputElement) {
|
|
218
|
+
el.focus();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Search in Shadow DOM
|
|
222
|
+
@query({ selector: 'p', root: 'shadow' }) shadowP!: HTMLElement;
|
|
223
|
+
|
|
224
|
+
// Search in Light DOM
|
|
225
|
+
@query({ selector: 'slot', root: 'light' }) slotEl!: HTMLSlotElement;
|
|
226
|
+
|
|
227
|
+
@innerHtml({ useShadow: true })
|
|
228
|
+
render() {
|
|
229
|
+
return `<input name="email" type="email" /><p>Status</p>`;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
### `@addEventListener(options)`
|
|
237
|
+
|
|
238
|
+
Declaratively registers event listeners. Auto-removed on disconnect when `autoRemoveEventListeners: true` (default).
|
|
239
|
+
|
|
240
|
+
```typescript
|
|
241
|
+
@elementDefine('event-demo')
|
|
242
|
+
class EventDemo extends HTMLElement {
|
|
243
|
+
@state value = '';
|
|
36
244
|
|
|
37
245
|
@innerHtml({ useShadow: true })
|
|
38
246
|
render() {
|
|
39
247
|
return `
|
|
40
|
-
<
|
|
41
|
-
<
|
|
248
|
+
<input id="input" type="text" />
|
|
249
|
+
<button id="submit-btn">Submit</button>
|
|
42
250
|
`;
|
|
43
251
|
}
|
|
252
|
+
|
|
253
|
+
@addEventListener({ type: 'input', query: '#input' })
|
|
254
|
+
onInput(event: InputEvent) {
|
|
255
|
+
this.value = (event.target as HTMLInputElement).value;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
@addEventListener({
|
|
259
|
+
type: 'click',
|
|
260
|
+
query: '#submit-btn',
|
|
261
|
+
stopPropagation: true,
|
|
262
|
+
preventDefault: true,
|
|
263
|
+
})
|
|
264
|
+
onSubmit() {
|
|
265
|
+
console.log('Submitted:', this.value);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Host element event (no query)
|
|
269
|
+
@addEventListener({ type: 'click' })
|
|
270
|
+
onHostClick(event: MouseEvent) {
|
|
271
|
+
console.log('Component clicked');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
### `@emitCustomEvent(options)`
|
|
279
|
+
|
|
280
|
+
Automatically dispatches a `CustomEvent` with the method's return value as `detail`.
|
|
281
|
+
|
|
282
|
+
```typescript
|
|
283
|
+
@elementDefine('data-emitter')
|
|
284
|
+
class DataEmitter extends HTMLElement {
|
|
285
|
+
@emitCustomEvent('data-change')
|
|
286
|
+
updateData() {
|
|
287
|
+
return { value: 42, timestamp: Date.now() };
|
|
288
|
+
// → CustomEvent('data-change', { detail: { value: 42, ... } })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@emitCustomEvent({ type: 'user-select', bubbles: true, composed: true })
|
|
292
|
+
selectUser(id: number) {
|
|
293
|
+
return { id, name: 'Kim' };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
// Listen from parent
|
|
300
|
+
document.querySelector('data-emitter')?.addEventListener('data-change', (e: CustomEvent) => {
|
|
301
|
+
console.log(e.detail); // { value: 42, timestamp: ... }
|
|
302
|
+
});
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## Lifecycle Hooks
|
|
308
|
+
|
|
309
|
+
### Decorator Hooks
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
@elementDefine('lifecycle-demo')
|
|
313
|
+
class LifecycleDemo extends HTMLElement {
|
|
314
|
+
@onBeforeConnected
|
|
315
|
+
beforeConnect() { console.log('Before template injection'); }
|
|
316
|
+
|
|
317
|
+
@onAfterConnected // alias: @onConnected
|
|
318
|
+
afterConnect() { console.log('After template injection and binding'); }
|
|
319
|
+
|
|
320
|
+
@onBeforeDisconnected
|
|
321
|
+
beforeDisconnect() { console.log('Before removal from DOM'); }
|
|
322
|
+
|
|
323
|
+
@onAfterDisconnected // alias: @onDisconnected
|
|
324
|
+
afterDisconnect() { console.log('After removal and cleanup'); }
|
|
325
|
+
|
|
326
|
+
// Single attribute
|
|
327
|
+
@onAttributeChanged('name')
|
|
328
|
+
onNameChange(newVal: string, oldVal: string, attrName: string) {
|
|
329
|
+
console.log(`name: ${oldVal} → ${newVal}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// MutationSubscriber
|
|
333
|
+
@onMutationSubscribe({root: 'all'})
|
|
334
|
+
onMutationSubscribe(mutations: MutationRecord[], observer: MutationObserver, root: 'light' | 'shadow') {
|
|
335
|
+
console.log('mutations', mutations, observer, root);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Wildcard: any attribute change
|
|
339
|
+
@onAttributeChanged
|
|
340
|
+
onAnyChange(newVal: string, oldVal: string, attrName: string) {
|
|
341
|
+
console.log(`${attrName} changed`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Multiple attributes
|
|
345
|
+
@onAttributeChanged(['name', 'age'])
|
|
346
|
+
onNameOrAgeChange(newVal: string, oldVal: string, attrName: string) {
|
|
347
|
+
console.log(`${attrName} changed`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
@onAddEventListener
|
|
351
|
+
onListenerAdded(el: Element, type: string, handler: EventListener) {
|
|
352
|
+
console.log(`Event registered: ${type}`);
|
|
353
|
+
}
|
|
44
354
|
}
|
|
45
355
|
```
|
|
46
356
|
|
|
357
|
+
### HTML Attribute Hooks (Zero-JS)
|
|
358
|
+
|
|
359
|
+
Execute scripts directly in HTML without any JS class definition.
|
|
360
|
+
|
|
361
|
+
```html
|
|
362
|
+
<my-component
|
|
363
|
+
swc-on-constructor="console.log('created', this)"
|
|
364
|
+
swc-on-connected="console.log('connected', $host)"
|
|
365
|
+
swc-on-disconnected="console.log('removed')"
|
|
366
|
+
swc-on-attribute-changed="console.log($name, $old, '→', $new)"
|
|
367
|
+
></my-component>
|
|
368
|
+
```
|
|
369
|
+
|
|
47
370
|
---
|
|
48
371
|
|
|
49
|
-
##
|
|
372
|
+
## Template Binding & Expressions
|
|
50
373
|
|
|
51
|
-
|
|
374
|
+
```typescript
|
|
375
|
+
@state user = { name: 'Kim', age: 30 };
|
|
376
|
+
@state items = ['apple', 'banana'];
|
|
377
|
+
@state isActive = true;
|
|
378
|
+
@state count = 0;
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
```html
|
|
382
|
+
<!-- Simple value -->
|
|
383
|
+
<p>{{name}}</p>
|
|
52
384
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
385
|
+
<!-- Object path -->
|
|
386
|
+
<p>{{user.name}} (age: {{user.age}})</p>
|
|
387
|
+
|
|
388
|
+
<!-- Expressions -->
|
|
389
|
+
<p>Item #{{count + 1}}</p>
|
|
390
|
+
<p class="{{isActive ? 'active' : 'inactive'}}">Status</p>
|
|
391
|
+
<p>{{items.length}} items</p>
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
---
|
|
395
|
+
|
|
396
|
+
## Inline Event Handler Variables
|
|
397
|
+
|
|
398
|
+
Available variables in all `swc-on-*` attribute scripts:
|
|
399
|
+
|
|
400
|
+
| Variable | Description |
|
|
401
|
+
|---|---|
|
|
402
|
+
| `this` | The DOM element where the event fired |
|
|
403
|
+
| `event` | The event object |
|
|
404
|
+
| `$data` | `event.detail` (CustomEvent) |
|
|
405
|
+
| `$host` / `$lastHost` | Nearest SWC parent component |
|
|
406
|
+
| `$firstHost` | Top-most SWC ancestor component |
|
|
407
|
+
| `$hosts` | All SWC ancestors array `[root, ..., parent]` |
|
|
408
|
+
| `$q(sel, root?)` | `querySelector` shorthand |
|
|
409
|
+
| `$qa(sel, root?)` | `querySelectorAll` → Array |
|
|
410
|
+
| `$qi(id, root?)` | `getElementById` shorthand |
|
|
411
|
+
| `$d` | `document` |
|
|
412
|
+
| `$w` | `window` |
|
|
57
413
|
|
|
58
414
|
```html
|
|
59
|
-
<!--
|
|
60
|
-
<button swc-on-click="$host.
|
|
61
|
-
|
|
62
|
-
|
|
415
|
+
<!-- Call host component method -->
|
|
416
|
+
<button swc-on-click="$host.save();">Save</button>
|
|
417
|
+
|
|
418
|
+
<!-- Access top-most component in nested structure -->
|
|
419
|
+
<button swc-on-click="$firstHost.logout();">Logout</button>
|
|
420
|
+
|
|
421
|
+
<!-- DOM query shorthands -->
|
|
422
|
+
<button swc-on-click="$qi('modal')?.show();">Open Modal</button>
|
|
423
|
+
<button swc-on-click="$q('.panel', $host.shadowRoot)?.classList.toggle('open');">Panel</button>
|
|
424
|
+
|
|
425
|
+
<!-- Event object -->
|
|
426
|
+
<input swc-on-input="$host.name = event.target.value" />
|
|
427
|
+
|
|
428
|
+
<!-- Run once on connect -->
|
|
429
|
+
<div swc-on-connected="console.log('connected', $host, $firstHost)"></div>
|
|
63
430
|
```
|
|
64
431
|
|
|
65
432
|
---
|
|
66
433
|
|
|
67
|
-
##
|
|
434
|
+
## Built-in Logic Components
|
|
435
|
+
|
|
436
|
+
All standard HTML tags support logic via the `is` attribute.
|
|
437
|
+
Pass data with `swc-value`, item alias with `as`, index alias with `as-index`.
|
|
438
|
+
|
|
439
|
+
### `swc-for-of` — Loop (removes element when array is empty)
|
|
440
|
+
|
|
441
|
+
```html
|
|
442
|
+
<!-- Standalone component -->
|
|
443
|
+
<swc-for-of swc-value="{{users}}" as="u">
|
|
444
|
+
<div>{{u.name}}</div>
|
|
445
|
+
</swc-for-of>
|
|
446
|
+
|
|
447
|
+
<!-- is attribute (keeps semantic HTML) -->
|
|
448
|
+
<ul is="swc-for-of-ul" swc-value="{{users}}" as="u" as-index="i">
|
|
449
|
+
<li>{{i + 1}}. {{u.name}} — {{u.active ? 'Active' : 'Inactive'}}</li>
|
|
450
|
+
</ul>
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
const list = document.querySelector('ul[is="swc-for-of-ul"]') as SwcForOfUl;
|
|
455
|
+
list.swcValue = [{ name: 'Kim', active: true }, { name: 'Lee', active: false }];
|
|
456
|
+
list.addRow({ name: 'Park', active: true }); // add row
|
|
457
|
+
list.removeRow(0); // remove first row
|
|
458
|
+
```
|
|
68
459
|
|
|
69
|
-
|
|
460
|
+
### `swc-for-of-children` — Loop (keeps wrapper element)
|
|
70
461
|
|
|
71
|
-
### 1. Dynamic Lists (`swc-for-of`)
|
|
72
|
-
Supports high-performance `addRow` and `removeRow` for pinpoint list management.
|
|
73
462
|
```html
|
|
74
|
-
|
|
75
|
-
<
|
|
76
|
-
|
|
463
|
+
<ul is="swc-for-of-children-ul" swc-value="{{items}}" as="item" as-index="idx">
|
|
464
|
+
<li class="{{item.done ? 'done' : ''}}">
|
|
465
|
+
{{idx + 1}}. {{item.text}}
|
|
466
|
+
<button swc-on-click="$firstHost.removeItem({{idx}});">Delete</button>
|
|
467
|
+
</li>
|
|
77
468
|
</ul>
|
|
78
469
|
```
|
|
79
470
|
|
|
80
|
-
###
|
|
81
|
-
Choose between physical removal or content swapping.
|
|
82
|
-
- **`swc-if-[tag]`**: Truly removes the element from the DOM (leaving a comment placeholder).
|
|
83
|
-
- **`swc-if-children-[tag]`**: Keeps the element (wrapper) but swaps its internal children.
|
|
471
|
+
### `swc-if` — Conditional rendering (removes from DOM when false)
|
|
84
472
|
|
|
85
473
|
```html
|
|
86
|
-
<
|
|
87
|
-
<p>
|
|
474
|
+
<swc-if swc-value="{{isLoggedIn}}">
|
|
475
|
+
<p>Welcome!</p>
|
|
476
|
+
</swc-if>
|
|
477
|
+
|
|
478
|
+
<div is="swc-if-div" swc-value="{{isVisible}}" style="background: yellow;">
|
|
479
|
+
<b>Conditional content</b>
|
|
88
480
|
</div>
|
|
89
481
|
```
|
|
90
482
|
|
|
91
|
-
|
|
483
|
+
```typescript
|
|
484
|
+
const el = document.querySelector('[is="swc-if-div"]') as SwcIfDiv;
|
|
485
|
+
el.swcValue = true; // insert into DOM
|
|
486
|
+
el.swcValue = false; // remove from DOM
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### `swc-if-children` — Conditional rendering (keeps wrapper, swaps children)
|
|
490
|
+
|
|
491
|
+
```html
|
|
492
|
+
<section is="swc-if-children-section" swc-value="{{showContent}}">
|
|
493
|
+
<p>Shown only when showContent is true.</p>
|
|
494
|
+
</section>
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
### `swc-choose` / `swc-when` / `swc-other` — Switch/Case
|
|
498
|
+
|
|
92
499
|
```html
|
|
500
|
+
<!-- Removes element if no case matches -->
|
|
93
501
|
<div is="swc-choose-div" swc-value="{{status}}">
|
|
94
|
-
<span is="swc-when-span" test="'loading'"
|
|
95
|
-
<span is="swc-when-span" test="'
|
|
96
|
-
<span is="swc-
|
|
502
|
+
<span is="swc-when-span" test="'loading'">⏳ Loading...</span>
|
|
503
|
+
<span is="swc-when-span" test="'error'">❌ Error</span>
|
|
504
|
+
<span is="swc-when-span" test="'success'">✅ Done</span>
|
|
505
|
+
<span is="swc-other-span">⚪ Idle</span>
|
|
506
|
+
</div>
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
const el = document.querySelector('[is="swc-choose-div"]') as SwcChooseDiv;
|
|
511
|
+
el.swcValue = 'loading'; // shows "⏳ Loading..."
|
|
512
|
+
el.swcValue = 'success'; // shows "✅ Done"
|
|
513
|
+
el.swcValue = 'unknown'; // shows "⚪ Idle" (swc-other)
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
### `swc-choose-children` — Switch/Case (keeps wrapper)
|
|
517
|
+
|
|
518
|
+
```html
|
|
519
|
+
<div is="swc-choose-children-div" swc-value="{{tab}}">
|
|
520
|
+
<div is="swc-when-div" test="'home'">Home tab content</div>
|
|
521
|
+
<div is="swc-when-div" test="'profile'">Profile tab content</div>
|
|
522
|
+
<div is="swc-other-div">Default tab content</div>
|
|
523
|
+
</div>
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### `swc-object` — Object binding
|
|
527
|
+
|
|
528
|
+
```html
|
|
529
|
+
<div is="swc-object-div" swc-value="{{user}}" as="u">
|
|
530
|
+
<p>Name: {{u.name}}</p>
|
|
531
|
+
<p>Age: {{u.age}}</p>
|
|
97
532
|
</div>
|
|
98
533
|
```
|
|
99
534
|
|
|
535
|
+
```typescript
|
|
536
|
+
const el = document.querySelector('[is="swc-object-div"]') as SwcObjectDiv;
|
|
537
|
+
el.swcValue = { name: 'Kim', age: 30 };
|
|
538
|
+
el.swcValue.name = 'Lee'; // Proxy reactive — DOM auto-updates
|
|
539
|
+
```
|
|
540
|
+
|
|
100
541
|
---
|
|
101
542
|
|
|
102
|
-
##
|
|
543
|
+
## Hierarchical Context (`$host` System)
|
|
544
|
+
|
|
545
|
+
Access parent components from nested components.
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
@elementDefine('parent-comp')
|
|
549
|
+
class ParentComp extends HTMLElement {
|
|
550
|
+
@state count = 0;
|
|
551
|
+
@state items = [{ name: 'Item 1' }, { name: 'Item 2' }];
|
|
552
|
+
|
|
553
|
+
removeItem(index: number) {
|
|
554
|
+
this.items.splice(index, 1);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
@innerHtml({ useShadow: true })
|
|
558
|
+
render() {
|
|
559
|
+
return `
|
|
560
|
+
<p>Count: {{count}}</p>
|
|
561
|
+
<!-- Call parent method from child event -->
|
|
562
|
+
<button swc-on-click="$host.count++;">Increment</button>
|
|
563
|
+
|
|
564
|
+
<!-- In nested for-of: $host = swc-for-of-children-ul, $firstHost = parent-comp -->
|
|
565
|
+
<ul is="swc-for-of-children-ul" swc-value="{{items}}" as="item" as-index="i">
|
|
566
|
+
<li>
|
|
567
|
+
{{item.name}}
|
|
568
|
+
<button swc-on-click="$firstHost.removeItem({{i}});">Delete</button>
|
|
569
|
+
</li>
|
|
570
|
+
</ul>
|
|
571
|
+
`;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
### Accessing parent `@state` from nested components
|
|
103
577
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
578
|
+
```typescript
|
|
579
|
+
@elementDefine('app-root')
|
|
580
|
+
class AppRoot extends HTMLElement {
|
|
581
|
+
@state currentUser = { name: 'Kim', role: 'admin' };
|
|
582
|
+
@state items = [{ id: 1, text: 'Item 1' }, { id: 2, text: 'Item 2' }];
|
|
583
|
+
|
|
584
|
+
@innerHtml({ useShadow: true })
|
|
585
|
+
render() {
|
|
586
|
+
return `
|
|
587
|
+
<ul is="swc-for-of-children-ul" swc-value="{{items}}" as="item">
|
|
588
|
+
<li>
|
|
589
|
+
{{item.text}}
|
|
590
|
+
<!-- Access parent's currentUser.name directly in child template -->
|
|
591
|
+
<small>(by: {{currentUser.name}})</small>
|
|
592
|
+
</li>
|
|
593
|
+
</ul>
|
|
594
|
+
`;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
```
|
|
112
598
|
|
|
113
599
|
---
|
|
114
600
|
|
|
115
|
-
##
|
|
601
|
+
## Shadow DOM + Slot Pattern
|
|
116
602
|
|
|
117
|
-
```
|
|
118
|
-
|
|
603
|
+
```typescript
|
|
604
|
+
@elementDefine('card-layout')
|
|
605
|
+
class CardLayout extends HTMLElement {
|
|
606
|
+
@state title = '';
|
|
607
|
+
|
|
608
|
+
@innerHtml({ useShadow: true })
|
|
609
|
+
render() {
|
|
610
|
+
return `
|
|
611
|
+
<style>
|
|
612
|
+
:host { display: block; border: 1px solid #ccc; border-radius: 8px; }
|
|
613
|
+
.header { background: #f5f5f5; padding: 12px; font-weight: bold; }
|
|
614
|
+
.body { padding: 16px; }
|
|
615
|
+
</style>
|
|
616
|
+
<div class="header">{{title}}</div>
|
|
617
|
+
<div class="body">
|
|
618
|
+
<slot></slot>
|
|
619
|
+
<slot name="footer"></slot>
|
|
620
|
+
</div>
|
|
621
|
+
`;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
119
624
|
```
|
|
120
625
|
|
|
121
|
-
|
|
626
|
+
```html
|
|
627
|
+
<card-layout title="User Info">
|
|
628
|
+
<p>Name: Kim</p>
|
|
629
|
+
<p>Email: kim@example.com</p>
|
|
630
|
+
<div slot="footer">
|
|
631
|
+
<button>Edit</button>
|
|
632
|
+
</div>
|
|
633
|
+
</card-layout>
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
---
|
|
637
|
+
|
|
638
|
+
## File Separation Pattern
|
|
639
|
+
|
|
640
|
+
When splitting a component into its own file, use `export default (w: Window) => {...}` to inject `window`:
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
// MyComponent.ts
|
|
644
|
+
import { elementDefine, innerHtml, state } from '@dooboostore/simple-web-component';
|
|
645
|
+
|
|
646
|
+
export default (w: Window) => {
|
|
647
|
+
@elementDefine({ name: 'my-component', window: w })
|
|
648
|
+
class MyComponent extends HTMLElement {
|
|
649
|
+
@state name = '';
|
|
650
|
+
|
|
651
|
+
@innerHtml({ useShadow: true })
|
|
652
|
+
render() {
|
|
653
|
+
return `<p>Hello, {{name}}!</p>`;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return MyComponent;
|
|
657
|
+
};
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
```typescript
|
|
661
|
+
// index.ts — inject window at the entry point
|
|
662
|
+
import createMyComponent from './MyComponent';
|
|
663
|
+
|
|
664
|
+
const MyComponent = createMyComponent(window);
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
> For browser-only SPAs, you can omit the `window` option entirely.
|
|
668
|
+
> `@elementDefine('my-component')` works fine — `buildEnv` falls back to the global `window` automatically.
|
|
669
|
+
|
|
670
|
+
---
|
|
671
|
+
|
|
672
|
+
## SPA Routing — Full Working Example
|
|
673
|
+
|
|
674
|
+
This example uses `@dooboostore/simple-boot` for DI and routing.
|
|
675
|
+
Copy and paste the following into a single `index.ts` file.
|
|
676
|
+
|
|
677
|
+
```typescript
|
|
678
|
+
import 'reflect-metadata';
|
|
679
|
+
import {
|
|
680
|
+
elementDefine, innerHtml, state, attribute,
|
|
681
|
+
query, addEventListener, onAfterConnected, Swc
|
|
682
|
+
} from '@dooboostore/simple-web-component';
|
|
683
|
+
import { SimpleApplication } from '@dooboostore/simple-boot/SimpleApplication';
|
|
684
|
+
import { Sim } from '@dooboostore/simple-boot/decorators/SimDecorator';
|
|
685
|
+
import { Router as WebRouter } from '@dooboostore/core-web/routers/Router';
|
|
686
|
+
import { Router } from '@dooboostore/simple-boot/decorators/route/Router';
|
|
687
|
+
import { RouterAction } from '@dooboostore/simple-boot/route/RouterAction';
|
|
688
|
+
import { RoutingDataSet } from '@dooboostore/simple-boot/route/RouterManager';
|
|
689
|
+
|
|
690
|
+
const w = window;
|
|
691
|
+
|
|
692
|
+
// ─── Page: Home ──────────────────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
@Sim
|
|
695
|
+
@elementDefine({ name: 'page-home', window: w })
|
|
696
|
+
class PageHome extends HTMLElement {
|
|
697
|
+
@state count = 0;
|
|
698
|
+
@state todos: { text: string; done: boolean }[] = [
|
|
699
|
+
{ text: 'Learn SWC', done: true },
|
|
700
|
+
{ text: 'Build something', done: false },
|
|
701
|
+
];
|
|
702
|
+
|
|
703
|
+
addTodo() {
|
|
704
|
+
this.todos = [...this.todos, { text: 'New todo ' + Date.now(), done: false }];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
toggleTodo(index: number) {
|
|
708
|
+
this.todos[index].done = !this.todos[index].done;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
@innerHtml({ useShadow: true })
|
|
712
|
+
render() {
|
|
713
|
+
return `
|
|
714
|
+
<style>
|
|
715
|
+
:host { display: block; padding: 20px; }
|
|
716
|
+
.done { text-decoration: line-through; color: #999; }
|
|
717
|
+
button { cursor: pointer; margin: 4px; padding: 6px 12px; }
|
|
718
|
+
li { margin: 6px 0; }
|
|
719
|
+
</style>
|
|
720
|
+
|
|
721
|
+
<h2>🏠 Home Page</h2>
|
|
722
|
+
|
|
723
|
+
<section>
|
|
724
|
+
<h3>Counter: {{count}}</h3>
|
|
725
|
+
<button swc-on-click="$host.count++;">+1</button>
|
|
726
|
+
<button swc-on-click="$host.count--;">-1</button>
|
|
727
|
+
<button swc-on-click="$host.count = 0;">Reset</button>
|
|
728
|
+
</section>
|
|
729
|
+
|
|
730
|
+
<hr/>
|
|
731
|
+
|
|
732
|
+
<section>
|
|
733
|
+
<h3>Todo List ({{todos.length}} items)</h3>
|
|
734
|
+
<button id="add-btn">Add Todo</button>
|
|
735
|
+
<ul is="swc-for-of-children-ul" swc-value="{{todos}}" as="todo" as-index="i">
|
|
736
|
+
<li class="{{todo.done ? 'done' : ''}}">
|
|
737
|
+
<input type="checkbox"
|
|
738
|
+
swc-on-change="$firstHost.toggleTodo({{i}});"
|
|
739
|
+
{{todo.done ? 'checked' : ''}}
|
|
740
|
+
/>
|
|
741
|
+
{{todo.text}}
|
|
742
|
+
</li>
|
|
743
|
+
</ul>
|
|
744
|
+
</section>
|
|
745
|
+
`;
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
@addEventListener({ type: 'click', query: '#add-btn' })
|
|
749
|
+
onAddTodo() {
|
|
750
|
+
this.addTodo();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
@onAfterConnected
|
|
754
|
+
init() {
|
|
755
|
+
console.log('PageHome connected');
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// ─── Page: About ─────────────────────────────────────────────────────────────
|
|
760
|
+
|
|
761
|
+
@Sim
|
|
762
|
+
@elementDefine({ name: 'page-about', window: w })
|
|
763
|
+
class PageAbout extends HTMLElement {
|
|
764
|
+
@state @attribute name = 'World';
|
|
765
|
+
|
|
766
|
+
@innerHtml({ useShadow: true })
|
|
767
|
+
render() {
|
|
768
|
+
return `
|
|
769
|
+
<style>
|
|
770
|
+
:host { display: block; padding: 20px; }
|
|
771
|
+
input { padding: 6px; margin-right: 8px; }
|
|
772
|
+
</style>
|
|
773
|
+
|
|
774
|
+
<h2>ℹ️ About Page</h2>
|
|
775
|
+
<p>Hello, <strong>{{name}}</strong>!</p>
|
|
776
|
+
<input
|
|
777
|
+
type="text"
|
|
778
|
+
placeholder="Enter your name"
|
|
779
|
+
value="{{name}}"
|
|
780
|
+
swc-on-input="$host.name = event.target.value"
|
|
781
|
+
/>
|
|
782
|
+
|
|
783
|
+
<hr/>
|
|
784
|
+
|
|
785
|
+
<h3>Status Panel</h3>
|
|
786
|
+
<div is="swc-choose-children-div" swc-value="{{name}}">
|
|
787
|
+
<p is="swc-when-p" test="'World'">👋 Default greeting</p>
|
|
788
|
+
<p is="swc-when-p" test="'Admin'">🔑 Admin mode activated</p>
|
|
789
|
+
<p is="swc-other-p">😊 Hello, {{name}}!</p>
|
|
790
|
+
</div>
|
|
791
|
+
`;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// ─── Sub Router ──────────────────────────────────────────────────────────────
|
|
796
|
+
|
|
797
|
+
@Sim
|
|
798
|
+
@Router({
|
|
799
|
+
path: '/app',
|
|
800
|
+
route: {
|
|
801
|
+
'/home': PageHome,
|
|
802
|
+
'/about': PageAbout,
|
|
803
|
+
}
|
|
804
|
+
})
|
|
805
|
+
@elementDefine({ name: 'app-sub-router', window: w })
|
|
806
|
+
class AppSubRouter extends HTMLElement implements RouterAction.CanActivate {
|
|
807
|
+
@query('#outlet') outlet!: HTMLElement;
|
|
808
|
+
|
|
809
|
+
@innerHtml({ useShadow: true })
|
|
810
|
+
render() {
|
|
811
|
+
return `
|
|
812
|
+
<style>
|
|
813
|
+
:host { display: block; }
|
|
814
|
+
nav { background: #f0f0f0; padding: 10px; }
|
|
815
|
+
nav a { margin-right: 12px; cursor: pointer; color: #1a73e8; text-decoration: none; }
|
|
816
|
+
nav a:hover { text-decoration: underline; }
|
|
817
|
+
#outlet { min-height: 200px; }
|
|
818
|
+
</style>
|
|
819
|
+
<nav>
|
|
820
|
+
<a id="nav-home">Home</a>
|
|
821
|
+
<a id="nav-about">About</a>
|
|
822
|
+
</nav>
|
|
823
|
+
<div id="outlet"></div>
|
|
824
|
+
`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
async canActivate(url: RoutingDataSet, data?: any): Promise<void> {
|
|
828
|
+
if (data instanceof Node && !this.outlet?.contains(data)) {
|
|
829
|
+
this.outlet?.replaceChildren(data);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
@addEventListener({ type: 'click', query: '#nav-home', root: 'shadow' })
|
|
834
|
+
goHome() { (this as any)._sim?.routing('/app/home'); }
|
|
835
|
+
|
|
836
|
+
@addEventListener({ type: 'click', query: '#nav-about', root: 'shadow' })
|
|
837
|
+
goAbout() { (this as any)._sim?.routing('/app/about'); }
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ─── Root Router ─────────────────────────────────────────────────────────────
|
|
841
|
+
|
|
842
|
+
@Sim
|
|
843
|
+
@Router({ routers: [AppSubRouter] })
|
|
844
|
+
@elementDefine({ name: 'root-router', window: w })
|
|
845
|
+
class RootRouter extends HTMLElement implements RouterAction.CanActivate {
|
|
846
|
+
@state currentPath = '/';
|
|
847
|
+
|
|
848
|
+
constructor(
|
|
849
|
+
private router: WebRouter,
|
|
850
|
+
private sim: SimpleApplication
|
|
851
|
+
) {
|
|
852
|
+
super();
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
async canActivate(url: RoutingDataSet, data?: any): Promise<void> {
|
|
856
|
+
this.currentPath = url.intent.pathname;
|
|
857
|
+
if (data instanceof Node && !this.contains(data)) {
|
|
858
|
+
this.replaceChildren(data);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
@innerHtml({ useShadow: true })
|
|
863
|
+
render() {
|
|
864
|
+
return `
|
|
865
|
+
<style>
|
|
866
|
+
:host { display: block; font-family: sans-serif; max-width: 800px; margin: 0 auto; }
|
|
867
|
+
header {
|
|
868
|
+
background: #1a73e8; color: white;
|
|
869
|
+
padding: 16px 20px; display: flex;
|
|
870
|
+
align-items: center; justify-content: space-between;
|
|
871
|
+
}
|
|
872
|
+
header h1 { margin: 0; font-size: 1.2rem; }
|
|
873
|
+
.path { font-size: 0.85rem; opacity: 0.8; }
|
|
874
|
+
main { padding: 0; }
|
|
875
|
+
</style>
|
|
876
|
+
<header>
|
|
877
|
+
<h1>SWC SPA Demo</h1>
|
|
878
|
+
<span class="path">{{currentPath}}</span>
|
|
879
|
+
</header>
|
|
880
|
+
<main><slot></slot></main>
|
|
881
|
+
`;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ─── Bootstrap ───────────────────────────────────────────────────────────────
|
|
886
|
+
|
|
887
|
+
const swc = new Swc({ rootRouter: RootRouter, window: w });
|
|
888
|
+
|
|
889
|
+
window.addEventListener('DOMContentLoaded', async () => {
|
|
890
|
+
document.body.appendChild(swc.run());
|
|
891
|
+
await swc.routing('/app/home');
|
|
892
|
+
});
|
|
893
|
+
```
|
|
894
|
+
|
|
895
|
+
```html
|
|
896
|
+
<!-- index.html -->
|
|
897
|
+
<!DOCTYPE html>
|
|
898
|
+
<html lang="en">
|
|
899
|
+
<head>
|
|
900
|
+
<meta charset="UTF-8" />
|
|
901
|
+
<title>SWC SPA Demo</title>
|
|
902
|
+
</head>
|
|
903
|
+
<body>
|
|
904
|
+
<!-- root-router is appended by swc.run() -->
|
|
905
|
+
</body>
|
|
906
|
+
</html>
|
|
907
|
+
```
|
|
908
|
+
|
|
909
|
+
---
|
|
910
|
+
|
|
911
|
+
## Complete Component Example
|
|
912
|
+
|
|
913
|
+
```typescript
|
|
914
|
+
import 'reflect-metadata';
|
|
915
|
+
import {
|
|
916
|
+
elementDefine, innerHtml, state, attribute,
|
|
917
|
+
query, addEventListener, onAfterConnected,
|
|
918
|
+
emitCustomEvent, onAttributeChanged
|
|
919
|
+
} from '@dooboostore/simple-web-component';
|
|
920
|
+
|
|
921
|
+
@elementDefine({ name: 'user-profile', autoRemoveEventListeners: true })
|
|
922
|
+
class UserProfile extends HTMLElement {
|
|
923
|
+
@state @attribute name = 'Guest';
|
|
924
|
+
@state @attribute({ type: Number }) age = 0;
|
|
925
|
+
@state isEditing = false;
|
|
926
|
+
@state history: string[] = [];
|
|
927
|
+
|
|
928
|
+
@query('input[name="name"]') nameInput!: HTMLInputElement;
|
|
929
|
+
|
|
930
|
+
@emitCustomEvent({ type: 'profile-save', bubbles: true })
|
|
931
|
+
save() {
|
|
932
|
+
return { name: this.name, age: this.age };
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
@innerHtml({ useShadow: true })
|
|
936
|
+
render() {
|
|
937
|
+
return `
|
|
938
|
+
<style>
|
|
939
|
+
:host { display: block; font-family: sans-serif; }
|
|
940
|
+
.card { padding: 16px; border: 1px solid #ddd; border-radius: 8px; }
|
|
941
|
+
button { cursor: pointer; margin: 4px; padding: 6px 12px; }
|
|
942
|
+
input { padding: 6px; margin: 4px; }
|
|
943
|
+
</style>
|
|
944
|
+
|
|
945
|
+
<div class="card">
|
|
946
|
+
<h2>{{name}} (age: {{age}})</h2>
|
|
947
|
+
<button id="edit-btn">Edit</button>
|
|
948
|
+
</div>
|
|
949
|
+
|
|
950
|
+
<div is="swc-if-children-div" swc-value="{{isEditing}}">
|
|
951
|
+
<div class="card">
|
|
952
|
+
<input name="name" type="text" value="{{name}}" placeholder="Name" />
|
|
953
|
+
<input name="age" type="number" value="{{age}}" placeholder="Age" />
|
|
954
|
+
<button id="save-btn">Save</button>
|
|
955
|
+
<button id="cancel-btn">Cancel</button>
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
|
|
959
|
+
<h4>Change History ({{history.length}})</h4>
|
|
960
|
+
<ul is="swc-for-of-children-ul" swc-value="{{history}}" as="log">
|
|
961
|
+
<li>{{log}}</li>
|
|
962
|
+
</ul>
|
|
963
|
+
`;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
@addEventListener({ type: 'click', query: '#edit-btn' })
|
|
967
|
+
onEdit() { this.isEditing = true; }
|
|
968
|
+
|
|
969
|
+
@addEventListener({ type: 'click', query: '#cancel-btn' })
|
|
970
|
+
onCancel() { this.isEditing = false; }
|
|
971
|
+
|
|
972
|
+
@addEventListener({ type: 'click', query: '#save-btn' })
|
|
973
|
+
onSave() {
|
|
974
|
+
this.name = this.nameInput?.value ?? this.name;
|
|
975
|
+
this.isEditing = false;
|
|
976
|
+
this.save(); // dispatches CustomEvent automatically
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
@onAttributeChanged('name')
|
|
980
|
+
onNameChange(newVal: string, oldVal: string) {
|
|
981
|
+
if (oldVal && newVal !== oldVal) {
|
|
982
|
+
this.history = [...this.history, `${oldVal} → ${newVal}`];
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
@onAfterConnected
|
|
987
|
+
init() {
|
|
988
|
+
console.log('UserProfile ready');
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
```html
|
|
994
|
+
<user-profile name="Kim" age="30"></user-profile>
|
|
995
|
+
|
|
996
|
+
<script>
|
|
997
|
+
document.querySelector('user-profile').addEventListener('profile-save', (e) => {
|
|
998
|
+
console.log('Saved:', e.detail); // { name: '...', age: ... }
|
|
999
|
+
});
|
|
1000
|
+
</script>
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
---
|
|
1004
|
+
|
|
1005
|
+
## License
|
|
122
1006
|
|
|
123
|
-
## 📜 License
|
|
124
1007
|
MIT
|