@vanilla-bean/components 1.1.0 → 2.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 (75) hide show
  1. package/Component/Component.js +226 -40
  2. package/Component/Component.scenarios.js +2 -2
  3. package/Component/Component.test.js +427 -40
  4. package/Component/README.md +480 -0
  5. package/Component/observeElementConnection.js +1 -1
  6. package/Elem/README.md +373 -0
  7. package/README.md +2 -2
  8. package/components/BottomSheet/BottomSheet.js +8 -16
  9. package/components/BottomSheet/README.md +1 -1
  10. package/components/Button/Button.js +12 -18
  11. package/components/Button/Button.lld.md +1 -1
  12. package/components/Calendar/Calendar.js +139 -59
  13. package/components/Calendar/CalendarEvent.js +9 -4
  14. package/components/Calendar/Toolbar.js +28 -20
  15. package/components/Calendar/index.js +1 -0
  16. package/components/Code/Code.js +26 -30
  17. package/components/Code/Code.lld.md +1 -1
  18. package/components/ColorPicker/ColorPicker.js +58 -58
  19. package/components/Dialog/Dialog.js +79 -73
  20. package/components/Dialog/Dialog.lld.md +1 -1
  21. package/components/Dialog/README.md +10 -10
  22. package/components/Form/Form.js +21 -17
  23. package/components/Form/Form.lld.md +2 -2
  24. package/components/Form/README.md +3 -3
  25. package/components/Icon/Icon.js +19 -11
  26. package/components/Input/Input.js +85 -67
  27. package/components/Input/README.md +7 -9
  28. package/components/Keyboard/Key.js +7 -10
  29. package/components/Keyboard/Keyboard.js +38 -46
  30. package/components/Keyboard/Keyboard.lld.md +1 -1
  31. package/components/Label/Label.js +52 -50
  32. package/components/Link/Link.js +14 -20
  33. package/components/List/List.js +28 -30
  34. package/components/Menu/Menu.js +19 -10
  35. package/components/Menu/Menu.lld.md +2 -2
  36. package/components/Notify/Notify.js +29 -19
  37. package/components/Notify/Notify.lld.md +2 -2
  38. package/components/Page/Page.lld.md +1 -1
  39. package/components/Popover/Popover.js +49 -44
  40. package/components/RadioButton/RadioButton.js +33 -29
  41. package/components/RadioButton/RadioButton.lld.md +1 -1
  42. package/components/Router/README.md +12 -12
  43. package/components/Router/Router.js +19 -21
  44. package/components/Select/README.md +5 -5
  45. package/components/Select/Select.js +25 -31
  46. package/components/Table/README.md +4 -4
  47. package/components/Table/Table.js +55 -36
  48. package/components/Table/Table.lld.md +1 -1
  49. package/components/TagList/Tag.js +16 -14
  50. package/components/TagList/TagList.js +15 -8
  51. package/components/TagList/TagList.lld.md +1 -1
  52. package/components/Tooltip/Tooltip.js +12 -25
  53. package/components/TooltipWrapper/TooltipWrapper.js +55 -49
  54. package/components/TooltipWrapper/TooltipWrapper.lld.md +2 -2
  55. package/components/Whiteboard/Whiteboard.js +45 -43
  56. package/components/Whiteboard/Whiteboard.lld.md +1 -1
  57. package/devTools/build.js +43 -0
  58. package/devTools/buildTypes.js +322 -0
  59. package/devTools/createComponent.js +155 -0
  60. package/devTools/extractJSDoc.js +395 -0
  61. package/devTools/processTemplate.js +500 -0
  62. package/devTools/updateComponentIndex.js +16 -0
  63. package/devTools/updateDemoViewIndex.js +90 -0
  64. package/eslint.config.cjs +5 -2
  65. package/index.d.ts +120 -62
  66. package/package.json +23 -22
  67. package/spellcheck.config.cjs +3 -0
  68. package/styled/README.md +329 -0
  69. package/theme/.test.js +2 -2
  70. package/theme/README.md +8 -8
  71. package/theme/colors.js +16 -13
  72. package/theme/colors.test.js +28 -0
  73. package/utils/browser.js +1 -1
  74. package/utils/element.js +2 -2
  75. package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
@@ -29,61 +29,67 @@ const StyledIcon = styled(
29
29
  * @returns {TooltipWrapper} TooltipWrapper component instance
30
30
  */
31
31
  export default class TooltipWrapper extends StyledIcon {
32
- static handlers = {
33
- tooltip(value) {
34
- if (value == null) return;
35
- const tooltipOptions = typeof value === 'object' ? value : { textContent: value };
32
+ static schema = {
33
+ tooltip: {
34
+ set(value) {
35
+ this._applyTooltip(value);
36
+ },
37
+ },
38
+ };
36
39
 
37
- if (this._tooltip) {
38
- this._tooltip.setOptions(tooltipOptions);
39
- } else {
40
- this._tooltip = new Tooltip({
41
- appendTo: this.elem,
42
- ...tooltipOptions,
43
- });
40
+ _applyTooltip(value) {
41
+ if (value == null) return;
42
+ const tooltipOptions = typeof value === 'object' ? value : { textContent: value };
44
43
 
45
- this.addClass('has-tooltip');
44
+ if (this._tooltip) {
45
+ this._tooltip.setOptions(tooltipOptions);
46
+ } else {
47
+ this._tooltip = new Tooltip({
48
+ appendTo: this.elem,
49
+ ...tooltipOptions,
50
+ });
46
51
 
47
- this.on({
48
- targetEvent: 'pointerover',
49
- callback: ({ clientX, clientY }) => {
50
- this.tooltipTimeout = setTimeout(() => this._tooltip?.show({ x: clientX, y: clientY }), 700);
51
- },
52
- });
52
+ this.addClass('has-tooltip');
53
53
 
54
- this.on({
55
- targetEvent: 'pointerout',
56
- callback: () => {
57
- clearTimeout(this.tooltipTimeout);
58
- if (!this.elem.contains(document.activeElement)) this._tooltip?.hide();
59
- },
60
- });
54
+ this.on({
55
+ targetEvent: 'pointerover',
56
+ callback: ({ clientX, clientY }) => {
57
+ this.tooltipTimeout = setTimeout(() => this._tooltip?.show({ x: clientX, y: clientY }), 700);
58
+ },
59
+ });
61
60
 
62
- this.on({
63
- targetEvent: 'focus',
64
- callback: () => {
65
- clearTimeout(this.tooltipTimeout);
66
- const { left, bottom } = this.elem.getBoundingClientRect();
67
- this._tooltip?.show({ x: left, y: bottom });
68
- },
69
- });
61
+ this.on({
62
+ targetEvent: 'pointerout',
63
+ callback: () => {
64
+ clearTimeout(this.tooltipTimeout);
65
+ if (!this.elem.contains(document.activeElement)) this._tooltip?.hide();
66
+ },
67
+ });
70
68
 
71
- this.on({
72
- targetEvent: 'blur',
73
- callback: () => {
74
- clearTimeout(this.tooltipTimeout);
75
- this._tooltip?.hide();
76
- },
77
- });
69
+ this.on({
70
+ targetEvent: 'focus',
71
+ callback: () => {
72
+ clearTimeout(this.tooltipTimeout);
73
+ const { left, bottom } = this.elem.getBoundingClientRect();
74
+ this._tooltip?.show({ x: left, y: bottom });
75
+ },
76
+ });
78
77
 
79
- this.replaceCleanup('tooltip', () => {
80
- if (this._tooltip) {
81
- this._tooltip.destroy?.();
82
- this._tooltip = null;
83
- }
78
+ this.on({
79
+ targetEvent: 'blur',
80
+ callback: () => {
84
81
  clearTimeout(this.tooltipTimeout);
85
- });
86
- }
87
- },
88
- };
82
+ this._tooltip?.hide();
83
+ },
84
+ });
85
+
86
+ this.replaceCleanup('tooltip', () => {
87
+ if (this._tooltip) {
88
+ this._tooltip.destroy?.();
89
+ this._tooltip = null;
90
+ }
91
+ clearTimeout(this.tooltipTimeout);
92
+ });
93
+ }
94
+ }
89
95
  }
@@ -9,13 +9,13 @@ Component that adds a tooltip to whatever it wraps. The Tooltip is created durin
9
9
  - the Tooltip component is created when the `tooltip` option is processed during render; after construction it exists as `this._tooltip`
10
10
  - does the Tooltip component exist after construction with a tooltip option?
11
11
 
12
- ## Hover shows after a delay — brief mouse-overs do not trigger the tooltip
12
+ ## Hover shows after a delay - brief mouse-overs do not trigger the tooltip
13
13
 
14
14
  - pointerover registers a 700ms timer; pointerout cancels it and hides immediately; hovering for longer than 700ms shows the tooltip
15
15
  - does hovering for longer than 700ms show the tooltip?
16
16
  - does leaving before 700ms prevent the tooltip from appearing?
17
17
 
18
- ## Cleanup removes the tooltip element — nothing outlives the component
18
+ ## Cleanup removes the tooltip element - nothing outlives the component
19
19
 
20
20
  - when the TooltipWrapper is destroyed, the Tooltip component is also destroyed and removed from the DOM
21
21
  - does destroying the wrapper also remove the tooltip from the DOM?
@@ -1,17 +1,6 @@
1
1
  import { throttle } from '../../utils/data';
2
2
  import { Component } from '../../Component';
3
3
 
4
- const defaultOptions = {
5
- tag: 'canvas',
6
- background: '#FFF',
7
- color: '#000',
8
- lineWidth: 3,
9
- width: '200px',
10
- height: '200px',
11
- readOnly: false,
12
- registeredEvents: new Set(['line', 'draw']),
13
- };
14
-
15
4
  /**
16
5
  * Interactive drawing canvas component with multi-touch support and line rendering.
17
6
  *
@@ -31,22 +20,52 @@ const defaultOptions = {
31
20
  * @returns {Whiteboard} Whiteboard component instance
32
21
  */
33
22
  export default class Whiteboard extends Component {
34
- defaultOptions = { ...super.defaultOptions, ...defaultOptions };
35
-
36
- constructor(options = {}, ...children) {
37
- super(
38
- {
39
- ...defaultOptions,
40
- ...options,
41
- style: {
42
- cursor: 'crosshair',
43
- touchAction: 'none',
44
- borderRadius: '3px',
45
- ...options.style,
46
- },
23
+ static schema = {
24
+ tag: { default: 'canvas' },
25
+ width: {
26
+ default: '200px',
27
+ set(value) {
28
+ this.elem.width = Number.parseInt(value);
29
+ this.elem.style.width = value;
47
30
  },
48
- ...children,
49
- );
31
+ },
32
+ height: {
33
+ default: '200px',
34
+ set(value) {
35
+ this.elem.height = Number.parseInt(value);
36
+ this.elem.style.height = value;
37
+ },
38
+ },
39
+ background: {
40
+ default: '#FFF',
41
+ set(value) {
42
+ this.elem.style.background = value;
43
+ },
44
+ },
45
+ lines: {
46
+ set(value) {
47
+ value.forEach(line => this.drawLine(line));
48
+ },
49
+ },
50
+ // Drawing config read from this.options during draw interactions
51
+ color: { default: '#000' },
52
+ lineWidth: { default: 3 },
53
+ readOnly: { default: false },
54
+ drawThrottle: {},
55
+ };
56
+
57
+ static events = ['line', 'draw'];
58
+
59
+ static prepareOptions(options) {
60
+ return {
61
+ ...options,
62
+ style: {
63
+ cursor: 'crosshair',
64
+ touchAction: 'none',
65
+ borderRadius: '3px',
66
+ ...options.style,
67
+ },
68
+ };
50
69
  }
51
70
 
52
71
  build() {
@@ -59,23 +78,6 @@ export default class Whiteboard extends Component {
59
78
  this.replaceCleanup('pointerdown', () => this.elem.removeEventListener('pointerdown', boundInteractionInit));
60
79
  }
61
80
 
62
- static handlers = {
63
- width(value) {
64
- this.elem.width = Number.parseInt(value);
65
- this.elem.style.width = value;
66
- },
67
- height(value) {
68
- this.elem.height = Number.parseInt(value);
69
- this.elem.style.height = value;
70
- },
71
- lines(value) {
72
- value.forEach(line => this.drawLine(line));
73
- },
74
- background(value) {
75
- this.elem.style.background = value;
76
- },
77
- };
78
-
79
81
  getPosition({ offsetX, offsetY }) {
80
82
  return { x: offsetX.toFixed(3), y: offsetY.toFixed(3) };
81
83
  }
@@ -10,7 +10,7 @@ Multi-touch drawing canvas that tracks each pointer independently. The design de
10
10
  - the whiteboard tracks each active pointer by its ID
11
11
  - does a second finger touching the canvas start a separate line from the first?
12
12
 
13
- ## Draw throttle rate adapts to line width — no separate configuration needed
13
+ ## Draw throttle rate adapts to line width - no separate configuration needed
14
14
 
15
15
  - the throttle delay is derived from `lineWidth + 3` (clamped to a range); thicker lines are visually coarser and tolerate a longer delay
16
16
  - callers set `lineWidth` and the draw rate adjusts automatically; they do not configure throttle separately
@@ -0,0 +1,43 @@
1
+ import { watch } from 'fs';
2
+ import { buildLoader as asText } from '../plugins/asText';
3
+ import { buildLoader as markdownLoader } from '../plugins/markdownLoader';
4
+
5
+ const build = async () => {
6
+ console.log('Building...');
7
+
8
+ const buildResults = await Bun.build({
9
+ entrypoints: ['demo/index.html'],
10
+ outdir: 'demo/build',
11
+ define: {
12
+ 'process.env.AUTOPREFIXER_GRID': 'undefined',
13
+ 'process.cwd': 'String',
14
+ },
15
+ plugins: [asText, markdownLoader],
16
+ });
17
+
18
+ console.log(buildResults.success ? 'build.success' : buildResults.logs);
19
+
20
+ return buildResults;
21
+ };
22
+
23
+ const enableWatcher = process.argv[2] === '--watch';
24
+ const watcherIgnore = /\.asText$|^demo\/build|^\.|^img\/|^devTools\//;
25
+
26
+ if (enableWatcher) {
27
+ console.log(`Initializing watcher`);
28
+
29
+ const watcher = watch(`${import.meta.dir}/..`, { recursive: true }, (event, filename) => {
30
+ if (watcherIgnore.test(filename)) return;
31
+
32
+ console.log(`Detected ${event} in ${filename}`);
33
+
34
+ build();
35
+ });
36
+
37
+ process.on('SIGINT', () => {
38
+ watcher.close();
39
+ process.exit(0);
40
+ });
41
+ }
42
+
43
+ await build();
@@ -0,0 +1,322 @@
1
+ import { readdirSync, readFileSync, writeFileSync, existsSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ import { GlobalRegistrator } from '@happy-dom/global-registrator';
6
+
7
+ import { extractJSDoc } from './extractJSDoc.js';
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url));
10
+ const root = join(__dirname, '..');
11
+
12
+ // Components need a DOM to import; happy-dom stands in so the schema statics can be read
13
+ GlobalRegistrator.register({ width: 1920, height: 1080 });
14
+
15
+ const library = await import(join(root, 'index.js'));
16
+ const { Component } = library;
17
+
18
+ const GENERATED_START = '// ── GENERATED: Built-in components ────────────────────────────────────────────';
19
+ const GENERATED_END = '// ── END GENERATED ────────────────────────────────────────────────────────────';
20
+
21
+ /**
22
+ * Convert a JSDoc type string (e.g. `{string|Function}`) to a TypeScript type string.
23
+ * @param {string} type - Raw JSDoc type including surrounding braces
24
+ * @returns {string} TypeScript-compatible type string
25
+ */
26
+ function jsDocTypeToTS(type) {
27
+ if (!type) return 'any';
28
+
29
+ return type
30
+ .trim()
31
+ .replace(/^\{|\}$/g, '')
32
+ .replace(/\*/g, 'any')
33
+ .replace(/\bFunction\b/g, '(...args: any[]) => any')
34
+ .replace(/\bobject\b/g, 'Record<string, any>')
35
+ .replace(/\bArray\b(?!<)/g, 'Array<any>')
36
+ .replace(/\|/g, ' | ')
37
+ .replace(/\s{2,}/g, ' ')
38
+ .trim();
39
+ }
40
+
41
+ const COMPONENT_OPTIONS_KEYS = new Set([
42
+ 'tag',
43
+ 'autoRender',
44
+ 'styles',
45
+ 'uniqueId',
46
+ 'style',
47
+ 'attributes',
48
+ 'className',
49
+ 'id',
50
+ 'textContent',
51
+ 'innerText',
52
+ 'innerHTML',
53
+ 'content',
54
+ 'appendTo',
55
+ 'prependTo',
56
+ 'append',
57
+ 'prepend',
58
+ 'before',
59
+ 'disabled',
60
+ 'onclick',
61
+ 'onConnected',
62
+ 'onDisconnected',
63
+ 'onRendered',
64
+ 'onPointerPress',
65
+ 'onHover',
66
+ 'onPointerOver',
67
+ 'onPointerEnter',
68
+ 'onPointerDown',
69
+ 'onPointerMove',
70
+ 'onPointerUp',
71
+ 'onPointerLeave',
72
+ 'onPointerCancel',
73
+ 'onContextmenu',
74
+ 'onChange',
75
+ 'onKeydown',
76
+ 'onKeyup',
77
+ 'onInput',
78
+ 'onBlur',
79
+ 'onSearch',
80
+ 'addClass',
81
+ ]);
82
+
83
+ /**
84
+ * Merges static schema declarations up a component's constructor chain.
85
+ * Parent schemas apply first so child classes override per descriptor field,
86
+ * mirroring the runtime's nearest-class-wins semantics.
87
+ * @param {Function} klass - Component subclass to walk
88
+ * @param {Function} [stopAt] - Ancestor class to stop before (exclusive), so subclasses only report their own keys
89
+ * @returns {object} Merged descriptor info keyed by option name
90
+ */
91
+ function collectSchema(klass, stopAt = Component) {
92
+ const chain = [];
93
+ let current = klass;
94
+
95
+ while (current && current !== stopAt && current !== Component) {
96
+ if (Object.prototype.hasOwnProperty.call(current, 'schema') && current.schema) chain.unshift(current.schema);
97
+ current = Object.getPrototypeOf(current);
98
+ }
99
+
100
+ const merged = {};
101
+
102
+ for (const schema of chain) {
103
+ for (const [key, descriptor] of Object.entries(schema)) {
104
+ if (!descriptor) continue;
105
+ const info = (merged[key] ??= {});
106
+ if ('default' in descriptor) {
107
+ info.hasDefault = true;
108
+ try {
109
+ info.default = descriptor.default;
110
+ } catch {
111
+ info.hasDefault = false;
112
+ }
113
+ }
114
+ if ('enum' in descriptor) info.enum = descriptor.enum;
115
+ if ('data' in descriptor) info.data = !!descriptor.data;
116
+ }
117
+ }
118
+
119
+ return merged;
120
+ }
121
+
122
+ /**
123
+ * Derive a TypeScript type for a schema key from its enum or default value.
124
+ * @param {object} info - Merged descriptor info from collectSchema
125
+ * @returns {string} TypeScript type string
126
+ */
127
+ function schemaKeyType(info) {
128
+ if (info.enum?.length) return info.enum.map(value => JSON.stringify(value)).join(' | ');
129
+
130
+ if (info.hasDefault) {
131
+ const type = typeof info.default;
132
+ if (type === 'string' || type === 'number' || type === 'boolean') return type;
133
+ if (type === 'function') return '(...args: any[]) => any';
134
+ if (Array.isArray(info.default)) return 'Array<any>';
135
+ if (type === 'object' && info.default !== null) return 'Record<string, any>';
136
+ }
137
+
138
+ return 'any';
139
+ }
140
+
141
+ /**
142
+ * Format a schema default as a doc-comment suffix, primitives only.
143
+ * @param {object} info - Merged descriptor info from collectSchema
144
+ * @returns {string} ' (default: ...)' or empty string
145
+ */
146
+ function defaultSuffix(info) {
147
+ if (!info?.hasDefault) return '';
148
+ const type = typeof info.default;
149
+ if (type !== 'string' && type !== 'number' && type !== 'boolean') return '';
150
+
151
+ return ` (default: ${JSON.stringify(info.default)})`;
152
+ }
153
+
154
+ /**
155
+ * Generate TypeScript interface and class declaration for a single component.
156
+ * The schema supplies key existence, defaults, and enum unions; JSDoc supplies types and descriptions.
157
+ * Interfaces and class declarations extend the component's nearest exported ancestor,
158
+ * mirroring the runtime hierarchy so inherited options and methods carry through.
159
+ * @param {string} name - Component class name (e.g. "Button")
160
+ * @param {object} jsDoc - Parsed JSDoc object from extractJSDoc
161
+ * @param {object} [schema] - Merged descriptor info from collectSchema
162
+ * @param {object} [bases] - Base names for the generated declarations
163
+ * @param {string} [bases.baseClass] - Class the declared class extends
164
+ * @param {string} [bases.baseInterface] - Interface the options interface extends
165
+ * @returns {string} TypeScript declaration block
166
+ */
167
+ function generateComponentInterface(
168
+ name,
169
+ jsDoc,
170
+ schema = {},
171
+ { baseClass = 'Component', baseInterface = 'ComponentOptions' } = {},
172
+ ) {
173
+ const lines = [];
174
+ const componentOptions = jsDoc.options.filter(
175
+ opt => (opt.name !== 'options' || opt.isSubProperty) && !COMPONENT_OPTIONS_KEYS.has(opt.name),
176
+ );
177
+ const documentedKeys = new Set(componentOptions.map(opt => opt.name));
178
+ const schemaOnlyKeys = Object.keys(schema).filter(
179
+ key => !documentedKeys.has(key) && !COMPONENT_OPTIONS_KEYS.has(key),
180
+ );
181
+ const methods = jsDoc.methods.filter(m => !m.name.startsWith('_'));
182
+
183
+ if (componentOptions.length > 0 || schemaOnlyKeys.length > 0) {
184
+ lines.push(`export interface ${name}Options extends ${baseInterface} {`);
185
+ for (const opt of componentOptions) {
186
+ const info = schema[opt.name];
187
+ // The schema is ground truth for valid values and defaults; JSDoc supplies types and descriptions
188
+ const tsType = info?.enum?.length ? schemaKeyType(info) : jsDocTypeToTS(opt.type);
189
+ const description = `${opt.description || ''}${defaultSuffix(info)}`.trim();
190
+ if (description) lines.push(`\t/** ${description} */`);
191
+ lines.push(`\t${opt.name}?: ${tsType};`);
192
+ }
193
+ for (const key of schemaOnlyKeys) {
194
+ const info = schema[key];
195
+ const description = defaultSuffix(info).trim();
196
+ if (description) lines.push(`\t/** ${description.replace(/^\(|\)$/g, '')} */`);
197
+ lines.push(`\t${key}?: ${schemaKeyType(info)};`);
198
+ }
199
+ lines.push('}');
200
+ lines.push('');
201
+ }
202
+
203
+ const getters = jsDoc.properties.filter(p => p.access === 'readonly' && p.type !== 'unknown');
204
+
205
+ const optionsType = componentOptions.length > 0 || schemaOnlyKeys.length > 0 ? `${name}Options` : baseInterface;
206
+ lines.push(`export declare class ${name} extends ${baseClass} {`);
207
+ lines.push(`\tconstructor(options?: ${optionsType}, ...children: Array<Elem | HTMLElement | string>);`);
208
+
209
+ for (const getter of getters) {
210
+ const tsType = jsDocTypeToTS(`{${getter.type}}`);
211
+ if (getter.description) lines.push(`\t/** ${getter.description} */`);
212
+ lines.push(`\treadonly ${getter.name}: ${tsType};`);
213
+ }
214
+
215
+ for (const method of methods) {
216
+ const params = method.parameters.map(p => `${p.name}${p.optional ? '?' : ''}: ${jsDocTypeToTS(p.type)}`).join(', ');
217
+ const ret = method.returns ? jsDocTypeToTS(method.returns.type) : 'void';
218
+ if (method.description) lines.push(`\t/** ${method.description} */`);
219
+ lines.push(`\t${method.name}(${params}): ${ret};`);
220
+ }
221
+
222
+ lines.push('}');
223
+ lines.push('');
224
+
225
+ return lines.join('\n');
226
+ }
227
+
228
+ /**
229
+ * Scan the components directory and build the generated type declaration block.
230
+ * @returns {{ block: string, count: number }} Generated TS source and component count
231
+ */
232
+ function buildComponentTypes() {
233
+ const componentsDir = join(root, 'components');
234
+ const dirs = readdirSync(componentsDir, { withFileTypes: true })
235
+ .filter(d => d.isDirectory() && existsSync(join(componentsDir, d.name, `${d.name}.js`)))
236
+ .map(d => d.name)
237
+ .sort();
238
+
239
+ // Map exported component classes to their names so subclasses can extend
240
+ // their nearest exported ancestor instead of flattening to Component
241
+ const exportedClasses = new Map();
242
+ for (const dir of dirs) {
243
+ if (typeof library[dir] === 'function') exportedClasses.set(library[dir], dir);
244
+ }
245
+
246
+ const nearestAncestor = klass => {
247
+ let current = Object.getPrototypeOf(klass);
248
+ while (current && current !== Component) {
249
+ if (exportedClasses.has(current)) return current;
250
+ current = Object.getPrototypeOf(current);
251
+ }
252
+ return null;
253
+ };
254
+
255
+ // First pass: gather each component's JSDoc, own-schema (below its exported ancestor), and parentage
256
+ const components = dirs.map(dir => {
257
+ const klass = library[dir];
258
+ const parentClass = typeof klass === 'function' ? nearestAncestor(klass) : null;
259
+ const schema = typeof klass === 'function' ? collectSchema(klass, parentClass ?? Component) : {};
260
+
261
+ return { dir, jsDoc: extractJSDoc(join(componentsDir, dir, `${dir}.js`)), schema, parentClass };
262
+ });
263
+
264
+ const hasInterface = new Set(
265
+ components
266
+ .filter(({ jsDoc, schema }) => {
267
+ const documented = jsDoc.options.filter(
268
+ opt => (opt.name !== 'options' || opt.isSubProperty) && !COMPONENT_OPTIONS_KEYS.has(opt.name),
269
+ );
270
+ return documented.length > 0 || Object.keys(schema).some(key => !COMPONENT_OPTIONS_KEYS.has(key));
271
+ })
272
+ .map(({ dir }) => dir),
273
+ );
274
+
275
+ const generated = [
276
+ GENERATED_START,
277
+ '// This section is generated by devTools/buildTypes.js - do not edit manually.',
278
+ '// Run `bun run build:types` to regenerate from static schema declarations and JSDoc annotations.',
279
+ '',
280
+ ];
281
+
282
+ for (const { dir, jsDoc, schema, parentClass } of components) {
283
+ const parentName = parentClass ? exportedClasses.get(parentClass) : null;
284
+
285
+ generated.push(
286
+ generateComponentInterface(dir, jsDoc, schema, {
287
+ baseClass: parentName || 'Component',
288
+ baseInterface: parentName && hasInterface.has(parentName) ? `${parentName}Options` : 'ComponentOptions',
289
+ }),
290
+ );
291
+ }
292
+
293
+ generated.push(GENERATED_END);
294
+
295
+ return { block: generated.join('\n'), count: dirs.length };
296
+ }
297
+
298
+ /**
299
+ * Read index.d.ts, replace the generated component block, and write it back.
300
+ */
301
+ function updateIndexDts() {
302
+ const indexDtsPath = join(root, 'index.d.ts');
303
+ const content = readFileSync(indexDtsPath, 'utf8');
304
+ const { block, count } = buildComponentTypes();
305
+
306
+ let updated;
307
+ if (content.includes(GENERATED_START) && content.includes(GENERATED_END)) {
308
+ const start = content.indexOf(GENERATED_START);
309
+ const end = content.indexOf(GENERATED_END) + GENERATED_END.length;
310
+ updated = content.slice(0, start) + block + content.slice(end);
311
+ } else {
312
+ const builtInMarker = '// ── Built-in components ──';
313
+ const markerIndex = content.indexOf(builtInMarker);
314
+ updated =
315
+ markerIndex !== -1 ? content.slice(0, markerIndex) + block + '\n' : content.trimEnd() + '\n\n' + block + '\n';
316
+ }
317
+
318
+ writeFileSync(indexDtsPath, updated, 'utf8');
319
+ console.log(`✓ index.d.ts updated - ${count} components`);
320
+ }
321
+
322
+ updateIndexDts();