@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.
- package/Component/Component.js +598 -0
- package/Component/Component.scenarios.js +88 -0
- package/Component/Component.test.js +717 -0
- package/Component/README.md +455 -0
- package/Component/index.js +3 -0
- package/Component/observeElementConnection.js +52 -0
- package/Component/observeElementConnection.test.js +121 -0
- package/Elem/Elem.js +304 -0
- package/Elem/Elem.test.js +679 -0
- package/Elem/README.md +373 -0
- package/Elem/index.js +1 -0
- package/FontWithASyntaxHighlighter-Regular.woff2 +0 -0
- package/LICENSE +21 -0
- package/README.md +413 -0
- package/components/BottomSheet/BottomSheet.js +192 -0
- package/components/BottomSheet/BottomSheet.lld.md +25 -0
- package/components/BottomSheet/README.md +66 -0
- package/components/BottomSheet/index.js +1 -0
- package/components/Button/Button.js +53 -0
- package/components/Button/Button.lld.md +21 -0
- package/components/Button/index.js +1 -0
- package/components/Calendar/Calendar.js +720 -0
- package/components/Calendar/Calendar.lld.md +22 -0
- package/components/Calendar/CalendarEvent.js +102 -0
- package/components/Calendar/Toolbar.js +78 -0
- package/components/Calendar/index.js +2 -0
- package/components/Calendar/utils.js +56 -0
- package/components/Code/Code.js +84 -0
- package/components/Code/Code.lld.md +21 -0
- package/components/Code/index.js +1 -0
- package/components/ColorPicker/ColorPicker.js +445 -0
- package/components/ColorPicker/ColorPicker.lld.md +21 -0
- package/components/ColorPicker/index.js +1 -0
- package/components/ColorPicker/svg.js +5 -0
- package/components/Dialog/Dialog.js +278 -0
- package/components/Dialog/Dialog.lld.md +20 -0
- package/components/Dialog/README.md +96 -0
- package/components/Dialog/index.js +1 -0
- package/components/Form/Form.js +257 -0
- package/components/Form/Form.lld.md +21 -0
- package/components/Form/README.md +87 -0
- package/components/Form/index.js +1 -0
- package/components/Icon/Icon.js +54 -0
- package/components/Icon/Icon.lld.md +21 -0
- package/components/Icon/index.js +1 -0
- package/components/Input/Input.js +173 -0
- package/components/Input/Input.lld.md +28 -0
- package/components/Input/README.md +97 -0
- package/components/Input/index.js +2 -0
- package/components/Input/utils.js +122 -0
- package/components/Keyboard/Key.js +38 -0
- package/components/Keyboard/Keyboard.js +173 -0
- package/components/Keyboard/Keyboard.lld.md +21 -0
- package/components/Keyboard/index.js +1 -0
- package/components/Label/Label.js +214 -0
- package/components/Label/Label.lld.md +20 -0
- package/components/Label/index.js +1 -0
- package/components/Link/Link.js +43 -0
- package/components/Link/Link.lld.md +15 -0
- package/components/Link/index.js +1 -0
- package/components/List/List.js +82 -0
- package/components/List/List.lld.md +19 -0
- package/components/List/index.js +1 -0
- package/components/Menu/Menu.js +93 -0
- package/components/Menu/Menu.lld.md +15 -0
- package/components/Menu/index.js +1 -0
- package/components/Notify/Notify.js +96 -0
- package/components/Notify/Notify.lld.md +20 -0
- package/components/Notify/index.js +1 -0
- package/components/Page/Page.js +67 -0
- package/components/Page/Page.lld.md +20 -0
- package/components/Page/index.js +1 -0
- package/components/Popover/Popover.js +175 -0
- package/components/Popover/Popover.lld.md +19 -0
- package/components/Popover/index.js +1 -0
- package/components/RadioButton/RadioButton.js +108 -0
- package/components/RadioButton/RadioButton.lld.md +15 -0
- package/components/RadioButton/index.js +1 -0
- package/components/Router/README.md +160 -0
- package/components/Router/Router.js +150 -0
- package/components/Router/Router.lld.md +31 -0
- package/components/Router/View.js +15 -0
- package/components/Router/index.js +2 -0
- package/components/Router/utils.js +17 -0
- package/components/Select/README.md +88 -0
- package/components/Select/Select.js +74 -0
- package/components/Select/Select.lld.md +20 -0
- package/components/Select/index.js +1 -0
- package/components/Table/README.md +94 -0
- package/components/Table/Table.js +171 -0
- package/components/Table/Table.lld.md +21 -0
- package/components/Table/index.js +1 -0
- package/components/TagList/Tag.js +84 -0
- package/components/TagList/TagList.js +118 -0
- package/components/TagList/TagList.lld.md +30 -0
- package/components/TagList/design.excalidraw.png +0 -0
- package/components/TagList/index.js +2 -0
- package/components/Tooltip/Tooltip.js +139 -0
- package/components/Tooltip/Tooltip.lld.md +22 -0
- package/components/Tooltip/index.js +1 -0
- package/components/TooltipWrapper/TooltipWrapper.js +89 -0
- package/components/TooltipWrapper/TooltipWrapper.lld.md +21 -0
- package/components/TooltipWrapper/index.js +1 -0
- package/components/Whiteboard/Whiteboard.js +198 -0
- package/components/Whiteboard/Whiteboard.lld.md +35 -0
- package/components/Whiteboard/index.js +1 -0
- package/components/index.js +27 -0
- package/eslint.config.cjs +118 -0
- package/index.d.ts +635 -0
- package/index.js +19 -0
- package/package.json +123 -0
- package/plugins/asText.js +38 -0
- package/plugins/loadPlugins.js +5 -0
- package/plugins/markdownLoader.js +121 -0
- package/prettier.config.cjs +7 -0
- package/spellcheck.config.cjs +227 -0
- package/styled/README.md +329 -0
- package/styled/appendStyles.js +26 -0
- package/styled/appendStyles.test.js +45 -0
- package/styled/index.js +4 -0
- package/styled/shimCSS.js +31 -0
- package/styled/shimCSS.test.js +103 -0
- package/styled/styled.js +91 -0
- package/styled/styled.test.js +586 -0
- package/styled/themeStyles.js +36 -0
- package/styled/themeStyles.test.js +135 -0
- package/test-setup.js +123 -0
- package/theme/.test.js +69 -0
- package/theme/README.md +607 -0
- package/theme/button.js +100 -0
- package/theme/code.js +123 -0
- package/theme/colors.js +42 -0
- package/theme/fonts.js +42 -0
- package/theme/index.js +33 -0
- package/theme/input.js +64 -0
- package/theme/page.js +208 -0
- package/theme/scrollbar.js +24 -0
- package/theme/table.js +53 -0
- package/utils/README.md +176 -0
- package/utils/browser.js +92 -0
- package/utils/class.js +30 -0
- package/utils/color.js +81 -0
- package/utils/data.js +164 -0
- package/utils/element.js +55 -0
- package/utils/index.js +7 -0
- package/utils/rand.js +12 -0
- 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?
|