@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,717 @@
1
+ import { screen, within } from '@testing-library/dom';
2
+ import userEvent from '@testing-library/user-event';
3
+
4
+ import { Oxject } from '@vanilla-bean/oxject';
5
+ import { Elem } from '../Elem';
6
+ import Component from './Component';
7
+
8
+ const user = userEvent.setup();
9
+
10
+ describe('Component', () => {
11
+ let component;
12
+
13
+ afterEach(() => {
14
+ component?.destroy?.();
15
+ component = null;
16
+ document.body.replaceChildren();
17
+ });
18
+
19
+ describe('construction', () => {
20
+ test('creates default component with div element', () => {
21
+ component = new Component();
22
+ expect(component.tag).toBe('div');
23
+ expect(component.elem.tagName).toBe('DIV');
24
+ expect(component.elem._elem).toBe(component);
25
+ });
26
+
27
+ test('inherits all Elem functionality', () => {
28
+ component = new Component({
29
+ tag: 'button',
30
+ textContent: 'Click Me',
31
+ className: 'test-btn',
32
+ attributes: { 'data-testid': 'test-button' },
33
+ });
34
+ document.body.appendChild(component.elem);
35
+
36
+ const button = screen.getByTestId('test-button');
37
+ expect(button).toHaveTextContent('Click Me');
38
+ expect(button).toHaveClass('test-btn');
39
+ expect(button.tagName).toBe('BUTTON');
40
+ });
41
+
42
+ test('handles children in constructor like Elem', () => {
43
+ const child1 = new Component({ tag: 'span', textContent: 'Child 1' });
44
+ const child2 = new Component({ tag: 'span', textContent: 'Child 2' });
45
+
46
+ component = new Component(
47
+ {
48
+ tag: 'div',
49
+ attributes: { 'data-testid': 'parent' },
50
+ },
51
+ child1,
52
+ child2,
53
+ );
54
+ document.body.appendChild(component.elem);
55
+
56
+ expect(screen.getByText('Child 1')).toBeInTheDocument();
57
+ expect(screen.getByText('Child 2')).toBeInTheDocument();
58
+
59
+ const parent = screen.getByTestId('parent');
60
+ const spans = within(parent).getAllByRole('generic');
61
+ expect(spans[0]).toHaveTextContent('Child 1');
62
+ expect(spans[1]).toHaveTextContent('Child 2');
63
+ });
64
+
65
+ test('handles styles option as object', () => {
66
+ component = new Component({
67
+ styles: { color: 'red', fontSize: '16px' },
68
+ });
69
+ document.body.appendChild(component.elem);
70
+
71
+ expect(component.elem).toHaveStyle({
72
+ color: 'red',
73
+ fontSize: '16px',
74
+ });
75
+ });
76
+ });
77
+
78
+ describe('rendering system', () => {
79
+ test('has render method', () => {
80
+ component = new Component();
81
+ expect(typeof component.render).toBe('function');
82
+ });
83
+
84
+ test('render processes component options', () => {
85
+ component = new Component({
86
+ tag: 'p',
87
+ textContent: 'Test content',
88
+ styles: { color: 'red', padding: '10px' },
89
+ });
90
+ document.body.appendChild(component.elem);
91
+
92
+ expect(screen.getByText('Test content')).toBeInTheDocument();
93
+ expect(component.elem).toHaveStyle({ color: 'red', padding: '10px' });
94
+
95
+ expect(() => component.render()).not.toThrow();
96
+ expect(component.elem).toHaveStyle({ color: 'red', padding: '10px' });
97
+ });
98
+
99
+ test('render method exists and is callable', () => {
100
+ component = new Component();
101
+ expect(() => component.render()).not.toThrow();
102
+ });
103
+ });
104
+
105
+ describe('event system integration', () => {
106
+ test('handles DOM events like Elem', async () => {
107
+ const clickHandler = mock();
108
+
109
+ component = new Component({
110
+ tag: 'button',
111
+ textContent: 'Click me',
112
+ });
113
+
114
+ component.elem.onclick = clickHandler;
115
+ document.body.appendChild(component.elem);
116
+
117
+ await user.click(screen.getByRole('button'));
118
+ expect(clickHandler).toHaveBeenCalledTimes(1);
119
+ });
120
+
121
+ test('can add event listeners via addEventListener', async () => {
122
+ const clickHandler = mock();
123
+
124
+ component = new Component({
125
+ tag: 'button',
126
+ textContent: 'Click me',
127
+ });
128
+ document.body.appendChild(component.elem);
129
+
130
+ component.elem.addEventListener('click', clickHandler);
131
+
132
+ await user.click(screen.getByRole('button'));
133
+ expect(clickHandler).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ test('has on method for event handling', () => {
137
+ component = new Component();
138
+ expect(typeof component.on).toBe('function');
139
+ });
140
+ });
141
+
142
+ describe('method chaining and fluent API', () => {
143
+ test('maintains Elem chaining capabilities', () => {
144
+ component = new Component({ tag: 'div' })
145
+ .addClass('component', 'active')
146
+ .setStyle({ padding: '10px', margin: '5px' })
147
+ .setAttributes({ role: 'main', 'aria-label': 'Main content' });
148
+
149
+ document.body.appendChild(component.elem);
150
+
151
+ expect(component.elem).toHaveClass('component', 'active');
152
+ expect(component.elem).toHaveStyle({ padding: '10px', margin: '5px' });
153
+ expect(component.elem).toHaveAttribute('role', 'main');
154
+ expect(component.elem).toHaveAttribute('aria-label', 'Main content');
155
+ });
156
+
157
+ test('can chain methods after render', () => {
158
+ component = new Component({ tag: 'span', textContent: 'Test' });
159
+ component.render();
160
+ component.addClass('rendered');
161
+
162
+ expect(component.elem).toHaveClass('rendered');
163
+ expect(component.elem.textContent).toBe('Test');
164
+ });
165
+ });
166
+
167
+ describe('inheritance and polymorphism', () => {
168
+ test('can be extended by custom components', () => {
169
+ class CustomComponent extends Component {
170
+ constructor(options = {}) {
171
+ super({ tag: 'article', ...options });
172
+ this.customProperty = 'custom';
173
+ }
174
+
175
+ customMethod() {
176
+ return 'custom behavior';
177
+ }
178
+ }
179
+
180
+ const custom = new CustomComponent({
181
+ textContent: 'Custom content',
182
+ attributes: { 'data-testid': 'custom' },
183
+ });
184
+ document.body.appendChild(custom.elem);
185
+
186
+ expect(custom.elem.tagName).toBe('ARTICLE');
187
+ expect(custom.customProperty).toBe('custom');
188
+ expect(custom.customMethod()).toBe('custom behavior');
189
+ expect(screen.getByTestId('custom')).toHaveTextContent('Custom content');
190
+
191
+ custom.destroy?.();
192
+ });
193
+
194
+ test('extended components maintain Component functionality', () => {
195
+ class ContextAwareComponent extends Component {
196
+ constructor(options = {}) {
197
+ super(options);
198
+ }
199
+ }
200
+
201
+ const custom = new ContextAwareComponent();
202
+
203
+ expect(typeof custom.render).toBe('function');
204
+ expect(typeof custom.on).toBe('function');
205
+ expect(custom.elem).toBeDefined();
206
+
207
+ custom.destroy?.();
208
+ });
209
+ });
210
+
211
+ describe('edge cases and error handling', () => {
212
+ test('handles null context gracefully', () => {
213
+ expect(() => {
214
+ component = new Component({ context: null });
215
+ }).not.toThrow();
216
+ });
217
+
218
+ test('handles undefined styles gracefully', () => {
219
+ expect(() => {
220
+ component = new Component({ styles: undefined });
221
+ }).not.toThrow();
222
+ });
223
+
224
+ test('handles empty styles object', () => {
225
+ expect(() => {
226
+ component = new Component({ styles: {} });
227
+ }).not.toThrow();
228
+ });
229
+
230
+ test('handles invalid options gracefully', () => {
231
+ expect(() => {
232
+ component = new Component({ invalidOption: 'test' });
233
+ }).not.toThrow();
234
+ });
235
+ });
236
+
237
+ describe('lifecycle management', () => {
238
+ test('component can be destroyed if destroy method exists', () => {
239
+ component = new Component();
240
+
241
+ if (typeof component.destroy === 'function') {
242
+ expect(() => component.destroy()).not.toThrow();
243
+ } else {
244
+ expect(component).toBeDefined();
245
+ }
246
+ });
247
+
248
+ test('handles multiple render calls', () => {
249
+ component = new Component({
250
+ tag: 'p',
251
+ textContent: 'Test',
252
+ });
253
+ document.body.appendChild(component.elem);
254
+
255
+ expect(() => {
256
+ component.render();
257
+ component.render();
258
+ component.render();
259
+ }).not.toThrow();
260
+
261
+ expect(screen.getByText('Test')).toBeInTheDocument();
262
+ });
263
+ });
264
+
265
+ describe('integration scenarios', () => {
266
+ test('creates basic component structure', () => {
267
+ component = new Component({
268
+ tag: 'main',
269
+ className: 'app-container',
270
+ attributes: { 'data-testid': 'app' },
271
+ styles: {
272
+ backgroundColor: 'gray',
273
+ color: 'blue',
274
+ padding: '20px',
275
+ },
276
+ });
277
+
278
+ const header = new Component({
279
+ tag: 'header',
280
+ className: 'app-header',
281
+ });
282
+
283
+ const title = new Component({
284
+ tag: 'h1',
285
+ textContent: 'App Title',
286
+ attributes: { 'data-testid': 'title' },
287
+ });
288
+
289
+ header.append(title);
290
+ component.append(header);
291
+ document.body.appendChild(component.elem);
292
+
293
+ const app = screen.getByTestId('app');
294
+ expect(app).toHaveClass('app-container');
295
+ expect(app).toHaveStyle({
296
+ backgroundColor: 'gray',
297
+ color: 'blue',
298
+ padding: '20px',
299
+ });
300
+
301
+ expect(screen.getByTestId('title')).toHaveTextContent('App Title');
302
+
303
+ header.destroy?.();
304
+ });
305
+
306
+ test('demonstrates component composition', () => {
307
+ class Button extends Component {
308
+ constructor({ variant = 'primary', ...options } = {}) {
309
+ super({
310
+ tag: 'button',
311
+ className: `btn btn-${variant}`,
312
+ ...options,
313
+ });
314
+ }
315
+ }
316
+
317
+ class Card extends Component {
318
+ constructor(options = {}) {
319
+ super({
320
+ tag: 'div',
321
+ className: 'card',
322
+ ...options,
323
+ });
324
+ }
325
+ }
326
+
327
+ const card = new Card({
328
+ attributes: { 'data-testid': 'card' },
329
+ });
330
+
331
+ const primaryBtn = new Button({
332
+ variant: 'primary',
333
+ textContent: 'Primary Action',
334
+ attributes: { 'data-testid': 'primary-btn' },
335
+ });
336
+
337
+ const secondaryBtn = new Button({
338
+ variant: 'secondary',
339
+ textContent: 'Secondary Action',
340
+ attributes: { 'data-testid': 'secondary-btn' },
341
+ });
342
+
343
+ card.append(primaryBtn, secondaryBtn);
344
+ document.body.appendChild(card.elem);
345
+
346
+ const cardElement = screen.getByTestId('card');
347
+ expect(cardElement).toHaveClass('card');
348
+
349
+ const primaryButton = screen.getByTestId('primary-btn');
350
+ expect(primaryButton).toHaveClass('btn', 'btn-primary');
351
+ expect(primaryButton).toHaveTextContent('Primary Action');
352
+
353
+ const secondaryButton = screen.getByTestId('secondary-btn');
354
+ expect(secondaryButton).toHaveClass('btn', 'btn-secondary');
355
+ expect(secondaryButton).toHaveTextContent('Secondary Action');
356
+
357
+ card.destroy?.();
358
+ primaryBtn.destroy?.();
359
+ secondaryBtn.destroy?.();
360
+ });
361
+ });
362
+
363
+ describe('Component-specific functionality', () => {
364
+ test('processes options during construction', () => {
365
+ const spy = spyOn(Component.prototype, 'render');
366
+
367
+ component = new Component({
368
+ tag: 'section',
369
+ className: 'test-section',
370
+ textContent: 'Test Content',
371
+ });
372
+
373
+ expect(spy).toHaveBeenCalled();
374
+ expect(component.elem.tagName).toBe('SECTION');
375
+ expect(component.elem).toHaveClass('test-section');
376
+ expect(component.elem.textContent).toBe('Test Content');
377
+ });
378
+
379
+ test('handles complex option processing', () => {
380
+ const options = {
381
+ tag: 'article',
382
+ className: 'article-class',
383
+ attributes: { 'data-id': '123', role: 'article' },
384
+ style: { margin: '10px', padding: '20px' },
385
+ textContent: 'Article content',
386
+ };
387
+
388
+ component = new Component(options);
389
+ document.body.appendChild(component.elem);
390
+
391
+ expect(component.elem.tagName).toBe('ARTICLE');
392
+ expect(component.elem).toHaveClass('article-class');
393
+ expect(component.elem).toHaveAttribute('data-id', '123');
394
+ expect(component.elem).toHaveAttribute('role', 'article');
395
+ expect(component.elem).toHaveStyle({ margin: '10px', padding: '20px' });
396
+ expect(component.elem.textContent).toBe('Article content');
397
+ });
398
+ });
399
+
400
+ describe('reactive options', () => {
401
+ test('option assigned after render triggers _setOption', () => {
402
+ let reactions = 0;
403
+
404
+ class TestComp extends Component {
405
+ _setOption(key, value) {
406
+ if (key === 'textContent' && this.rendered) reactions++;
407
+ super._setOption(key, value);
408
+ }
409
+ }
410
+
411
+ const comp = new TestComp({ autoRender: false });
412
+ comp.render();
413
+
414
+ comp.options.textContent = 'hello';
415
+ expect(reactions).toBe(1);
416
+
417
+ comp.options.textContent = 'world';
418
+ expect(reactions).toBe(2);
419
+
420
+ comp.destroy();
421
+ });
422
+
423
+ test('option assigned before render is picked up by _processOptions', () => {
424
+ const comp = new Component({ autoRender: false });
425
+ comp.options.textContent = 'pre-render';
426
+ comp.render();
427
+
428
+ expect(comp.elem.textContent).toBe('pre-render');
429
+ comp.destroy();
430
+ });
431
+
432
+ test('subscriber as option value wires reactively', () => {
433
+ const ctx = new Oxject({ count: 0 });
434
+ const comp = new Component({
435
+ textContent: ctx.subscriber('count', n => `Count: ${n}`),
436
+ appendTo: document.body,
437
+ });
438
+
439
+ expect(comp.elem.textContent).toContain('0');
440
+
441
+ ctx.count = 5;
442
+ expect(comp.elem.textContent).toContain('5');
443
+
444
+ ctx.count = 10;
445
+ expect(comp.elem.textContent).toContain('10');
446
+
447
+ comp.destroy();
448
+ ctx.destroy();
449
+ });
450
+
451
+ test('subscriber cleanup runs on component destroy', () => {
452
+ const ctx = new Oxject({ name: 'Alice' });
453
+ const parser = mock(v => v.toUpperCase());
454
+
455
+ const comp = new Component({
456
+ textContent: ctx.subscriber('name', parser),
457
+ appendTo: document.body,
458
+ });
459
+
460
+ const callsAfterMount = parser.mock.calls.length;
461
+
462
+ ctx.name = 'Bob';
463
+ expect(parser.mock.calls.length).toBeGreaterThan(callsAfterMount);
464
+
465
+ comp.destroy();
466
+ const callsAfterDestroy = parser.mock.calls.length;
467
+
468
+ ctx.name = 'Charlie';
469
+ expect(parser.mock.calls.length).toBe(callsAfterDestroy);
470
+
471
+ ctx.destroy();
472
+ });
473
+ });
474
+
475
+ describe('lifecycle ordering', () => {
476
+ test('build() runs before _processOptions', () => {
477
+ let buildRanBeforeOption = false;
478
+ let buildRan = false;
479
+
480
+ class TestComp extends Component {
481
+ build() {
482
+ buildRan = true;
483
+ new Elem({ tag: 'span', appendTo: this });
484
+ }
485
+
486
+ _setOption(key, value) {
487
+ if (key === 'textContent') buildRanBeforeOption = buildRan;
488
+ super._setOption(key, value);
489
+ }
490
+ }
491
+
492
+ const comp = new TestComp({ textContent: 'hello', autoRender: false });
493
+ comp.render();
494
+ expect(buildRanBeforeOption).toBe(true);
495
+ comp.destroy();
496
+ });
497
+
498
+ test('re-render clears previous children via empty()', () => {
499
+ class TestComp extends Component {
500
+ build() {
501
+ new Elem({ tag: 'span', appendTo: this });
502
+ }
503
+ }
504
+
505
+ const comp = new TestComp({ autoRender: false });
506
+ comp.render();
507
+ // eslint-disable-next-line testing-library/no-node-access
508
+ expect(comp.elem.childElementCount).toBe(1);
509
+
510
+ comp.render();
511
+ // eslint-disable-next-line testing-library/no-node-access
512
+ expect(comp.elem.childElementCount).toBe(1);
513
+
514
+ comp.destroy();
515
+ });
516
+
517
+ test('priority options run before non-priority options', () => {
518
+ const order = [];
519
+
520
+ class TestComp extends Component {
521
+ _setOption(key, value) {
522
+ if (key === 'textContent' || key === 'style') order.push(key);
523
+ super._setOption(key, value);
524
+ }
525
+ }
526
+
527
+ const comp = new TestComp({ style: { color: 'red' }, textContent: 'hello', autoRender: false });
528
+ comp.render();
529
+
530
+ expect(order[0]).toBe('textContent');
531
+ comp.destroy();
532
+ });
533
+ });
534
+
535
+ describe('static handlers', () => {
536
+ test('handler is called for matching option key on render', () => {
537
+ const called = [];
538
+
539
+ class TestComp extends Component {
540
+ static handlers = {
541
+ label(value) {
542
+ called.push(value);
543
+ },
544
+ };
545
+ }
546
+
547
+ const comp = new TestComp({ label: 'hello' });
548
+ expect(called).toContain('hello');
549
+ comp.destroy();
550
+ });
551
+
552
+ test('handler is called for matching option key on reactive update', () => {
553
+ const called = [];
554
+
555
+ class TestComp extends Component {
556
+ static handlers = {
557
+ label(value) {
558
+ called.push(value);
559
+ },
560
+ };
561
+ }
562
+
563
+ const comp = new TestComp({ label: 'first' });
564
+ comp.options.label = 'second';
565
+ expect(called).toContain('second');
566
+ comp.destroy();
567
+ });
568
+
569
+ test('subclass handler shadows parent handler', () => {
570
+ const parentCalls = [];
571
+ const childCalls = [];
572
+
573
+ class Parent extends Component {
574
+ static handlers = {
575
+ label(value) {
576
+ parentCalls.push(value);
577
+ },
578
+ };
579
+ }
580
+
581
+ class Child extends Parent {
582
+ static handlers = {
583
+ label(value) {
584
+ childCalls.push(value);
585
+ },
586
+ };
587
+ }
588
+
589
+ const comp = new Child({ label: 'test' });
590
+ expect(childCalls).toContain('test');
591
+ expect(parentCalls).not.toContain('test');
592
+ comp.destroy();
593
+ });
594
+
595
+ test('handler can call parent handler explicitly', () => {
596
+ const parentCalls = [];
597
+ const childCalls = [];
598
+
599
+ class Parent extends Component {
600
+ static handlers = {
601
+ label(value) {
602
+ parentCalls.push(value);
603
+ },
604
+ };
605
+ }
606
+
607
+ class Child extends Parent {
608
+ static handlers = {
609
+ label(value) {
610
+ childCalls.push(value);
611
+ Parent.handlers.label.call(this, value);
612
+ },
613
+ };
614
+ }
615
+
616
+ const comp = new Child({ label: 'test' });
617
+ expect(childCalls).toContain('test');
618
+ expect(parentCalls).toContain('test');
619
+ comp.destroy();
620
+ });
621
+ });
622
+
623
+ describe('cleanup system', () => {
624
+ test('addCleanup chains — both functions run on processCleanup', () => {
625
+ const comp = new Component({ autoRender: false });
626
+ const calls = [];
627
+
628
+ comp.addCleanup('test', () => calls.push('first'));
629
+ comp.addCleanup('test', () => calls.push('second'));
630
+
631
+ comp.processCleanup();
632
+ expect(calls).toContain('first');
633
+ expect(calls).toContain('second');
634
+ });
635
+
636
+ test('replaceCleanup runs previous immediately and stores the new one', () => {
637
+ const comp = new Component({ autoRender: false });
638
+ let count = 0;
639
+
640
+ comp.replaceCleanup('test', () => count++);
641
+ comp.replaceCleanup('test', () => count++); // runs previous → count: 1
642
+ comp.replaceCleanup('test', () => count++); // runs previous → count: 2
643
+
644
+ const beforeFinal = count;
645
+ comp.processCleanup(); // runs only latest → count: 3
646
+ expect(count - beforeFinal).toBe(1);
647
+ });
648
+
649
+ test('processCleanup runs child cleanup recursively when rootCleanup=true', () => {
650
+ const calls = [];
651
+ const parent = new Component({ appendTo: document.body });
652
+ const child = new Component({ appendTo: parent });
653
+
654
+ child.addCleanup('test', () => calls.push('child'));
655
+
656
+ parent.processCleanup(parent.cleanup, true);
657
+ expect(calls).toContain('child');
658
+
659
+ parent.elem.remove();
660
+ });
661
+ });
662
+
663
+ describe('_setOption routing', () => {
664
+ test('aria-* routes to setAttribute', () => {
665
+ component = new Component({ 'aria-label': 'test label' });
666
+ document.body.appendChild(component.elem);
667
+ expect(component.elem).toHaveAttribute('aria-label', 'test label');
668
+ });
669
+
670
+ test('aria-* set to null removes the attribute', () => {
671
+ component = new Component({ 'aria-label': 'initial', appendTo: document.body });
672
+ component.options['aria-label'] = null;
673
+ expect(component.elem).not.toHaveAttribute('aria-label');
674
+ });
675
+
676
+ test('data-* routes to setAttribute', () => {
677
+ component = new Component({ 'data-testid': 'my-comp', appendTo: document.body });
678
+ expect(component.elem).toHaveAttribute('data-testid', 'my-comp');
679
+ });
680
+
681
+ test('style object routes to setStyle', () => {
682
+ component = new Component({ style: { color: 'red', fontSize: '14px' }, appendTo: document.body });
683
+ expect(component.elem).toHaveStyle({ color: 'red', fontSize: '14px' });
684
+ });
685
+
686
+ test('styles function routes through theme system to inline style', () => {
687
+ component = new Component({ styles: () => ({ color: 'red' }), appendTo: document.body });
688
+ expect(component.elem).toHaveStyle({ color: 'red' });
689
+ });
690
+
691
+ test('on* option registers a recognized pointer event', () => {
692
+ const handler = mock();
693
+ component = new Component({ tag: 'button', onPointerDown: handler, appendTo: document.body });
694
+ component.elem.dispatchEvent(new PointerEvent('pointerdown'));
695
+ expect(handler).toHaveBeenCalled();
696
+ });
697
+
698
+ test('on* method route — onPointerPress calls the component method', () => {
699
+ const handler = mock();
700
+ component = new Component({ tag: 'button', onPointerPress: handler, appendTo: document.body });
701
+
702
+ component.elem.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
703
+ expect(handler).toHaveBeenCalled();
704
+ });
705
+
706
+ test('addClass option calls the addClass method', () => {
707
+ component = new Component({ addClass: ['foo', 'bar'], appendTo: document.body });
708
+ expect(component.elem).toHaveClass('foo', 'bar');
709
+ });
710
+
711
+ test('reactive aria-* update goes through setAttribute', () => {
712
+ component = new Component({ 'aria-expanded': false, appendTo: document.body });
713
+ component.options['aria-expanded'] = true;
714
+ expect(component.elem).toHaveAttribute('aria-expanded');
715
+ });
716
+ });
717
+ });