@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.
Files changed (147) hide show
  1. package/Component/Component.js +598 -0
  2. package/Component/Component.scenarios.js +88 -0
  3. package/Component/Component.test.js +717 -0
  4. package/Component/README.md +455 -0
  5. package/Component/index.js +3 -0
  6. package/Component/observeElementConnection.js +52 -0
  7. package/Component/observeElementConnection.test.js +121 -0
  8. package/Elem/Elem.js +304 -0
  9. package/Elem/Elem.test.js +679 -0
  10. package/Elem/README.md +373 -0
  11. package/Elem/index.js +1 -0
  12. package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
  13. package/LICENSE +21 -0
  14. package/README.md +413 -0
  15. package/components/BottomSheet/BottomSheet.js +192 -0
  16. package/components/BottomSheet/BottomSheet.lld.md +25 -0
  17. package/components/BottomSheet/README.md +66 -0
  18. package/components/BottomSheet/index.js +1 -0
  19. package/components/Button/Button.js +53 -0
  20. package/components/Button/Button.lld.md +21 -0
  21. package/components/Button/index.js +1 -0
  22. package/components/Calendar/Calendar.js +720 -0
  23. package/components/Calendar/Calendar.lld.md +22 -0
  24. package/components/Calendar/CalendarEvent.js +102 -0
  25. package/components/Calendar/Toolbar.js +78 -0
  26. package/components/Calendar/index.js +2 -0
  27. package/components/Calendar/utils.js +56 -0
  28. package/components/Code/Code.js +84 -0
  29. package/components/Code/Code.lld.md +21 -0
  30. package/components/Code/index.js +1 -0
  31. package/components/ColorPicker/ColorPicker.js +445 -0
  32. package/components/ColorPicker/ColorPicker.lld.md +21 -0
  33. package/components/ColorPicker/index.js +1 -0
  34. package/components/ColorPicker/svg.js +5 -0
  35. package/components/Dialog/Dialog.js +278 -0
  36. package/components/Dialog/Dialog.lld.md +20 -0
  37. package/components/Dialog/README.md +96 -0
  38. package/components/Dialog/index.js +1 -0
  39. package/components/Form/Form.js +257 -0
  40. package/components/Form/Form.lld.md +21 -0
  41. package/components/Form/README.md +87 -0
  42. package/components/Form/index.js +1 -0
  43. package/components/Icon/Icon.js +54 -0
  44. package/components/Icon/Icon.lld.md +21 -0
  45. package/components/Icon/index.js +1 -0
  46. package/components/Input/Input.js +173 -0
  47. package/components/Input/Input.lld.md +28 -0
  48. package/components/Input/README.md +97 -0
  49. package/components/Input/index.js +2 -0
  50. package/components/Input/utils.js +122 -0
  51. package/components/Keyboard/Key.js +38 -0
  52. package/components/Keyboard/Keyboard.js +173 -0
  53. package/components/Keyboard/Keyboard.lld.md +21 -0
  54. package/components/Keyboard/index.js +1 -0
  55. package/components/Label/Label.js +214 -0
  56. package/components/Label/Label.lld.md +20 -0
  57. package/components/Label/index.js +1 -0
  58. package/components/Link/Link.js +43 -0
  59. package/components/Link/Link.lld.md +15 -0
  60. package/components/Link/index.js +1 -0
  61. package/components/List/List.js +82 -0
  62. package/components/List/List.lld.md +19 -0
  63. package/components/List/index.js +1 -0
  64. package/components/Menu/Menu.js +93 -0
  65. package/components/Menu/Menu.lld.md +15 -0
  66. package/components/Menu/index.js +1 -0
  67. package/components/Notify/Notify.js +96 -0
  68. package/components/Notify/Notify.lld.md +20 -0
  69. package/components/Notify/index.js +1 -0
  70. package/components/Page/Page.js +67 -0
  71. package/components/Page/Page.lld.md +20 -0
  72. package/components/Page/index.js +1 -0
  73. package/components/Popover/Popover.js +175 -0
  74. package/components/Popover/Popover.lld.md +19 -0
  75. package/components/Popover/index.js +1 -0
  76. package/components/RadioButton/RadioButton.js +108 -0
  77. package/components/RadioButton/RadioButton.lld.md +15 -0
  78. package/components/RadioButton/index.js +1 -0
  79. package/components/Router/README.md +160 -0
  80. package/components/Router/Router.js +150 -0
  81. package/components/Router/Router.lld.md +31 -0
  82. package/components/Router/View.js +15 -0
  83. package/components/Router/index.js +2 -0
  84. package/components/Router/utils.js +17 -0
  85. package/components/Select/README.md +88 -0
  86. package/components/Select/Select.js +74 -0
  87. package/components/Select/Select.lld.md +20 -0
  88. package/components/Select/index.js +1 -0
  89. package/components/Table/README.md +94 -0
  90. package/components/Table/Table.js +171 -0
  91. package/components/Table/Table.lld.md +21 -0
  92. package/components/Table/index.js +1 -0
  93. package/components/TagList/Tag.js +84 -0
  94. package/components/TagList/TagList.js +118 -0
  95. package/components/TagList/TagList.lld.md +30 -0
  96. package/components/TagList/design.excalidraw.png +0 -0
  97. package/components/TagList/index.js +2 -0
  98. package/components/Tooltip/Tooltip.js +139 -0
  99. package/components/Tooltip/Tooltip.lld.md +22 -0
  100. package/components/Tooltip/index.js +1 -0
  101. package/components/TooltipWrapper/TooltipWrapper.js +89 -0
  102. package/components/TooltipWrapper/TooltipWrapper.lld.md +21 -0
  103. package/components/TooltipWrapper/index.js +1 -0
  104. package/components/Whiteboard/Whiteboard.js +198 -0
  105. package/components/Whiteboard/Whiteboard.lld.md +35 -0
  106. package/components/Whiteboard/index.js +1 -0
  107. package/components/index.js +27 -0
  108. package/eslint.config.cjs +118 -0
  109. package/index.d.ts +635 -0
  110. package/index.js +19 -0
  111. package/package.json +123 -0
  112. package/plugins/asText.js +38 -0
  113. package/plugins/loadPlugins.js +5 -0
  114. package/plugins/markdownLoader.js +121 -0
  115. package/prettier.config.cjs +7 -0
  116. package/spellcheck.config.cjs +227 -0
  117. package/styled/README.md +329 -0
  118. package/styled/appendStyles.js +26 -0
  119. package/styled/appendStyles.test.js +45 -0
  120. package/styled/index.js +4 -0
  121. package/styled/shimCSS.js +31 -0
  122. package/styled/shimCSS.test.js +103 -0
  123. package/styled/styled.js +91 -0
  124. package/styled/styled.test.js +586 -0
  125. package/styled/themeStyles.js +36 -0
  126. package/styled/themeStyles.test.js +135 -0
  127. package/test-setup.js +123 -0
  128. package/theme/.test.js +69 -0
  129. package/theme/README.md +607 -0
  130. package/theme/button.js +100 -0
  131. package/theme/code.js +123 -0
  132. package/theme/colors.js +42 -0
  133. package/theme/fonts.js +42 -0
  134. package/theme/index.js +33 -0
  135. package/theme/input.js +64 -0
  136. package/theme/page.js +208 -0
  137. package/theme/scrollbar.js +24 -0
  138. package/theme/table.js +53 -0
  139. package/utils/README.md +176 -0
  140. package/utils/browser.js +92 -0
  141. package/utils/class.js +30 -0
  142. package/utils/color.js +81 -0
  143. package/utils/data.js +164 -0
  144. package/utils/element.js +55 -0
  145. package/utils/index.js +7 -0
  146. package/utils/rand.js +12 -0
  147. 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
+ };