@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,278 @@
1
+ import { styled } from '../../styled';
2
+ import { Button } from '../Button';
3
+ import { Component } from '../../Component';
4
+ import { Elem } from '../../Elem';
5
+ import { isDev } from '../../utils';
6
+
7
+ const DialogButton = styled(
8
+ Button,
9
+ () => `
10
+ margin: 4px 4px 4px 0;
11
+ `,
12
+ );
13
+
14
+ const size_enum = Object.freeze(['small', 'standard', 'large']);
15
+ const variant_enum = Object.freeze(['info', 'success', 'warning', 'error']);
16
+
17
+ const defaultOptions = {
18
+ tag: 'dialog',
19
+ size: 'small',
20
+ openOnRender: 16,
21
+ modal: true,
22
+ get appendTo() {
23
+ return document.body;
24
+ },
25
+ registeredEvents: new Set(['close']),
26
+ };
27
+
28
+ /**
29
+ * Modal and non-modal dialog component with customizable appearance and behavior.
30
+ *
31
+ * Provides native HTML dialog functionality with enhanced styling, animations, and configurable
32
+ * header/body/footer sections. Supports different sizes, color variants, and button configurations.
33
+ * @param {object} [options] - Dialog configuration options
34
+ * @param {string} [options.tag] - HTML tag, uses native dialog element
35
+ * @param {('small'|'standard'|'large')} [options.size] - Dialog size
36
+ * @param {('info'|'success'|'warning'|'error')} [options.variant] - Color variant
37
+ * @param {number|boolean} [options.openOnRender] - Auto-open delay in ms, or false to disable
38
+ * @param {boolean} [options.modal] - Whether to open as modal dialog
39
+ * @param {string} [options.header] - Header text content
40
+ * @param {string|Component} [options.body] - Body content
41
+ * @param {Array<Component>} [options.footer] - Custom footer components
42
+ * @param {Array<string|object>} [options.buttons] - Button configurations for default footer
43
+ * @param {Function} [options.onButtonPress] - Handler for button press events
44
+ * @param {Function} [options.closeDialog] - Custom close function
45
+ * @param {...(Component|HTMLElement|string)} children - Child elements to append
46
+ * @returns {Dialog} Dialog component instance
47
+ */
48
+ class Dialog extends styled(
49
+ Component,
50
+ ({ colors }) => `
51
+ background-color: ${colors.darker(colors.gray)};
52
+ color: ${colors.white};
53
+ flex-direction: column;
54
+ padding: 0;
55
+ margin: 0 auto;
56
+ border: 1px solid ${colors.alpha(colors.light(colors.teal), 0.6)};
57
+ box-shadow: 0 8px 40px ${colors.alpha(colors.vantablack, 0.7)};
58
+ border-radius: 0;
59
+ top: 50%;
60
+ transform: translateY(-50%);
61
+ opacity: 0;
62
+ transition: display 0.3s allow-discrete, overlay 0.3s allow-discrete;
63
+ animation: dialogFadeOut 0.3s ease-out forwards;
64
+
65
+ &[open] {
66
+ display: flex;
67
+ animation: dialogFadeIn 0.3s ease-out forwards;
68
+ }
69
+
70
+ & .header {
71
+ padding: 8px 12px;
72
+ font-size: 0.9em;
73
+ font-weight: 600;
74
+ color: ${colors.white};
75
+ background-color: ${colors.alpha(colors.vantablack, 0.25)};
76
+ border-bottom: 1px solid ${colors.alpha(colors.light(colors.teal), 0.2)};
77
+ margin: 0;
78
+ }
79
+
80
+ & .content {
81
+ flex: 1;
82
+ padding: 12px;
83
+ overflow: hidden auto;
84
+ }
85
+
86
+ & .footer {
87
+ border-top: 1px solid ${colors.alpha(colors.white, 0.06)};
88
+ display: flex;
89
+ flex-direction: row;
90
+ align-items: center;
91
+ justify-content: flex-end;
92
+ padding: 0 6px;
93
+ min-height: 40px;
94
+ }
95
+
96
+ &.size-small {
97
+ width: 420px;
98
+ height: 210px;
99
+ }
100
+
101
+ &.size-standard {
102
+ width: 640px;
103
+ height: 360px;
104
+ }
105
+
106
+ &.size-large {
107
+ width: 90vw;
108
+ height: 90vh;
109
+ }
110
+
111
+ &::backdrop {
112
+ background-color: ${colors.black.setAlpha(0.85)};
113
+ backdrop-filter: blur(2px);
114
+ }
115
+
116
+ &.variant-info {
117
+ border-color: ${colors.alpha(colors.blue, 0.6)};
118
+ & .header { color: ${colors.lighter(colors.blue)}; border-color: ${colors.alpha(colors.blue, 0.2)}; }
119
+ & .footer button { background-color: ${colors.blue}; }
120
+ }
121
+
122
+ &.variant-success {
123
+ border-color: ${colors.alpha(colors.green, 0.6)};
124
+ & .header { color: ${colors.lighter(colors.green)}; border-color: ${colors.alpha(colors.green, 0.2)}; }
125
+ & .footer button { background-color: ${colors.green}; }
126
+ }
127
+
128
+ &.variant-warning {
129
+ border-color: ${colors.alpha(colors.yellow, 0.6)};
130
+ & .header { color: ${colors.lighter(colors.yellow)}; border-color: ${colors.alpha(colors.yellow, 0.2)}; }
131
+ & .footer button { background-color: ${colors.yellow}; }
132
+ }
133
+
134
+ &.variant-error {
135
+ border-color: ${colors.alpha(colors.red, 0.6)};
136
+ box-shadow: 0 0 32px ${colors.alpha(colors.red, 0.14)}, 0 8px 40px ${colors.alpha(colors.vantablack, 0.7)};
137
+ & .header { color: ${colors.lighter(colors.red)}; border-color: ${colors.alpha(colors.red, 0.2)}; }
138
+ & .footer button { background-color: ${colors.red}; }
139
+ }
140
+
141
+ @keyframes dialogFadeIn {
142
+ from { opacity: 0; }
143
+ to { opacity: 1; }
144
+ }
145
+
146
+ @keyframes dialogFadeOut {
147
+ from { opacity: 1; }
148
+ to { opacity: 0; }
149
+ }
150
+ `,
151
+ ) {
152
+ variant_enum = variant_enum;
153
+ size_enum = size_enum;
154
+
155
+ static handlers = {
156
+ openOnRender(value) {
157
+ const delay = typeof value === 'number' ? value : defaultOptions.openOnRender;
158
+ const openDelay = value ? setTimeout(() => this.open(), delay) : null;
159
+ this.replaceCleanup('openOnRender', () => openDelay && clearTimeout(openDelay));
160
+ },
161
+ // Claim the key so standard routing doesn't try to call elem.showModal() or set elem.modal.
162
+ // The value is read directly from this.options.modal in open().
163
+ modal() {},
164
+ size(value) {
165
+ if (value && !size_enum.includes(value)) {
166
+ throw new Error(
167
+ `"${value}" is not a valid size. The size must be one of the following values: ${size_enum.join(', ')}`,
168
+ );
169
+ }
170
+
171
+ this.removeClass(/\bsize-\S+\b/g);
172
+
173
+ if (value) this.addClass(`size-${value}`);
174
+ },
175
+ variant(value) {
176
+ if (value && !variant_enum.includes(value)) {
177
+ throw new Error(
178
+ `"${value}" is not a valid variant. The variant must be one of the following values: ${variant_enum.join(', ')}`,
179
+ );
180
+ }
181
+
182
+ this.removeClass(/\bvariant-\S+\b/g);
183
+
184
+ if (value) this.addClass(`variant-${value}`);
185
+ },
186
+ body(value) {
187
+ this.body?.content(value);
188
+ },
189
+ header(value) {
190
+ this._header?.content(value);
191
+ },
192
+ };
193
+
194
+ defaultOptions = { ...super.defaultOptions, ...defaultOptions };
195
+
196
+ constructor(options = {}, ...children) {
197
+ super({ ...defaultOptions, ...options }, ...children);
198
+
199
+ this.options['aria-labelledby'] = this.uniqueId;
200
+ }
201
+
202
+ build() {
203
+ this._header = new Elem({
204
+ tag: 'div',
205
+ id: this.uniqueId,
206
+ addClass: ['header'],
207
+ content: this.options.header,
208
+ appendTo: this,
209
+ });
210
+
211
+ this._body = new Elem({ addClass: ['content'], appendTo: this });
212
+
213
+ this._footer = new Elem({
214
+ addClass: ['footer'],
215
+ append:
216
+ this.options.footer ||
217
+ this.options.buttons?.map(
218
+ button =>
219
+ new DialogButton({
220
+ onPointerPress: event =>
221
+ this.options.onButtonPress?.({
222
+ event,
223
+ button,
224
+ dialog: this,
225
+ closeDialog: this.options.closeDialog || (() => this.close()),
226
+ }),
227
+ ...(typeof button === 'object' ? button : { textContent: button }),
228
+ }),
229
+ ),
230
+ appendTo: this,
231
+ });
232
+ }
233
+
234
+ /**
235
+ * Content container — primary injection point for subclass and consumer content.
236
+ * @returns {Component} The dialog body component
237
+ */
238
+ get body() {
239
+ return this._body;
240
+ }
241
+
242
+ /**
243
+ * Opens the dialog, either as modal or non-modal
244
+ * @param {boolean} modal - Whether to open as modal dialog (default: true)
245
+ */
246
+ open(modal = this.options.modal) {
247
+ this._opener = document.activeElement;
248
+ try {
249
+ this.elem[modal ? 'showModal' : 'show']();
250
+ this._openRetried = false;
251
+ const focusable = this.elem.querySelector(
252
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
253
+ );
254
+ (focusable ?? this.elem).focus();
255
+ } catch (error) {
256
+ if (this._openRetried) return;
257
+
258
+ this._openRetried = true;
259
+
260
+ // eslint-disable-next-line no-console
261
+ if (isDev) console.error(error, 'Retrying...');
262
+
263
+ this.render();
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Closes the dialog with optional return value
269
+ * @param {string} returnValue - Value to return when dialog closes
270
+ */
271
+ close(returnValue) {
272
+ this.elem.close(returnValue);
273
+ this._opener?.focus();
274
+ this._opener = null;
275
+ }
276
+ }
277
+
278
+ export default Dialog;
@@ -0,0 +1,20 @@
1
+ # Dialog
2
+
3
+ > ./Dialog.js
4
+
5
+ Native `<dialog>` element wrapper. The decision to use the native element rather than a div with ARIA roles means focus trapping, ESC-to-close, and return values are handled by the platform. The component's job is structure and timing, not reimplementing dialog semantics.
6
+
7
+ ## Auto-open delays until the element is in the DOM
8
+
9
+ - `openOnRender` defaults to a small delay so the dialog is guaranteed to be attached before `showModal()` is called; opening before attachment fails silently
10
+ - does setting openOnRender: false prevent the dialog from opening on its own?
11
+
12
+ ## Invalid sizes and variants fail loudly rather than rendering in an unknown state
13
+
14
+ - an unrecognized size or variant throws at the point of assignment, not later when layout breaks
15
+ - does an unrecognized size value throw?
16
+
17
+ ## Header, body, and footer are stable references — options update them in place
18
+
19
+ - the structural elements are created once in `build()` and persist across option updates; assigning a new `header` value changes the header's content, not the dialog's structure
20
+ - does updating the header option change the header's content without replacing the dialog structure?
@@ -0,0 +1,96 @@
1
+ # Dialog
2
+
3
+ Modal and non-modal dialog component built on the native `<dialog>` element with animated open/close, configurable sizes, color variants, and flexible button configurations.
4
+
5
+ ## Usage
6
+
7
+ ```js
8
+ import { Dialog } from '@vanilla-bean/components';
9
+
10
+ const dialog = new Dialog({
11
+ header: 'Confirm Action',
12
+ body: 'Are you sure you want to proceed?',
13
+ buttons: ['Cancel', { text: 'Confirm', variant: 'success' }],
14
+ onButtonPress: ({ button, closeDialog }) => {
15
+ console.log('pressed', button);
16
+ closeDialog();
17
+ },
18
+ });
19
+ ```
20
+
21
+ ## Options
22
+
23
+ | Option | Type | Default | Description |
24
+ | --- | --- | --- | --- |
25
+ | `header` | `string` | — | Text rendered in the `<h2>` header |
26
+ | `body` | `string\|Component\|Array` | — | Content for the scrollable body section. Reactive: updating `options.body` replaces content |
27
+ | `buttons` | `Array<string\|object>` | — | Shorthand footer buttons. Each entry is a string label or `{ textContent, variant?, onPointerPress?, ...buttonOptions }` |
28
+ | `footer` | `Array<Component>` | — | Fully custom footer components. Takes precedence over `buttons` |
29
+ | `onButtonPress` | `Function` | — | Called when any `buttons` entry is pressed. Receives `{ button, closeDialog, event }` |
30
+ | `openOnRender` | `number\|boolean` | `16` | Auto-open delay in ms after render. Set to `false` to disable auto-open |
31
+ | `modal` | `boolean` | `true` | Open as a modal (with backdrop) vs. non-modal |
32
+ | `size` | `string` | `'small'` | Dialog dimensions: `'small'` (420×210px), `'standard'` (840×420px), `'large'` (90vw×90vh) |
33
+ | `variant` | `string` | — | Color theme: `'info'`, `'success'`, `'warning'`, `'error'` |
34
+ | `closeDialog` | `Function` | — | Override the default close behavior used inside `onButtonPress` |
35
+ | `appendTo` | `Element` | `document.body` | Where to append the dialog in the DOM |
36
+
37
+ ### `onButtonPress` callback signature
38
+
39
+ ```js
40
+ onButtonPress: ({ button, closeDialog, event }) => { ... }
41
+ // button — the original entry from the buttons array (string or object)
42
+ // closeDialog — function that closes the dialog (calls dialog.close() by default)
43
+ // event — the pointer event that triggered the press
44
+ ```
45
+
46
+ ## Methods
47
+
48
+ ```js
49
+ dialog.open(modal?: boolean): void
50
+ // Opens the dialog. Defaults to options.modal. Falls back to re-render on first failure.
51
+
52
+ dialog.close(returnValue?: string): void
53
+ // Closes the dialog. Passes returnValue to the native dialog close event.
54
+ ```
55
+
56
+ Enums are exposed as instance properties for validation reference:
57
+
58
+ ```js
59
+ dialog.size_enum; // ['small', 'standard', 'large']
60
+ dialog.variant_enum; // ['info', 'success', 'warning', 'error']
61
+ ```
62
+
63
+ ## Events
64
+
65
+ | Event | Description |
66
+ | ------- | --------------------------------------------------- |
67
+ | `close` | Native dialog close event, registered automatically |
68
+
69
+ ## Example
70
+
71
+ Dialog with reactive body content loaded asynchronously:
72
+
73
+ ```js
74
+ import { Dialog } from '@vanilla-bean/components';
75
+ import { Button } from '@vanilla-bean/components';
76
+
77
+ const dialog = new Dialog({
78
+ header: 'Results',
79
+ size: 'standard',
80
+ variant: 'info',
81
+ body: 'Loading...',
82
+ buttons: ['Dismiss'],
83
+ openOnRender: false,
84
+ onButtonPress: ({ closeDialog }) => closeDialog(),
85
+ });
86
+
87
+ fetchResults().then(data => {
88
+ dialog.options.body = data.summary;
89
+ });
90
+
91
+ new Button({
92
+ textContent: 'View Results',
93
+ onPointerPress: () => dialog.open(),
94
+ appendTo: document.body,
95
+ });
96
+ ```
@@ -0,0 +1 @@
1
+ export { default as Dialog } from './Dialog';
@@ -0,0 +1,257 @@
1
+ import { Oxject } from '@vanilla-bean/oxject';
2
+ import { fromCamelCase, capitalize } from '../../utils';
3
+ import { Component } from '../../Component';
4
+ import { Elem } from '../../Elem';
5
+ import { Input } from '../Input';
6
+ import { Label } from '../Label';
7
+
8
+ /**
9
+ * Dynamic form component with reactive data binding, validation, layout groups, and conditional fields.
10
+ *
11
+ * Generates form inputs with labels from a configuration array. Each entry in `inputs` is either
12
+ * a field definition or a group definition.
13
+ *
14
+ * **Field definition:**
15
+ * ```js
16
+ * {
17
+ * key: 'name', // required — data key
18
+ * label: 'Full Name', // optional — defaults to formatted key
19
+ * InputComponent: Select, // optional — defaults to Input
20
+ * onChange: event => {}, // optional — called after data update
21
+ * parse: value => value.trim(), // optional — transform before storing
22
+ * condition: data => data.type === 'x', // optional — hide field when false
23
+ * validate: value => value ? null : 'Required', // optional — field-level validator
24
+ * // ...any other InputComponent options
25
+ * }
26
+ * ```
27
+ *
28
+ * **Group definition** — renders inputs side-by-side in a layout container:
29
+ * ```js
30
+ * {
31
+ * type: 'group',
32
+ * style: { display: 'grid', gridTemplateColumns: '1fr 120px', gap: '8px' },
33
+ * inputs: [ ...field definitions... ],
34
+ * }
35
+ * ```
36
+ *
37
+ * Groups can be nested. Fields inside groups support `condition` as normal.
38
+ * When all conditional fields in a group are hidden, the group container is also hidden.
39
+ * Conditional and invalid fields are excluded from validation when hidden.
40
+ *
41
+ * Setting `form.options.inputs = newInputs` rebuilds the form preserving the data store.
42
+ * @param {object} [options={}] - Form configuration options
43
+ * @param {Array<object>} options.inputs - Field and group definitions
44
+ * @param {object} [options.data={}] - Initial form data
45
+ * @param {...(Component|HTMLElement|string)} children - Appended after generated inputs
46
+ */
47
+ export default class Form extends Component {
48
+ build() {
49
+ if (!this.options.inputs?.length) return;
50
+
51
+ this.setStyle({ overflow: 'hidden auto' });
52
+
53
+ const currentData = this.options.data ? { ...this.options.data } : {};
54
+ this.options.data?.destroy?.();
55
+ this.options.data = new Oxject(currentData);
56
+ this.replaceCleanup('formData', () => this.options.data?.destroy?.());
57
+
58
+ this._resetState();
59
+ this._processInputs(this.options.inputs, this);
60
+ this._wireConditions();
61
+ this._setupAnnouncer();
62
+ }
63
+
64
+ /**
65
+ * Rebuild the form when inputs change without recreating the data store.
66
+ * Existing data is preserved; new keys are added with their initial values.
67
+ */
68
+ static handlers = {
69
+ inputs(value) {
70
+ if (!this.rendered || !value || !this.options.data) return;
71
+ this.empty();
72
+ this._resetState();
73
+ this._processInputs(value, this);
74
+ this._wireConditions();
75
+ this._setupAnnouncer();
76
+ },
77
+ };
78
+
79
+ /** @private */
80
+ _resetState() {
81
+ clearTimeout(this._announceTimeout);
82
+ this._announceTimeout = null;
83
+ this.inputElements = {};
84
+ this._conditionals = [];
85
+ this._conditionalGroups = [];
86
+ this._fieldValidators = {};
87
+ }
88
+
89
+ /** @private */
90
+ _wireConditions() {
91
+ if (!this._conditionals.length) return;
92
+ const evaluate = () => this._evaluateConditions();
93
+ this.options.data.addEventListener('set', evaluate);
94
+ // replaceCleanup — safe to call multiple times (inputs handler re-wires on rebuild)
95
+ this.replaceCleanup('conditions', () => this.options.data.removeEventListener('set', evaluate));
96
+ }
97
+
98
+ /**
99
+ * Recursively processes an input definition array into the given append target.
100
+ * Handles both field definitions and group definitions.
101
+ * @param {Array<object>} inputs - Field or group definitions
102
+ * @param {Component|Elem} appendTarget - Where to append generated elements
103
+ * @private
104
+ */
105
+ _processInputs(inputs, appendTarget) {
106
+ const form = this;
107
+
108
+ for (const inputDef of inputs) {
109
+ if (inputDef.type === 'group') {
110
+ const group = new Component({ appendTo: appendTarget, style: inputDef.style });
111
+ const priorConditionalCount = this._conditionals.length;
112
+ this._processInputs(inputDef.inputs, group);
113
+ // Track groups that contain conditional fields for container-level hide/show
114
+ if (this._conditionals.length > priorConditionalCount) {
115
+ this._conditionalGroups.push(group);
116
+ }
117
+ continue;
118
+ }
119
+
120
+ const {
121
+ key,
122
+ label,
123
+ InputComponent = Input,
124
+ onChange = () => {},
125
+ parse = value => value,
126
+ condition,
127
+ validate,
128
+ ...inputOptions
129
+ } = inputDef;
130
+
131
+ const input = new InputComponent({
132
+ value: this.options.data.subscriber(key),
133
+ ...(InputComponent !== Component && {
134
+ onChange: function (event) {
135
+ form.options.data[key] = this.options.value = parse(event.value, input);
136
+ this.validate?.();
137
+ onChange(event);
138
+ },
139
+ }),
140
+ ...inputOptions,
141
+ });
142
+
143
+ form.options.data[key] = input.options.value;
144
+ form.inputElements[key] = input;
145
+
146
+ if (validate) form._fieldValidators[key] = validate;
147
+
148
+ const isCheckbox = input.options.type === 'checkbox';
149
+ const wrapper = new Label(
150
+ typeof label === 'object'
151
+ ? label
152
+ : { label: label || capitalize(fromCamelCase(key), true), ...(isCheckbox && { variant: 'inline' }) },
153
+ input,
154
+ );
155
+
156
+ appendTarget.append(wrapper);
157
+
158
+ if (condition) {
159
+ wrapper.elem.style.display = condition(form.options.data) ? '' : 'none';
160
+ form._conditionals.push({ key, wrapper, condition });
161
+ }
162
+ }
163
+ }
164
+
165
+ /** @private */
166
+ _setupAnnouncer() {
167
+ if (!this._conditionals.length) return;
168
+ this._announcer = new Elem({
169
+ 'aria-live': 'polite',
170
+ 'aria-atomic': 'true',
171
+ style: {
172
+ position: 'absolute',
173
+ width: '1px',
174
+ height: '1px',
175
+ padding: '0',
176
+ margin: '-1px',
177
+ overflow: 'hidden',
178
+ clip: 'rect(0, 0, 0, 0)',
179
+ whiteSpace: 'nowrap',
180
+ border: '0',
181
+ },
182
+ appendTo: this,
183
+ });
184
+ }
185
+
186
+ /**
187
+ * Re-evaluates all conditional field and group visibility against current form data.
188
+ * Called automatically when any form data key changes.
189
+ * @private
190
+ */
191
+ _evaluateConditions() {
192
+ const newlyVisible = [];
193
+
194
+ for (const { wrapper, condition } of this._conditionals) {
195
+ const wasHidden = wrapper.elem.style.display === 'none';
196
+ const isVisible = condition(this.options.data);
197
+ wrapper.elem.style.display = isVisible ? '' : 'none';
198
+ if (wasHidden && isVisible) {
199
+ const label = wrapper.elem.querySelector('label')?.textContent?.trim();
200
+ if (label) newlyVisible.push(label);
201
+ }
202
+ }
203
+
204
+ // Hide group containers when all their conditional children are hidden
205
+ for (const group of this._conditionalGroups) {
206
+ const children = Array.from(group.elem.children);
207
+ const anyVisible = !children.length || children.some(el => el.style.display !== 'none');
208
+ group.elem.style.display = anyVisible ? '' : 'none';
209
+ }
210
+
211
+ if (newlyVisible.length && this._announcer) {
212
+ this._announcer.textContent = '';
213
+ clearTimeout(this._announceTimeout);
214
+ this._announceTimeout = setTimeout(() => {
215
+ if (this._announcer) this._announcer.textContent = `${newlyVisible.join(', ')} now available`;
216
+ }, 50);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Validates all visible form inputs and returns whether errors were found.
222
+ * Runs both VBC Input validations and field-level `validate` functions from the definition.
223
+ * Hidden conditional fields are excluded.
224
+ * @param {object} [options] - Validation options passed to individual input validators
225
+ * @returns {boolean} True if validation errors exist, false if all visible inputs are valid
226
+ */
227
+ hasErrors(options) {
228
+ if (!this.inputElements) return false;
229
+
230
+ const hiddenKeys = new Set(
231
+ (this._conditionals || []).filter(({ wrapper }) => wrapper.elem.style.display === 'none').map(({ key }) => key),
232
+ );
233
+
234
+ let hasErrors = false;
235
+
236
+ for (const [key, input] of Object.entries(this.inputElements)) {
237
+ if (hiddenKeys.has(key)) continue;
238
+
239
+ if (input?.validate?.(options)) hasErrors = true;
240
+
241
+ const fieldValidator = this._fieldValidators?.[key];
242
+ if (fieldValidator) {
243
+ const error = fieldValidator(this.options.data[key]);
244
+ if (error) {
245
+ input?.addClass?.('validation-errors');
246
+ input?.elem?.setAttribute('aria-invalid', 'true');
247
+ hasErrors = true;
248
+ } else {
249
+ input?.removeClass?.('validation-errors');
250
+ input?.elem?.removeAttribute('aria-invalid');
251
+ }
252
+ }
253
+ }
254
+
255
+ return hasErrors;
256
+ }
257
+ }
@@ -0,0 +1,21 @@
1
+ # Form
2
+
3
+ > ./Form.js
4
+
5
+ Declarative form from an array of input configurations. The key design decision is that the form owns data binding. Each input's changes update a shared reactive `data` context, and `hasErrors()` validates the whole form in one call.
6
+
7
+ ## Label text falls back to the field key — no separate label required for obvious fields
8
+
9
+ - if an input config omits `label`, the key name becomes the label
10
+ - does an input with no label option display the key name as its label?
11
+
12
+ ## hasErrors validates all inputs simultaneously and returns a boolean
13
+
14
+ - `hasErrors()` runs all validations and returns true if any failed, false if all passed; false means submittable
15
+ - does hasErrors return false when all inputs are valid?
16
+ - does hasErrors return true when an input's validation fails?
17
+
18
+ ## Form data is live — changes are visible before any submit action
19
+
20
+ - each input's value feeds the shared `data` context as it changes
21
+ - does typing in an input field update the form's data context before any submit?