@vanilla-bean/components 1.1.1 → 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 (52) hide show
  1. package/Component/Component.js +222 -36
  2. package/Component/Component.test.js +425 -38
  3. package/Component/README.md +480 -0
  4. package/Elem/README.md +373 -0
  5. package/README.md +1 -1
  6. package/components/BottomSheet/BottomSheet.js +8 -16
  7. package/components/Button/Button.js +12 -18
  8. package/components/Calendar/Calendar.js +139 -59
  9. package/components/Calendar/CalendarEvent.js +9 -4
  10. package/components/Calendar/Toolbar.js +28 -20
  11. package/components/Calendar/index.js +1 -0
  12. package/components/Code/Code.js +26 -30
  13. package/components/ColorPicker/ColorPicker.js +57 -57
  14. package/components/Dialog/Dialog.js +78 -72
  15. package/components/Form/Form.js +12 -8
  16. package/components/Icon/Icon.js +19 -11
  17. package/components/Input/Input.js +85 -67
  18. package/components/Input/README.md +0 -2
  19. package/components/Keyboard/Key.js +7 -10
  20. package/components/Keyboard/Keyboard.js +38 -46
  21. package/components/Label/Label.js +52 -50
  22. package/components/Link/Link.js +14 -20
  23. package/components/List/List.js +28 -30
  24. package/components/Menu/Menu.js +19 -10
  25. package/components/Menu/Menu.lld.md +2 -2
  26. package/components/Notify/Notify.js +29 -19
  27. package/components/Popover/Popover.js +49 -44
  28. package/components/RadioButton/RadioButton.js +33 -29
  29. package/components/Router/Router.js +19 -21
  30. package/components/Select/Select.js +24 -30
  31. package/components/Table/Table.js +55 -36
  32. package/components/TagList/Tag.js +16 -14
  33. package/components/TagList/TagList.js +15 -8
  34. package/components/Tooltip/Tooltip.js +12 -25
  35. package/components/TooltipWrapper/TooltipWrapper.js +55 -49
  36. package/components/Whiteboard/Whiteboard.js +45 -43
  37. package/devTools/build.js +43 -0
  38. package/devTools/buildTypes.js +322 -0
  39. package/devTools/createComponent.js +155 -0
  40. package/devTools/extractJSDoc.js +395 -0
  41. package/devTools/processTemplate.js +500 -0
  42. package/devTools/updateComponentIndex.js +16 -0
  43. package/devTools/updateDemoViewIndex.js +90 -0
  44. package/eslint.config.cjs +5 -2
  45. package/index.d.ts +117 -59
  46. package/package.json +23 -22
  47. package/spellcheck.config.cjs +3 -0
  48. package/styled/README.md +329 -0
  49. package/theme/colors.js +15 -12
  50. package/theme/colors.test.js +28 -0
  51. package/utils/element.js +2 -2
  52. package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
@@ -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();
@@ -0,0 +1,155 @@
1
+ import fs from 'fs';
2
+
3
+ import { capitalize } from '../utils/string';
4
+
5
+ if (!process.argv[2]) {
6
+ console.error('Usage: bun run create:component <ComponentName> [optionsJSON]');
7
+ process.exit(1);
8
+ }
9
+
10
+ let options = {};
11
+ try {
12
+ options = process.argv[3] ? JSON.parse(process.argv[3]) : {};
13
+ } catch {
14
+ console.error('Invalid JSON options:', process.argv[3]);
15
+ process.exit(1);
16
+ }
17
+
18
+ const name = capitalize(process.argv[2]);
19
+ const componentFolder = `components/${name}`;
20
+
21
+ if (fs.existsSync(componentFolder)) {
22
+ console.error(`Component '${name}' already exists at ${componentFolder}`);
23
+ process.exit(1);
24
+ }
25
+
26
+ fs.mkdirSync(componentFolder);
27
+
28
+ const demoOptions = Object.entries(options)
29
+ .map(
30
+ ([key, value]) =>
31
+ `${key}: ${
32
+ typeof value === 'object'
33
+ ? JSON.stringify(value)
34
+ .replaceAll(/"(\w+)":/g, ` $1: `)
35
+ .replaceAll('"', `'`)
36
+ : value
37
+ },`,
38
+ )
39
+ .join('\n');
40
+
41
+ const testFile = `import { ${name} } from '.';
42
+
43
+ describe('${name}', () => {
44
+ test('renders', () => {
45
+ new ${name}({ appendTo: container });
46
+
47
+ expect(container.firstElementChild).toBeDefined();
48
+ });
49
+ });
50
+ `;
51
+
52
+ const demoFile = `import DemoView from '../../demo/DemoView';
53
+ import { ${name} } from '.';
54
+
55
+ export default class Demo extends DemoView {
56
+ build() {
57
+ this.component = new ${name}({
58
+ ${demoOptions}
59
+ });
60
+ }
61
+ }
62
+ `;
63
+
64
+ const readMeFile = `# ${name}
65
+
66
+ [[extract-description ${name}.js]]
67
+
68
+ ## Usage
69
+
70
+ \`\`\`js
71
+ [[import ./demo.js]]/^(\\t*).+component = new.+\\(\\s*{(.|\\n)+?\\1}?\\);/gm
72
+ \`\`\`
73
+
74
+ ## Options
75
+
76
+ [[extract-options ${name}.js]]
77
+
78
+ ## Methods
79
+
80
+ [[extract-methods ${name}.js]]
81
+
82
+ ## Properties
83
+
84
+ [[extract-properties ${name}.js]]
85
+
86
+ ## Events
87
+
88
+ [[extract-events ${name}.js]]
89
+
90
+ ## Dependencies
91
+
92
+ [[extract-imports ${name}.js]]
93
+
94
+ ## Design
95
+
96
+ ![design](../${name}/design.excalidraw.png)
97
+ `;
98
+
99
+ const componentFile = `import { Component } from '../../Component';
100
+
101
+ /**
102
+ * [Component description - describe what this component does and its primary purpose]
103
+ *
104
+ * [Optional: Additional context about when to use this component or special behaviors]
105
+ *
106
+ * @param {object} [options={}] - Component configuration options
107
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
108
+ * @returns {${name}} Component instance with reactive options
109
+ * @example
110
+ * // Basic usage
111
+ * new ${name}({
112
+ * // Add example options here
113
+ * });
114
+ */
115
+ export default class ${name} extends Component {
116
+ /**
117
+ * Creates component structure before options are processed.
118
+ * Assign child elements to instance properties here so handlers can reference them.
119
+ */
120
+ build() {
121
+ // Create child elements here - this runs before options are processed
122
+ }
123
+
124
+ // Declare the option schema once with static schema - what can exist, the defaults,
125
+ // and how each key routes. Descriptor fields:
126
+ // default - initial value, merged automatically (child classes override per-key)
127
+ // set(value, next) - change handler; omit next() to own the key, call it to continue
128
+ // enum: [...] - valid values; anything else throws
129
+ // attribute: true / priority: true - setAttribute routing / processed first
130
+ // Declared keys with no DOM match live in this.options silently - a bare {} declares
131
+ // a data-only key. data: true exists only to force store-only on DOM-name collisions.
132
+ //
133
+ // static schema = {
134
+ // label: {
135
+ // default: 'Untitled',
136
+ // set(value) {
137
+ // this.myChild.elem.textContent = value;
138
+ // },
139
+ // },
140
+ // items: {},
141
+ // };
142
+ //
143
+ // Custom events: static events = ['select'];
144
+ // Computed defaults (no constructor needed): static prepareOptions(options, children) { return options; }
145
+ }
146
+ `;
147
+
148
+ await Bun.write(`${componentFolder}/${name}.js`, componentFile);
149
+ await Bun.write(`${componentFolder}/.test.js`, testFile);
150
+ await Bun.write(`${componentFolder}/demo.js`, demoFile);
151
+ await Bun.write(`${componentFolder}/README.md`, readMeFile);
152
+ await Bun.write(`${componentFolder}/index.js`, `export { default as ${name} } from './${name}';`);
153
+
154
+ await Bun.spawn(['bun', 'run', 'build:index']).exited;
155
+ await Bun.spawn(['bun', 'run', 'format']).exited;