@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,598 @@
|
|
|
1
|
+
import { Oxject } from '@vanilla-bean/oxject';
|
|
2
|
+
import { appendStyles, themeStyles } from '../styled';
|
|
3
|
+
import { classSafeNanoid, isDev } from '../utils';
|
|
4
|
+
import { Elem } from '../Elem';
|
|
5
|
+
|
|
6
|
+
import { observeElementConnection } from './observeElementConnection';
|
|
7
|
+
|
|
8
|
+
const _internalProperties = new Set([
|
|
9
|
+
'cleanup',
|
|
10
|
+
'_destroyCleanup',
|
|
11
|
+
'rendered',
|
|
12
|
+
'elemObserver',
|
|
13
|
+
'options',
|
|
14
|
+
'elem',
|
|
15
|
+
'tag',
|
|
16
|
+
'defaultOptions',
|
|
17
|
+
'handlers',
|
|
18
|
+
'__registeredEvents',
|
|
19
|
+
'__knownAttributes',
|
|
20
|
+
'__priorityOptions',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
const _lifecycleMethods = new Set([
|
|
24
|
+
'render',
|
|
25
|
+
'destroy',
|
|
26
|
+
'build',
|
|
27
|
+
'empty',
|
|
28
|
+
'processCleanup',
|
|
29
|
+
'addCleanup',
|
|
30
|
+
'replaceCleanup',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const connectionEvents = new Set(['connected', 'disconnected']);
|
|
34
|
+
const inputEvents = new Set(['keydown', 'keyup', 'change', 'blur', 'input', 'search']);
|
|
35
|
+
const commonEvents = new Set([
|
|
36
|
+
'pointerover',
|
|
37
|
+
'pointerenter',
|
|
38
|
+
'pointerdown',
|
|
39
|
+
'pointermove',
|
|
40
|
+
'pointerup',
|
|
41
|
+
'pointercancel',
|
|
42
|
+
'pointerout',
|
|
43
|
+
'pointerleave',
|
|
44
|
+
'contextmenu',
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const defaultOptions = {
|
|
48
|
+
tag: 'div',
|
|
49
|
+
autoRender: true,
|
|
50
|
+
registeredEvents: new Set([]),
|
|
51
|
+
knownAttributes: new Set(['role', 'name', 'colspan', 'anchor', 'popover', 'popovertarget', 'popovertargetaction']),
|
|
52
|
+
get priorityOptions() {
|
|
53
|
+
return new Set(['onConnected', 'textContent', 'content', 'appendTo', 'prependTo', 'value']);
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* General purpose reactive component with automatic cleanup and lifecycle management.
|
|
59
|
+
* Extends Elem with Oxject-driven options, event handling, and style processing.
|
|
60
|
+
* @augments Elem
|
|
61
|
+
* @augments EventTarget
|
|
62
|
+
*/
|
|
63
|
+
class Component extends Elem {
|
|
64
|
+
defaultOptions = defaultOptions;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Creates reactive component with Oxject-driven options, automatic cleanup, and lifecycle management.
|
|
68
|
+
* @param {object} [options] - Component configuration object with reactive properties
|
|
69
|
+
* @param {string} [options.tag] - HTML tag name for the root element
|
|
70
|
+
* @param {boolean|'onload'|'animationFrame'} [options.autoRender] - Render timing: true (immediate), 'onload' (window load), 'animationFrame' (next frame), false (manual)
|
|
71
|
+
* @param {Set<string>} [options.registeredEvents] - Additional event types to handle via on() method
|
|
72
|
+
* @param {Set<string>} [options.knownAttributes] - Attribute names routed to elem.setAttribute() instead of property assignment
|
|
73
|
+
* @param {Set<string>} [options.priorityOptions] - Option keys processed first during render
|
|
74
|
+
* @param {object} [options.style] - Inline CSS properties applied as HTMLElement.style
|
|
75
|
+
* @param {object} [options.attributes] - HTML attributes applied via setAttribute()
|
|
76
|
+
* @param {string|object|Function} [options.styles] - CSS definition: string/function processed through theme system, object applied inline
|
|
77
|
+
* @param {string} [options.textContent] - Text content for the element
|
|
78
|
+
* @param {string|string[]} [options.addClass] - CSS classes to add to the element
|
|
79
|
+
* @param {Component|HTMLElement|Array} [options.append] - Child elements to append
|
|
80
|
+
* @param {Component|HTMLElement} [options.appendTo] - Parent element to append this component to
|
|
81
|
+
* @param {Function} [options.onConnected] - Callback when component is added to DOM
|
|
82
|
+
* @param {Function} [options.onDisconnected] - Callback when component is removed from DOM
|
|
83
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements automatically added to append option
|
|
84
|
+
* @returns {Component} Component instance with reactive options accessible via this.options
|
|
85
|
+
*/
|
|
86
|
+
constructor(options = {}, ...children) {
|
|
87
|
+
const { tag, autoRender, registeredEvents, knownAttributes, priorityOptions, ...optionsWithoutConfig } = {
|
|
88
|
+
...defaultOptions,
|
|
89
|
+
...options,
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
super({ tag });
|
|
93
|
+
|
|
94
|
+
this.__registeredEvents = registeredEvents;
|
|
95
|
+
this.__knownAttributes = knownAttributes;
|
|
96
|
+
this.__priorityOptions = priorityOptions;
|
|
97
|
+
|
|
98
|
+
this.elem._component = this;
|
|
99
|
+
|
|
100
|
+
this.uniqueId = Object.freeze(classSafeNanoid());
|
|
101
|
+
|
|
102
|
+
this.options = new Oxject({
|
|
103
|
+
...optionsWithoutConfig,
|
|
104
|
+
addClass: [this.uniqueId, optionsWithoutConfig.addClass],
|
|
105
|
+
append: [optionsWithoutConfig.append, children],
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const setOption = ({ detail: { key, value } }) => {
|
|
109
|
+
if (this.rendered) this._setOption(key, value);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
this.options.addEventListener('set', setOption);
|
|
113
|
+
|
|
114
|
+
this.addCleanup('options', () => {
|
|
115
|
+
this.options.removeEventListener('set', setOption);
|
|
116
|
+
this.options.destroy?.();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (autoRender === true) this.render();
|
|
120
|
+
else if (autoRender === 'onload') {
|
|
121
|
+
if (document.readyState === 'complete') this.render();
|
|
122
|
+
else {
|
|
123
|
+
const render = () => this.render();
|
|
124
|
+
window.addEventListener('load', render);
|
|
125
|
+
|
|
126
|
+
this.addCleanup('autoRender_onload', () => {
|
|
127
|
+
window.removeEventListener('load', render);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
} else if (autoRender === 'animationFrame') {
|
|
131
|
+
const frameId = requestAnimationFrame(() => this.render());
|
|
132
|
+
|
|
133
|
+
this.addCleanup('autoRender_animationFrame', () => cancelAnimationFrame(frameId));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (isDev) {
|
|
137
|
+
this.addClass(...this.ancestry().map(({ constructor }) => constructor.name));
|
|
138
|
+
|
|
139
|
+
if (this.constructor !== Component && this.constructor.prototype.hasOwnProperty('render')) {
|
|
140
|
+
// eslint-disable-next-line no-console
|
|
141
|
+
console.warn(
|
|
142
|
+
`[Component] ${this.constructor.name} overrides render(). Structure belongs in build() — render() is the lifecycle orchestrator.`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* String representation of Component instance.
|
|
150
|
+
* @returns {string} '[object Component]'
|
|
151
|
+
*/
|
|
152
|
+
toString() {
|
|
153
|
+
return '[object Component]';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Subclass structural hook — override to create child elements and component structure.
|
|
158
|
+
* Called by render() before options are processed, so all structure exists
|
|
159
|
+
* before _setOption receives values.
|
|
160
|
+
*/
|
|
161
|
+
build() {}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Process all options through _setOption with priority ordering.
|
|
165
|
+
* @private
|
|
166
|
+
*/
|
|
167
|
+
_processOptions() {
|
|
168
|
+
if (this.options) {
|
|
169
|
+
const priority = [];
|
|
170
|
+
const rest = [];
|
|
171
|
+
|
|
172
|
+
for (const entry of Object.entries(this.options)) {
|
|
173
|
+
(this.__priorityOptions.has(entry[0]) ? priority : rest).push(entry);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const [key, value] of priority) this._setOption(key, value);
|
|
177
|
+
for (const [key, value] of rest) this._setOption(key, value);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Orchestrates the render lifecycle: empty → build() → _processOptions() → rendered.
|
|
183
|
+
* Ensures subclass structure exists before options are processed.
|
|
184
|
+
*/
|
|
185
|
+
render() {
|
|
186
|
+
if (this.rendered) {
|
|
187
|
+
this.empty();
|
|
188
|
+
this.rendered = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
this.build();
|
|
193
|
+
this._processOptions();
|
|
194
|
+
} catch (error) {
|
|
195
|
+
this.processCleanup();
|
|
196
|
+
throw error;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
this.rendered = true;
|
|
200
|
+
this.onRendered?.();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Routes option changes through the static handlers chain, then standard routing.
|
|
205
|
+
*
|
|
206
|
+
* Walks the constructor chain collecting all handlers for the given key (deepest class
|
|
207
|
+
* first), then executes them in order. Each handler receives `next(value?)` — call it
|
|
208
|
+
* to continue to the next handler in the chain, or to standard routing when the chain
|
|
209
|
+
* is exhausted. Handlers that do not call `next` fully own the key.
|
|
210
|
+
* @param {string} key - Option property name being changed
|
|
211
|
+
* @param {*} value - New value being assigned to the option
|
|
212
|
+
* @private
|
|
213
|
+
*/
|
|
214
|
+
_setOption(key, value) {
|
|
215
|
+
const chain = [];
|
|
216
|
+
let klass = this.constructor;
|
|
217
|
+
while (klass && klass !== Component) {
|
|
218
|
+
if (Object.prototype.hasOwnProperty.call(klass, 'handlers') && klass.handlers?.[key]) {
|
|
219
|
+
chain.push(klass.handlers[key]);
|
|
220
|
+
}
|
|
221
|
+
klass = Object.getPrototypeOf(klass);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (chain.length > 0) {
|
|
225
|
+
let i = 0;
|
|
226
|
+
const next = (v = value) => {
|
|
227
|
+
if (i < chain.length) chain[i++].call(this, v, next);
|
|
228
|
+
else this._standardSetOption(key, v);
|
|
229
|
+
};
|
|
230
|
+
chain[i++].call(this, value, next);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
this._standardSetOption(key, value);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Standard option routing pipeline — event handlers, special keys, attributes, methods, properties.
|
|
239
|
+
* Called by _setOption when no handler claims the key, or when a handler calls next() past
|
|
240
|
+
* the end of its chain.
|
|
241
|
+
* @param {string} key - Option property name
|
|
242
|
+
* @param {*} value - Value to apply
|
|
243
|
+
* @private
|
|
244
|
+
*/
|
|
245
|
+
_standardSetOption(key, value) {
|
|
246
|
+
if (key === 'onRendered') {
|
|
247
|
+
this.onRendered = value;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (key.startsWith('on') && value) {
|
|
252
|
+
const targetEvent = key.replace(/^on/, '').toLowerCase();
|
|
253
|
+
if (this.on({ targetEvent, id: key, callback: value })) return;
|
|
254
|
+
if (typeof this[key] !== 'function') {
|
|
255
|
+
if (isDev) {
|
|
256
|
+
// eslint-disable-next-line no-console
|
|
257
|
+
console.warn(
|
|
258
|
+
`Component._setOption(): "${key}" starts with "on" but "${targetEvent}" is not a recognized event and there is no "${key}" method. Add "${targetEvent}" to registeredEvents to register it as a custom event.`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
// method route — e.g. onPointerPress, onHover
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (key === 'uniqueId') this.elem.id = typeof value === 'string' ? value : this.uniqueId;
|
|
267
|
+
else if (key === 'style') this.setStyle(value);
|
|
268
|
+
else if (key === 'attributes') this.setAttributes(value);
|
|
269
|
+
else if (this.__knownAttributes.has(key) || key.startsWith('aria-') || key.startsWith('data-')) {
|
|
270
|
+
if (value === null || value === undefined || value === false) this.elem.removeAttribute(key);
|
|
271
|
+
else this.elem.setAttribute(key, typeof value === 'boolean' ? '' : String(value));
|
|
272
|
+
} else if (typeof this[key] === 'function') {
|
|
273
|
+
if (_lifecycleMethods.has(key)) return;
|
|
274
|
+
this[key].call(this, value);
|
|
275
|
+
} else if (this.hasOwnProperty(key) && !_internalProperties.has(key)) this[key] = value;
|
|
276
|
+
else if (typeof this.elem[key] === 'function') {
|
|
277
|
+
if (value?.elem) value = value.elem;
|
|
278
|
+
|
|
279
|
+
this.elem[key].call(this.elem, value);
|
|
280
|
+
} else if (typeof value === 'function') this[key] = value;
|
|
281
|
+
else {
|
|
282
|
+
if (isDev && !(key in this.elem)) {
|
|
283
|
+
// eslint-disable-next-line no-console
|
|
284
|
+
console.warn(
|
|
285
|
+
`Component._setOption(): unknown key "${key}" assigned directly to elem. If intentional, add to knownAttributes.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
this.elem[key] = value;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Get parent Component instance.
|
|
294
|
+
* @returns {Component|undefined} Parent component or undefined if none
|
|
295
|
+
*/
|
|
296
|
+
get parent() {
|
|
297
|
+
return this.parentElem?._component;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get child Component instances.
|
|
302
|
+
* @returns {Component[]} Array of child components
|
|
303
|
+
*/
|
|
304
|
+
get children() {
|
|
305
|
+
return Array.from(this.elem.children).flatMap(({ _component }) => (_component ? [_component] : []));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Removes all child elements after running cleanup on descendant components.
|
|
310
|
+
* @returns {this} The component instance
|
|
311
|
+
*/
|
|
312
|
+
empty() {
|
|
313
|
+
const cleanupDescendants = element => {
|
|
314
|
+
for (const child of Array.from(element.children)) {
|
|
315
|
+
cleanupDescendants(child);
|
|
316
|
+
child._component?.processCleanup?.();
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
cleanupDescendants(this.elem);
|
|
320
|
+
this.elem.replaceChildren();
|
|
321
|
+
return this;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Register cleanup function called on disconnect or manual cleanup.
|
|
326
|
+
* Chains with any existing cleanup for the same ID (both will run).
|
|
327
|
+
* Use replaceCleanup() instead when rebinding (e.g., event handlers in loops).
|
|
328
|
+
* Initializes cleanup system and disconnect listener on first use.
|
|
329
|
+
* @param {string} id - Cleanup identifier
|
|
330
|
+
* @param {Function} cleanupFunction - Function called during cleanup
|
|
331
|
+
*/
|
|
332
|
+
addCleanup(id, cleanupFunction) {
|
|
333
|
+
this._initCleanup();
|
|
334
|
+
|
|
335
|
+
const existing = this.cleanup[id];
|
|
336
|
+
this.cleanup[id] = existing
|
|
337
|
+
? () => {
|
|
338
|
+
try {
|
|
339
|
+
existing();
|
|
340
|
+
} catch (error) {
|
|
341
|
+
// eslint-disable-next-line no-console
|
|
342
|
+
console.error('Cleanup error:', error);
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
cleanupFunction();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
// eslint-disable-next-line no-console
|
|
348
|
+
console.error('Cleanup error:', error);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
: cleanupFunction;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Register cleanup that replaces any existing cleanup for the same ID.
|
|
356
|
+
* Runs the previous cleanup immediately before storing the new one.
|
|
357
|
+
* @param {string} id - Cleanup identifier
|
|
358
|
+
* @param {Function} cleanupFunction - Function called during cleanup
|
|
359
|
+
*/
|
|
360
|
+
replaceCleanup(id, cleanupFunction) {
|
|
361
|
+
this._initCleanup();
|
|
362
|
+
|
|
363
|
+
this.cleanup[id]?.();
|
|
364
|
+
this.cleanup[id] = cleanupFunction;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Register cleanup that only runs on destroy(), not on disconnect.
|
|
369
|
+
* Use for element-level event listeners that must survive temporary DOM moves.
|
|
370
|
+
* @param {string} id - Cleanup identifier
|
|
371
|
+
* @param {Function} cleanupFunction - Function called during destroy
|
|
372
|
+
*/
|
|
373
|
+
replaceDestroyCleanup(id, cleanupFunction) {
|
|
374
|
+
if (!this._destroyCleanup) this._destroyCleanup = {};
|
|
375
|
+
this._destroyCleanup[id]?.();
|
|
376
|
+
this._destroyCleanup[id] = cleanupFunction;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** @private */
|
|
380
|
+
_initCleanup() {
|
|
381
|
+
if (!this.cleanup) {
|
|
382
|
+
this.cleanup = {};
|
|
383
|
+
this.on({ targetEvent: 'disconnected', callback: () => this.processCleanup(this.cleanup, true) });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Execute cleanup functions for this component and optionally children.
|
|
389
|
+
* @param {object} [cleanup] - Cleanup functions object (defaults to this.cleanup)
|
|
390
|
+
* @param {boolean} [rootCleanup] - Whether to recursively clean up child components
|
|
391
|
+
*/
|
|
392
|
+
processCleanup(cleanup = this.cleanup || {}, rootCleanup = false) {
|
|
393
|
+
if (rootCleanup) {
|
|
394
|
+
const cleanups = [];
|
|
395
|
+
const collectCleanups = children => {
|
|
396
|
+
children.forEach(child => {
|
|
397
|
+
if (!child) return;
|
|
398
|
+
|
|
399
|
+
if (child.cleanup) cleanups.push(child);
|
|
400
|
+
|
|
401
|
+
collectCleanups(child.children);
|
|
402
|
+
});
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
collectCleanups(this.children);
|
|
406
|
+
|
|
407
|
+
cleanups.forEach(child => {
|
|
408
|
+
child.processCleanup();
|
|
409
|
+
if (child._destroyCleanup) child.processCleanup(child._destroyCleanup);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const fns = Object.values(cleanup);
|
|
414
|
+
for (const key in cleanup) delete cleanup[key];
|
|
415
|
+
|
|
416
|
+
fns.forEach(cleanupFunction => {
|
|
417
|
+
try {
|
|
418
|
+
cleanupFunction();
|
|
419
|
+
} catch (error) {
|
|
420
|
+
// eslint-disable-next-line no-console
|
|
421
|
+
console.error('Cleanup error:', error);
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Destroys this component and all children. Runs all cleanup functions,
|
|
428
|
+
* clears registries, and removes from DOM.
|
|
429
|
+
*/
|
|
430
|
+
destroy() {
|
|
431
|
+
this.elemObserver?.disconnect();
|
|
432
|
+
|
|
433
|
+
// Disconnect descendant elemObservers before running cleanup
|
|
434
|
+
const disconnectDescendantObservers = children => {
|
|
435
|
+
children.forEach(child => {
|
|
436
|
+
child.elemObserver?.disconnect();
|
|
437
|
+
disconnectDescendantObservers(child.children);
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
disconnectDescendantObservers(this.children);
|
|
441
|
+
|
|
442
|
+
this.processCleanup(this.cleanup, true);
|
|
443
|
+
if (this._destroyCleanup) this.processCleanup(this._destroyCleanup);
|
|
444
|
+
this.elem?.remove();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Registers event listener with automatic cleanup and enhanced event processing.
|
|
449
|
+
*
|
|
450
|
+
* Provides specialized handling for input events (adds .value property),
|
|
451
|
+
* connection events (DOM observation), and common pointer events.
|
|
452
|
+
* @param {object} config - Event registration configuration
|
|
453
|
+
* @param {string} config.targetEvent - Event type to listen for
|
|
454
|
+
* @param {string} [config.id] - Unique identifier for cleanup management
|
|
455
|
+
* @param {Function} config.callback - Event handler function, bound to component context for input events
|
|
456
|
+
* @returns {boolean} True if event type was recognized and registered, false if unsupported
|
|
457
|
+
*/
|
|
458
|
+
on({ targetEvent, id = targetEvent, callback }) {
|
|
459
|
+
if (!callback) return false;
|
|
460
|
+
|
|
461
|
+
if (commonEvents.has(targetEvent) || this.__registeredEvents.has(targetEvent)) {
|
|
462
|
+
this.replaceDestroyCleanup(id, () => this.elem.removeEventListener(targetEvent, callback));
|
|
463
|
+
this.elem.addEventListener(targetEvent, callback);
|
|
464
|
+
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (inputEvents.has(targetEvent)) {
|
|
469
|
+
const _callback = event => {
|
|
470
|
+
event.value =
|
|
471
|
+
event.target.type === 'checkbox'
|
|
472
|
+
? event.target.checked
|
|
473
|
+
: (event?.detail?.value ?? event.target.value ?? this.options.value ?? this.elem.value);
|
|
474
|
+
|
|
475
|
+
callback.call(this, event);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
this.replaceDestroyCleanup(id, () => this.elem.removeEventListener(targetEvent, _callback));
|
|
479
|
+
this.elem.addEventListener(targetEvent, _callback);
|
|
480
|
+
|
|
481
|
+
return true;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (connectionEvents.has(targetEvent)) {
|
|
485
|
+
if (!this.elemObserver) {
|
|
486
|
+
this.elemObserver = observeElementConnection({
|
|
487
|
+
target: this.elem,
|
|
488
|
+
onConnected: event => this.emit('connected', event),
|
|
489
|
+
onDisconnected: event => this.emit('disconnected', event),
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
this.addCleanup('elemObserver', () => this.elemObserver?.disconnect());
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
this.replaceDestroyCleanup(id, () => this.removeEventListener(targetEvent, callback));
|
|
496
|
+
this.addEventListener(targetEvent, callback);
|
|
497
|
+
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Dispatch custom event on this component.
|
|
506
|
+
* @param {string} eventType - Event type name
|
|
507
|
+
* @param {*} [detail] - Event detail data
|
|
508
|
+
*/
|
|
509
|
+
emit(eventType, detail) {
|
|
510
|
+
// Two separate instances: a fired CustomEvent cannot be re-dispatched per spec.
|
|
511
|
+
this.dispatchEvent(new CustomEvent(eventType, { detail }));
|
|
512
|
+
this.elem.dispatchEvent(new CustomEvent(eventType, { detail }));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Applies styles via inline properties or scoped CSS injection with theme processing.
|
|
517
|
+
*
|
|
518
|
+
* Object styles are applied as inline properties. String/function styles are
|
|
519
|
+
* processed through the theme system and injected as scoped CSS.
|
|
520
|
+
* @param {string|object|Function} styles - Style definition: object for inline styles, string/function for scoped CSS
|
|
521
|
+
*/
|
|
522
|
+
styles(styles) {
|
|
523
|
+
if (!styles) return;
|
|
524
|
+
if (typeof styles === 'object') {
|
|
525
|
+
this.setStyle(styles);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const themedStyles = themeStyles({ styles, scope: `.${this.uniqueId}` });
|
|
530
|
+
|
|
531
|
+
if (typeof themedStyles === 'object') {
|
|
532
|
+
this.setStyle(themedStyles);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
appendStyles(themedStyles, this.uniqueId);
|
|
537
|
+
|
|
538
|
+
this.replaceDestroyCleanup(this.uniqueId, () => {
|
|
539
|
+
document.getElementById(this.uniqueId)?.remove();
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Register pointer hover handler with move tracking during hover.
|
|
545
|
+
* Callback bound to component context and called on enter and during move.
|
|
546
|
+
* @param {Function} [callback] - Handler called on pointerenter and pointermove
|
|
547
|
+
*/
|
|
548
|
+
onHover(callback = () => {}) {
|
|
549
|
+
callback = callback.bind(this);
|
|
550
|
+
|
|
551
|
+
const pointerEnter = event => {
|
|
552
|
+
callback(event);
|
|
553
|
+
|
|
554
|
+
this.elem.addEventListener('pointermove', callback, true);
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const pointerLeave = () => {
|
|
558
|
+
this.elem.removeEventListener('pointermove', callback, true);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
this.replaceDestroyCleanup('onHover', () => {
|
|
562
|
+
this.elem.removeEventListener('pointerenter', pointerEnter);
|
|
563
|
+
this.elem.removeEventListener('pointerleave', pointerLeave);
|
|
564
|
+
this.elem.removeEventListener('pointercancel', pointerLeave);
|
|
565
|
+
this.elem.removeEventListener('pointerout', pointerLeave);
|
|
566
|
+
this.elem.removeEventListener('pointermove', callback, true);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
this.elem.addEventListener('pointerenter', pointerEnter);
|
|
570
|
+
this.elem.addEventListener('pointerleave', pointerLeave);
|
|
571
|
+
this.elem.addEventListener('pointercancel', pointerLeave);
|
|
572
|
+
this.elem.addEventListener('pointerout', pointerLeave);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Register pointer press handler. Fires on pointerdown for immediate, reliable response
|
|
577
|
+
* across all contexts including scroll containers and modal dialogs.
|
|
578
|
+
* @param {Function} [callback] - Handler called on pointerdown on this element
|
|
579
|
+
*/
|
|
580
|
+
onPointerPress(callback = () => {}) {
|
|
581
|
+
callback = callback.bind(this);
|
|
582
|
+
this.replaceDestroyCleanup('onPointerPress', () => this.elem.removeEventListener('pointerdown', callback));
|
|
583
|
+
this.elem.addEventListener('pointerdown', callback);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Get prototype chain from this instance up to Object.
|
|
588
|
+
* @param {object} [targetClass] - Starting point for traversal (defaults to this)
|
|
589
|
+
* @returns {object[]} Array of prototype objects in inheritance order
|
|
590
|
+
*/
|
|
591
|
+
ancestry(targetClass = this) {
|
|
592
|
+
if (!targetClass || targetClass?.constructor?.name === 'Object') return [];
|
|
593
|
+
|
|
594
|
+
return [targetClass, ...this.ancestry(Object.getPrototypeOf(targetClass))];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export default Component;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import Component from './Component.js';
|
|
2
|
+
|
|
3
|
+
export const __lld_api = `
|
|
4
|
+
destructiveRender() -> boolean | renders a component twice; returns true if child count matches (1) — second render cleared the first
|
|
5
|
+
optionReactionFires() -> number | assigns to option after render; returns how many times _setOption was called (1 = reactive)
|
|
6
|
+
buildBeforeOptions() -> boolean | verifies build() DOM structure exists when _setOption runs (true = ordering is correct)
|
|
7
|
+
priorityRunsFirst() -> boolean | textContent (priority option) runs before style (non-priority); returns true if correct order
|
|
8
|
+
replaceCleanupRunsOnce() -> number | replaceCleanup called 3x with same key; returns count of fns that ran at processCleanup time (1 = no accumulation)
|
|
9
|
+
`;
|
|
10
|
+
|
|
11
|
+
export const destructiveRender = () => {
|
|
12
|
+
class TestComp extends Component {
|
|
13
|
+
build() {
|
|
14
|
+
this.elem.append(document.createElement('span'));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const comp = new TestComp({ autoRender: false });
|
|
18
|
+
comp.render();
|
|
19
|
+
const afterFirst = comp.elem?.children?.length ?? 0;
|
|
20
|
+
comp.render();
|
|
21
|
+
const afterSecond = comp.elem?.children?.length ?? 0;
|
|
22
|
+
return afterFirst === 1 && afterSecond === 1;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const optionReactionFires = () => {
|
|
26
|
+
let reactions = 0;
|
|
27
|
+
class TestComp extends Component {
|
|
28
|
+
_setOption(key, value) {
|
|
29
|
+
if (key === 'textContent' && this.rendered) reactions++;
|
|
30
|
+
super._setOption(key, value);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const comp = new TestComp({ autoRender: false });
|
|
34
|
+
comp.render();
|
|
35
|
+
const baseline = reactions;
|
|
36
|
+
comp.options.textContent = 'hello';
|
|
37
|
+
return reactions - baseline;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const buildBeforeOptions = () => {
|
|
41
|
+
let buildRanBeforeOption = false;
|
|
42
|
+
let buildRan = false;
|
|
43
|
+
class TestComp extends Component {
|
|
44
|
+
build() {
|
|
45
|
+
buildRan = true;
|
|
46
|
+
this.elem.append(document.createElement('span'));
|
|
47
|
+
}
|
|
48
|
+
_setOption(key, value) {
|
|
49
|
+
if (key === 'textContent') buildRanBeforeOption = buildRan;
|
|
50
|
+
super._setOption(key, value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const comp = new TestComp({ textContent: 'hello', autoRender: false });
|
|
54
|
+
comp.render();
|
|
55
|
+
return buildRanBeforeOption;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const priorityRunsFirst = () => {
|
|
59
|
+
const order = [];
|
|
60
|
+
class TestComp extends Component {
|
|
61
|
+
build() {}
|
|
62
|
+
_setOption(key, value) {
|
|
63
|
+
if (key === 'textContent' || key === 'style') order.push(key);
|
|
64
|
+
super._setOption(key, value);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// textContent is a priority option; style is not
|
|
68
|
+
const comp = new TestComp({ textContent: 'hello', style: { color: 'red' }, autoRender: false });
|
|
69
|
+
comp.render();
|
|
70
|
+
return order[0] === 'textContent';
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const replaceCleanupRunsOnce = () => {
|
|
74
|
+
const comp = new Component({ autoRender: false });
|
|
75
|
+
let count = 0;
|
|
76
|
+
comp.replaceCleanup('test', () => {
|
|
77
|
+
count++;
|
|
78
|
+
});
|
|
79
|
+
comp.replaceCleanup('test', () => {
|
|
80
|
+
count++;
|
|
81
|
+
}); // runs previous = 1
|
|
82
|
+
comp.replaceCleanup('test', () => {
|
|
83
|
+
count++;
|
|
84
|
+
}); // runs previous = 2, stores latest
|
|
85
|
+
const countBeforeCleanup = count;
|
|
86
|
+
comp.processCleanup(); // runs only the latest = 3
|
|
87
|
+
return count - countBeforeCleanup; // 1 — only latest ran at cleanup time
|
|
88
|
+
};
|