@vanilla-bean/components 1.0.0
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/Component/Component.js +598 -0
- package/Component/Component.scenarios.js +88 -0
- package/Component/Component.test.js +717 -0
- package/Component/README.md +455 -0
- package/Component/index.js +3 -0
- package/Component/observeElementConnection.js +52 -0
- package/Component/observeElementConnection.test.js +121 -0
- package/Elem/Elem.js +304 -0
- package/Elem/Elem.test.js +679 -0
- package/Elem/README.md +373 -0
- package/Elem/index.js +1 -0
- package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
- package/LICENSE +21 -0
- package/README.md +413 -0
- package/components/BottomSheet/BottomSheet.js +192 -0
- package/components/BottomSheet/BottomSheet.lld.md +25 -0
- package/components/BottomSheet/README.md +66 -0
- package/components/BottomSheet/index.js +1 -0
- package/components/Button/Button.js +53 -0
- package/components/Button/Button.lld.md +21 -0
- package/components/Button/index.js +1 -0
- package/components/Calendar/Calendar.js +720 -0
- package/components/Calendar/Calendar.lld.md +22 -0
- package/components/Calendar/CalendarEvent.js +102 -0
- package/components/Calendar/Toolbar.js +78 -0
- package/components/Calendar/index.js +2 -0
- package/components/Calendar/utils.js +56 -0
- package/components/Code/Code.js +84 -0
- package/components/Code/Code.lld.md +21 -0
- package/components/Code/index.js +1 -0
- package/components/ColorPicker/ColorPicker.js +445 -0
- package/components/ColorPicker/ColorPicker.lld.md +21 -0
- package/components/ColorPicker/index.js +1 -0
- package/components/ColorPicker/svg.js +5 -0
- package/components/Dialog/Dialog.js +278 -0
- package/components/Dialog/Dialog.lld.md +20 -0
- package/components/Dialog/README.md +96 -0
- package/components/Dialog/index.js +1 -0
- package/components/Form/Form.js +257 -0
- package/components/Form/Form.lld.md +21 -0
- package/components/Form/README.md +87 -0
- package/components/Form/index.js +1 -0
- package/components/Icon/Icon.js +54 -0
- package/components/Icon/Icon.lld.md +21 -0
- package/components/Icon/index.js +1 -0
- package/components/Input/Input.js +173 -0
- package/components/Input/Input.lld.md +28 -0
- package/components/Input/README.md +97 -0
- package/components/Input/index.js +2 -0
- package/components/Input/utils.js +122 -0
- package/components/Keyboard/Key.js +38 -0
- package/components/Keyboard/Keyboard.js +173 -0
- package/components/Keyboard/Keyboard.lld.md +21 -0
- package/components/Keyboard/index.js +1 -0
- package/components/Label/Label.js +214 -0
- package/components/Label/Label.lld.md +20 -0
- package/components/Label/index.js +1 -0
- package/components/Link/Link.js +43 -0
- package/components/Link/Link.lld.md +15 -0
- package/components/Link/index.js +1 -0
- package/components/List/List.js +82 -0
- package/components/List/List.lld.md +19 -0
- package/components/List/index.js +1 -0
- package/components/Menu/Menu.js +93 -0
- package/components/Menu/Menu.lld.md +15 -0
- package/components/Menu/index.js +1 -0
- package/components/Notify/Notify.js +96 -0
- package/components/Notify/Notify.lld.md +20 -0
- package/components/Notify/index.js +1 -0
- package/components/Page/Page.js +67 -0
- package/components/Page/Page.lld.md +20 -0
- package/components/Page/index.js +1 -0
- package/components/Popover/Popover.js +175 -0
- package/components/Popover/Popover.lld.md +19 -0
- package/components/Popover/index.js +1 -0
- package/components/RadioButton/RadioButton.js +108 -0
- package/components/RadioButton/RadioButton.lld.md +15 -0
- package/components/RadioButton/index.js +1 -0
- package/components/Router/README.md +160 -0
- package/components/Router/Router.js +150 -0
- package/components/Router/Router.lld.md +31 -0
- package/components/Router/View.js +15 -0
- package/components/Router/index.js +2 -0
- package/components/Router/utils.js +17 -0
- package/components/Select/README.md +88 -0
- package/components/Select/Select.js +74 -0
- package/components/Select/Select.lld.md +20 -0
- package/components/Select/index.js +1 -0
- package/components/Table/README.md +94 -0
- package/components/Table/Table.js +171 -0
- package/components/Table/Table.lld.md +21 -0
- package/components/Table/index.js +1 -0
- package/components/TagList/Tag.js +84 -0
- package/components/TagList/TagList.js +118 -0
- package/components/TagList/TagList.lld.md +30 -0
- package/components/TagList/design.excalidraw.png +0 -0
- package/components/TagList/index.js +2 -0
- package/components/Tooltip/Tooltip.js +139 -0
- package/components/Tooltip/Tooltip.lld.md +22 -0
- package/components/Tooltip/index.js +1 -0
- package/components/TooltipWrapper/TooltipWrapper.js +89 -0
- package/components/TooltipWrapper/TooltipWrapper.lld.md +21 -0
- package/components/TooltipWrapper/index.js +1 -0
- package/components/Whiteboard/Whiteboard.js +198 -0
- package/components/Whiteboard/Whiteboard.lld.md +35 -0
- package/components/Whiteboard/index.js +1 -0
- package/components/index.js +27 -0
- package/eslint.config.cjs +118 -0
- package/index.d.ts +635 -0
- package/index.js +19 -0
- package/package.json +123 -0
- package/plugins/asText.js +38 -0
- package/plugins/loadPlugins.js +5 -0
- package/plugins/markdownLoader.js +121 -0
- package/prettier.config.cjs +7 -0
- package/spellcheck.config.cjs +227 -0
- package/styled/README.md +329 -0
- package/styled/appendStyles.js +26 -0
- package/styled/appendStyles.test.js +45 -0
- package/styled/index.js +4 -0
- package/styled/shimCSS.js +31 -0
- package/styled/shimCSS.test.js +103 -0
- package/styled/styled.js +91 -0
- package/styled/styled.test.js +586 -0
- package/styled/themeStyles.js +36 -0
- package/styled/themeStyles.test.js +135 -0
- package/test-setup.js +123 -0
- package/theme/.test.js +69 -0
- package/theme/README.md +607 -0
- package/theme/button.js +100 -0
- package/theme/code.js +123 -0
- package/theme/colors.js +42 -0
- package/theme/fonts.js +42 -0
- package/theme/index.js +33 -0
- package/theme/input.js +64 -0
- package/theme/page.js +208 -0
- package/theme/scrollbar.js +24 -0
- package/theme/table.js +53 -0
- package/utils/README.md +176 -0
- package/utils/browser.js +92 -0
- package/utils/class.js +30 -0
- package/utils/color.js +81 -0
- package/utils/data.js +164 -0
- package/utils/element.js +55 -0
- package/utils/index.js +7 -0
- package/utils/rand.js +12 -0
- package/utils/string.js +72 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
# Component
|
|
2
|
+
|
|
3
|
+
Reactive component class extending Elem with Oxject-backed reactive options, lifecycle management, and automatic cleanup.
|
|
4
|
+
|
|
5
|
+
## Key Features
|
|
6
|
+
|
|
7
|
+
- **Reactive options system** - Property changes backed by Oxject emit events and update DOM automatically
|
|
8
|
+
- **Automatic memory management** - Element-level listeners persist through DOM moves, cleaned up on destroy. External resources (timers, subscriptions) cleaned up on disconnect
|
|
9
|
+
- **Flexible render timing** - Immediate, onload, or animationFrame rendering modes
|
|
10
|
+
- **Enhanced event handling** - Input events include `.value` property, DOM connection detection
|
|
11
|
+
- **Integrated styling** - Inline objects or scoped CSS with theme system integration
|
|
12
|
+
- **Lifecycle hooks** - Built-in hover and press handlers with automatic cleanup
|
|
13
|
+
|
|
14
|
+
```js
|
|
15
|
+
import { Component } from '@vanilla-bean/components';
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Basic Usage
|
|
19
|
+
|
|
20
|
+
### Simple Components
|
|
21
|
+
|
|
22
|
+
Every Component extends EventTarget and wraps an HTMLElement with reactive options:
|
|
23
|
+
|
|
24
|
+
```js
|
|
25
|
+
import { Component } from '@vanilla-bean/components';
|
|
26
|
+
|
|
27
|
+
const button = new Component({
|
|
28
|
+
tag: 'button',
|
|
29
|
+
textContent: 'Click me',
|
|
30
|
+
className: 'primary-btn',
|
|
31
|
+
onPointerPress: () => alert('Hello!'),
|
|
32
|
+
appendTo: document.body,
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Reactive Property Updates
|
|
37
|
+
|
|
38
|
+
Options are backed by Oxject. Property changes trigger DOM updates automatically:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
const input = new Component({
|
|
42
|
+
tag: 'input',
|
|
43
|
+
value: 'Initial text',
|
|
44
|
+
onChange: ({ value }) => {
|
|
45
|
+
input.options.value = value; // Updates DOM reactively
|
|
46
|
+
},
|
|
47
|
+
appendTo: document.body,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Display that updates automatically
|
|
51
|
+
new Component({
|
|
52
|
+
tag: 'p',
|
|
53
|
+
textContent: input.options.subscriber('value', value => `Current: ${value}`),
|
|
54
|
+
appendTo: document.body,
|
|
55
|
+
});
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Extended Component Classes
|
|
59
|
+
|
|
60
|
+
Create reusable component classes with custom behavior:
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
class Button extends Component {
|
|
64
|
+
constructor(options = {}, ...children) {
|
|
65
|
+
super(
|
|
66
|
+
{
|
|
67
|
+
tag: 'button',
|
|
68
|
+
...options,
|
|
69
|
+
styles: ({ button }) => `
|
|
70
|
+
${button}
|
|
71
|
+
${options.styles?.({ button }) || ''}
|
|
72
|
+
`,
|
|
73
|
+
},
|
|
74
|
+
...children,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_setOption(key, value) {
|
|
79
|
+
if (key === 'mode') {
|
|
80
|
+
this.removeClass(/\bmode-\S+/g).addClass(`mode-${value}`);
|
|
81
|
+
} else {
|
|
82
|
+
super._setOption(key, value);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### With Styled Components
|
|
89
|
+
|
|
90
|
+
Components integrate seamlessly with the styled system:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import { styled } from '@vanilla-bean/components';
|
|
94
|
+
|
|
95
|
+
const StyledComponent = styled(
|
|
96
|
+
Component,
|
|
97
|
+
({ colors }) => `
|
|
98
|
+
background: ${colors.blue};
|
|
99
|
+
color: ${colors.white};
|
|
100
|
+
padding: 16px;
|
|
101
|
+
border-radius: 8px;
|
|
102
|
+
`,
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Reactive Options System
|
|
107
|
+
|
|
108
|
+
Component options are stored in an Oxject instance (not a plain object like Elem), enabling automatic reactivity:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const button = new Component({ textContent: 'Click me' });
|
|
112
|
+
|
|
113
|
+
// Property changes trigger DOM updates
|
|
114
|
+
button.options.textContent = 'Updated'; // Updates DOM reactively
|
|
115
|
+
button.options.addClass = 'active'; // Adds CSS class reactively
|
|
116
|
+
|
|
117
|
+
// Full Oxject features available
|
|
118
|
+
const subscription = button.options.subscribe({
|
|
119
|
+
key: 'textContent',
|
|
120
|
+
callback: value => console.log('Text changed:', value),
|
|
121
|
+
});
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
### Option Processing Pipeline
|
|
125
|
+
|
|
126
|
+
Options are routed through `_setOption` based on key and value type:
|
|
127
|
+
|
|
128
|
+
| Condition | Routing | Example |
|
|
129
|
+
| ------------------------ | -------------------------- | ------------------------- |
|
|
130
|
+
| Key starts with 'on' | Event handler registration | `onPointerPress: handler` |
|
|
131
|
+
| Key in `knownAttributes` | `elem.setAttribute()` | `id: 'my-id'` |
|
|
132
|
+
| Component has method | Method call with value | `addClass: 'active'` |
|
|
133
|
+
| HTMLElement has property | Direct elem property | `textContent: 'hello'` |
|
|
134
|
+
| Value is function | Component property | `customMethod: fn` |
|
|
135
|
+
| Default | Elem property assignment | `customProp: value` |
|
|
136
|
+
|
|
137
|
+
## Event Handling
|
|
138
|
+
|
|
139
|
+
### Automatic Event Registration
|
|
140
|
+
|
|
141
|
+
Event handlers are registered automatically when option keys start with 'on':
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
new Component({
|
|
145
|
+
onPointerPress: event => console.log('pressed'),
|
|
146
|
+
onChange: ({ value }) => console.log('value:', value), // Input events include .value
|
|
147
|
+
onConnected: () => console.log('added to DOM'),
|
|
148
|
+
onDisconnected: () => console.log('removed from DOM'),
|
|
149
|
+
});
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
> **Note:** `click` is not in the supported event set. Use `onPointerPress` for press interactions.
|
|
153
|
+
|
|
154
|
+
### Enhanced Input Events
|
|
155
|
+
|
|
156
|
+
Input-related events (`keydown`, `keyup`, `change`, `blur`, `input`, `search`) receive enhanced event objects:
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
new Component({
|
|
160
|
+
tag: 'input',
|
|
161
|
+
type: 'text',
|
|
162
|
+
onChange: ({ value, event }) => {
|
|
163
|
+
console.log('New value:', value); // Extracted from input
|
|
164
|
+
console.log('Original event:', event);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Connection Events
|
|
170
|
+
|
|
171
|
+
DOM connection/disconnection detected via MutationObserver:
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
new Component({
|
|
175
|
+
onConnected: () => console.log('Component added to DOM'),
|
|
176
|
+
onDisconnected: () => {
|
|
177
|
+
console.log('Component removed - cleanup triggered');
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Specialized Event Handlers
|
|
183
|
+
|
|
184
|
+
#### Hover with Movement Tracking
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
component.onHover(event => {
|
|
188
|
+
console.log('Mouse position:', event.clientX, event.clientY);
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
#### Press Detection
|
|
193
|
+
|
|
194
|
+
Fires on `pointerdown` for immediate, reliable response in all contexts including scroll containers and modal dialogs:
|
|
195
|
+
|
|
196
|
+
```js
|
|
197
|
+
component.onPointerPress(event => {
|
|
198
|
+
console.log('Pressed');
|
|
199
|
+
});
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Styling
|
|
203
|
+
|
|
204
|
+
### Inline Style Objects
|
|
205
|
+
|
|
206
|
+
Apply styles as JavaScript objects:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
component.styles({
|
|
210
|
+
color: 'red',
|
|
211
|
+
padding: '10px',
|
|
212
|
+
backgroundColor: '#f0f0f0',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Or as option
|
|
216
|
+
new Component({
|
|
217
|
+
styles: { color: 'red', padding: '10px' },
|
|
218
|
+
});
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Scoped CSS with Theme Integration
|
|
222
|
+
|
|
223
|
+
Use theme functions for scoped, processed CSS:
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
// Theme function with automatic scoping
|
|
227
|
+
component.styles(
|
|
228
|
+
({ colors, fonts }) => `
|
|
229
|
+
background: ${colors.blue};
|
|
230
|
+
${fonts.kodeMono}
|
|
231
|
+
padding: 16px;
|
|
232
|
+
border-radius: 8px;
|
|
233
|
+
|
|
234
|
+
&:hover {
|
|
235
|
+
background: ${colors.darker(colors.blue)};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@media (max-width: 768px) {
|
|
239
|
+
padding: 8px;
|
|
240
|
+
}
|
|
241
|
+
`,
|
|
242
|
+
);
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
CSS is scope-wrapped and injected as a scoped <style> tag. No preprocessing.
|
|
246
|
+
|
|
247
|
+
## Lifecycle Management
|
|
248
|
+
|
|
249
|
+
### Render Lifecycle
|
|
250
|
+
|
|
251
|
+
`render()` orchestrates a fixed sequence every time it is called:
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
render()
|
|
255
|
+
├─ if (this.rendered) ← re-render only: clears children, runs descendant cleanup
|
|
256
|
+
│ empty()
|
|
257
|
+
│ this.rendered = false
|
|
258
|
+
├─ build() ← subclass structural hook, runs before options are applied
|
|
259
|
+
├─ _processOptions() ← routes every option through _setOption()
|
|
260
|
+
│ priority keys first (onConnected, textContent, content, appendTo, prependTo, value)
|
|
261
|
+
│ then all remaining keys
|
|
262
|
+
└─ this.rendered = true
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**`build()` is the subclass structural hook.** Override `build()`, never `render()`, to create child elements and internal structure. Because `build()` runs before `_processOptions()`, all structure exists by the time `_setOption` receives values.
|
|
266
|
+
|
|
267
|
+
The preferred way to handle specific options is `static handlers` — a per-class dispatch map that composes across the constructor chain without requiring `super._setOption`:
|
|
268
|
+
|
|
269
|
+
```js
|
|
270
|
+
class Card extends Component {
|
|
271
|
+
build() {
|
|
272
|
+
this.header = new Component({ tag: 'header', appendTo: this });
|
|
273
|
+
this.body = new Component({ tag: 'section', appendTo: this });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
static handlers = {
|
|
277
|
+
title(value) {
|
|
278
|
+
this.header.options.textContent = value;
|
|
279
|
+
},
|
|
280
|
+
variant(value) {
|
|
281
|
+
this.removeClass(/variant-\S+/).addClass(`variant-${value}`);
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Handlers that don't call `next(value)` fully own their key. Unhandled keys fall through to standard routing automatically — no `super._setOption` required. Use `_setOption` override only for enum validation or cases that need to intercept before the routing chain.
|
|
288
|
+
|
|
289
|
+
**`empty()` only runs on re-render.** On the initial render it is skipped. On subsequent `render()` calls it removes all child elements and runs cleanup on all descendant components before the DOM is cleared.
|
|
290
|
+
|
|
291
|
+
**`async build()` is not supported.** `render()` does not await `build()`'s return value. Asynchronous initialization belongs in `onConnected`.
|
|
292
|
+
|
|
293
|
+
**Deep hierarchies must chain `super.build()` manually.** If both a parent class and its subclass define `build()`, the subclass must call `super.build()` explicitly. It is not called automatically.
|
|
294
|
+
|
|
295
|
+
### Rendering Process (summary)
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
component.render(); // Re-render: empty → build → _processOptions → rendered = true
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Rendering behavior:
|
|
302
|
+
|
|
303
|
+
1. **Content clearing** - `empty()` removes existing children on re-render only (skipped on first render)
|
|
304
|
+
2. **Structure creation** - `build()` creates sub-elements before options are applied
|
|
305
|
+
3. **Priority processing** - `_processOptions()` applies `priorityOptions` keys first
|
|
306
|
+
4. **Option routing** - Each option processed via `_setOption`
|
|
307
|
+
5. **Completion flag** - Sets `rendered = true`
|
|
308
|
+
|
|
309
|
+
### Automatic Cleanup System
|
|
310
|
+
|
|
311
|
+
Components automatically clean up resources when removed from DOM:
|
|
312
|
+
|
|
313
|
+
```js
|
|
314
|
+
// Manual cleanup registration
|
|
315
|
+
component.addCleanup('unique-id', () => {
|
|
316
|
+
console.log('Custom cleanup executed');
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
// Manual cleanup execution (automatic on disconnect)
|
|
320
|
+
component.processCleanup();
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
**Automatic cleanup includes:**
|
|
324
|
+
|
|
325
|
+
- Event listener removal
|
|
326
|
+
- Oxject subscription cleanup
|
|
327
|
+
- Style element removal from DOM
|
|
328
|
+
- Recursive child component cleanup
|
|
329
|
+
|
|
330
|
+
### Render Timing Control
|
|
331
|
+
|
|
332
|
+
Control when components render with `autoRender` option:
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
// Immediate rendering (default)
|
|
336
|
+
new Component({ autoRender: true });
|
|
337
|
+
|
|
338
|
+
// Render on window load event
|
|
339
|
+
new Component({ autoRender: 'onload' });
|
|
340
|
+
|
|
341
|
+
// Render on next animation frame
|
|
342
|
+
new Component({ autoRender: 'animationFrame' });
|
|
343
|
+
|
|
344
|
+
// Manual rendering only
|
|
345
|
+
new Component({ autoRender: false });
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## API Reference
|
|
349
|
+
|
|
350
|
+
### Constructor
|
|
351
|
+
|
|
352
|
+
```js
|
|
353
|
+
new Component(options?, ...children)
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
**Parameters:**
|
|
357
|
+
|
|
358
|
+
- `options` (Object) - Configuration options
|
|
359
|
+
- `children` (...Component|Elem) - Child elements appended to `append` option
|
|
360
|
+
|
|
361
|
+
### Configuration Options
|
|
362
|
+
|
|
363
|
+
#### Component-Specific Options
|
|
364
|
+
|
|
365
|
+
| Option | Type | Description |
|
|
366
|
+
| ------------------ | ------------------------------------- | ------------------------------------------- |
|
|
367
|
+
| `tag` | `string` | HTML tag name (default: 'div') |
|
|
368
|
+
| `autoRender` | `boolean\|'onload'\|'animationFrame'` | Render timing control |
|
|
369
|
+
| `registeredEvents` | `Set<string>` | Custom event types for on() method |
|
|
370
|
+
| `knownAttributes` | `Set<string>` | Attribute names for elem.setAttribute() |
|
|
371
|
+
| `priorityOptions` | `Set<string>` | Option keys processed first during render |
|
|
372
|
+
| `styles` | `string\|object\|Function` | CSS string, style object, or theme function |
|
|
373
|
+
| `uniqueId` | `string` | Override auto-generated unique ID |
|
|
374
|
+
|
|
375
|
+
#### Event Handler Options
|
|
376
|
+
|
|
377
|
+
| Option | Type | Description |
|
|
378
|
+
| ---------------- | ---------- | -------------------------------------------- |
|
|
379
|
+
| `onHover` | `Function` | Hover handler with move tracking |
|
|
380
|
+
| `onPointerPress` | `Function` | Fires on `pointerdown` |
|
|
381
|
+
| ~~`onClick`~~ | — | Not supported. Use `onPointerPress` instead. |
|
|
382
|
+
| `onChange` | `Function` | Change event handler (input .value included) |
|
|
383
|
+
| `onConnected` | `Function` | DOM connection detection |
|
|
384
|
+
| `onDisconnected` | `Function` | DOM disconnection detection |
|
|
385
|
+
|
|
386
|
+
All standard Elem options work as reactive Component options.
|
|
387
|
+
|
|
388
|
+
### Methods
|
|
389
|
+
|
|
390
|
+
#### Options-Compatible Methods
|
|
391
|
+
|
|
392
|
+
Can be called directly or used as reactive options:
|
|
393
|
+
|
|
394
|
+
```js
|
|
395
|
+
component.styles(styleFunction); // Direct call
|
|
396
|
+
component.onHover(callback); // Direct call
|
|
397
|
+
component.addClass('active'); // Direct call
|
|
398
|
+
component.append(child); // Direct call
|
|
399
|
+
|
|
400
|
+
// Or as reactive options:
|
|
401
|
+
new Component({
|
|
402
|
+
styles: styleFunction,
|
|
403
|
+
onHover: callback,
|
|
404
|
+
addClass: 'active',
|
|
405
|
+
append: [child],
|
|
406
|
+
});
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
#### Lifecycle Methods
|
|
410
|
+
|
|
411
|
+
```js
|
|
412
|
+
component.build(); // Subclass structural hook — override to create child elements
|
|
413
|
+
component.render(); // Re-render: empty → build → _processOptions → rendered = true
|
|
414
|
+
component.addCleanup(id, fn); // Register cleanup function (chains with existing for same id)
|
|
415
|
+
component.replaceCleanup(id, fn); // Replace cleanup, running the previous one immediately
|
|
416
|
+
component.replaceDestroyCleanup(id, fn); // Destroy-only cleanup: survives disconnect, runs on destroy()
|
|
417
|
+
component.processCleanup(); // Execute all cleanup functions
|
|
418
|
+
component.destroy(); // Disconnect observer, run all cleanup, remove from DOM
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
#### Event Methods
|
|
422
|
+
|
|
423
|
+
```js
|
|
424
|
+
component.on({ targetEvent, callback, id? }); // Event registration
|
|
425
|
+
component.emit(eventType, detail); // Event dispatch
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
#### Utility Methods
|
|
429
|
+
|
|
430
|
+
```js
|
|
431
|
+
component.ancestry(); // Prototype chain inspection
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Properties
|
|
435
|
+
|
|
436
|
+
```js
|
|
437
|
+
component.parent; // Parent Component/Elem instance
|
|
438
|
+
component.children; // Child Component/Elem instances
|
|
439
|
+
component.uniqueId; // Frozen unique identifier
|
|
440
|
+
component.options; // Reactive Oxject instance
|
|
441
|
+
component.elem; // Underlying HTMLElement
|
|
442
|
+
component.rendered; // Boolean render status flag
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## Memory Management
|
|
446
|
+
|
|
447
|
+
Components provide comprehensive automatic memory management:
|
|
448
|
+
|
|
449
|
+
- **Event listeners** - Automatically removed on DOM disconnect
|
|
450
|
+
- **Oxject subscriptions** - Cleaned up when component destroyed
|
|
451
|
+
- **Style elements** - Removed from DOM head on cleanup
|
|
452
|
+
- **Child components** - Recursive cleanup propagation
|
|
453
|
+
- **Custom cleanup** - Manual cleanup function registration
|
|
454
|
+
|
|
455
|
+
No manual cleanup required for typical usage - components handle resource management automatically.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const registry = new Map();
|
|
2
|
+
let sharedObserver = null;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
*
|
|
6
|
+
*/
|
|
7
|
+
function ensureObserver() {
|
|
8
|
+
if (sharedObserver) return;
|
|
9
|
+
|
|
10
|
+
sharedObserver = new MutationObserver(mutations => {
|
|
11
|
+
for (const mutation of mutations) {
|
|
12
|
+
for (const node of mutation.removedNodes) {
|
|
13
|
+
for (const [target, callbacks] of registry) {
|
|
14
|
+
if (node === target || node.contains?.(target)) callbacks.onDisconnected(mutation);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const node of mutation.addedNodes) {
|
|
18
|
+
for (const [target, callbacks] of registry) {
|
|
19
|
+
if (node === target || node.contains?.(target)) callbacks.onConnected(mutation);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
sharedObserver.observe(document, { childList: true, subtree: true });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Observe DOM connection/disconnection of an element via a shared MutationObserver.
|
|
30
|
+
* All registrations share one observer; the observer is torn down when no targets remain.
|
|
31
|
+
* Fires correctly when the target itself or any ancestor is moved.
|
|
32
|
+
* @param {object} config - Observer registration options
|
|
33
|
+
* @param {Node} config.target - Target element to watch for add/remove
|
|
34
|
+
* @param {Function} config.onConnected - Called when target is added to the document
|
|
35
|
+
* @param {Function} config.onDisconnected - Called when target is removed from the document
|
|
36
|
+
* @returns {{ disconnect: Function }} Handle — call disconnect() to deregister
|
|
37
|
+
*/
|
|
38
|
+
export const observeElementConnection = ({ target, onConnected, onDisconnected }) => {
|
|
39
|
+
ensureObserver();
|
|
40
|
+
registry.set(target, { onConnected, onDisconnected });
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
disconnect() {
|
|
44
|
+
registry.delete(target);
|
|
45
|
+
|
|
46
|
+
if (registry.size === 0) {
|
|
47
|
+
sharedObserver?.disconnect();
|
|
48
|
+
sharedObserver = null;
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { observeElementConnection } from './observeElementConnection';
|
|
2
|
+
|
|
3
|
+
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
4
|
+
|
|
5
|
+
describe('observeElementConnection', () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
document.body.replaceChildren();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('fires onConnected when target is directly appended', async () => {
|
|
11
|
+
const target = document.createElement('div');
|
|
12
|
+
const onConnected = mock();
|
|
13
|
+
const handle = observeElementConnection({ target, onConnected, onDisconnected: mock() });
|
|
14
|
+
|
|
15
|
+
document.body.appendChild(target);
|
|
16
|
+
await tick();
|
|
17
|
+
|
|
18
|
+
expect(onConnected).toHaveBeenCalledTimes(1);
|
|
19
|
+
handle.disconnect();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('fires onDisconnected when target is directly removed', async () => {
|
|
23
|
+
const target = document.createElement('div');
|
|
24
|
+
document.body.appendChild(target);
|
|
25
|
+
await tick();
|
|
26
|
+
|
|
27
|
+
const onDisconnected = mock();
|
|
28
|
+
const handle = observeElementConnection({ target, onConnected: mock(), onDisconnected });
|
|
29
|
+
|
|
30
|
+
document.body.removeChild(target);
|
|
31
|
+
await tick();
|
|
32
|
+
|
|
33
|
+
expect(onDisconnected).toHaveBeenCalledTimes(1);
|
|
34
|
+
handle.disconnect();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('fires onConnected when an ancestor containing the target is appended', async () => {
|
|
38
|
+
const parent = document.createElement('div');
|
|
39
|
+
const target = document.createElement('span');
|
|
40
|
+
parent.appendChild(target);
|
|
41
|
+
|
|
42
|
+
const onConnected = mock();
|
|
43
|
+
const handle = observeElementConnection({ target, onConnected, onDisconnected: mock() });
|
|
44
|
+
|
|
45
|
+
document.body.appendChild(parent);
|
|
46
|
+
await tick();
|
|
47
|
+
|
|
48
|
+
expect(onConnected).toHaveBeenCalledTimes(1);
|
|
49
|
+
handle.disconnect();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('fires onDisconnected when an ancestor containing the target is removed', async () => {
|
|
53
|
+
const parent = document.createElement('div');
|
|
54
|
+
const target = document.createElement('span');
|
|
55
|
+
parent.appendChild(target);
|
|
56
|
+
document.body.appendChild(parent);
|
|
57
|
+
await tick();
|
|
58
|
+
|
|
59
|
+
const onDisconnected = mock();
|
|
60
|
+
const handle = observeElementConnection({ target, onConnected: mock(), onDisconnected });
|
|
61
|
+
|
|
62
|
+
document.body.removeChild(parent);
|
|
63
|
+
await tick();
|
|
64
|
+
|
|
65
|
+
expect(onDisconnected).toHaveBeenCalledTimes(1);
|
|
66
|
+
handle.disconnect();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('does not fire after disconnect', async () => {
|
|
70
|
+
const target = document.createElement('div');
|
|
71
|
+
const onConnected = mock();
|
|
72
|
+
const handle = observeElementConnection({ target, onConnected, onDisconnected: mock() });
|
|
73
|
+
|
|
74
|
+
handle.disconnect();
|
|
75
|
+
document.body.appendChild(target);
|
|
76
|
+
await tick();
|
|
77
|
+
|
|
78
|
+
expect(onConnected).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('multiple targets all receive their callbacks', async () => {
|
|
82
|
+
const target1 = document.createElement('div');
|
|
83
|
+
const target2 = document.createElement('div');
|
|
84
|
+
const cb1 = mock();
|
|
85
|
+
const cb2 = mock();
|
|
86
|
+
|
|
87
|
+
const h1 = observeElementConnection({ target: target1, onConnected: cb1, onDisconnected: mock() });
|
|
88
|
+
const h2 = observeElementConnection({ target: target2, onConnected: cb2, onDisconnected: mock() });
|
|
89
|
+
|
|
90
|
+
document.body.appendChild(target1);
|
|
91
|
+
document.body.appendChild(target2);
|
|
92
|
+
await tick();
|
|
93
|
+
|
|
94
|
+
expect(cb1).toHaveBeenCalled();
|
|
95
|
+
expect(cb2).toHaveBeenCalled();
|
|
96
|
+
|
|
97
|
+
h1.disconnect();
|
|
98
|
+
h2.disconnect();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('deregistered target does not fire after its disconnect', async () => {
|
|
102
|
+
const target1 = document.createElement('div');
|
|
103
|
+
const target2 = document.createElement('div');
|
|
104
|
+
const cb1 = mock();
|
|
105
|
+
const cb2 = mock();
|
|
106
|
+
|
|
107
|
+
const h1 = observeElementConnection({ target: target1, onConnected: cb1, onDisconnected: mock() });
|
|
108
|
+
const h2 = observeElementConnection({ target: target2, onConnected: cb2, onDisconnected: mock() });
|
|
109
|
+
|
|
110
|
+
h1.disconnect();
|
|
111
|
+
|
|
112
|
+
document.body.appendChild(target1);
|
|
113
|
+
document.body.appendChild(target2);
|
|
114
|
+
await tick();
|
|
115
|
+
|
|
116
|
+
expect(cb1).not.toHaveBeenCalled();
|
|
117
|
+
expect(cb2).toHaveBeenCalled();
|
|
118
|
+
|
|
119
|
+
h2.disconnect();
|
|
120
|
+
});
|
|
121
|
+
});
|