@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,87 @@
|
|
|
1
|
+
# Form
|
|
2
|
+
|
|
3
|
+
Dynamic form component that generates labeled inputs from a configuration array and maintains reactive data state through an Oxject instance.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Form } from '@vanilla-bean/components';
|
|
9
|
+
|
|
10
|
+
const form = new Form({
|
|
11
|
+
data: { username: '', age: 0 },
|
|
12
|
+
inputs: [
|
|
13
|
+
{ key: 'username', validations: [[/.+/, 'Required']] },
|
|
14
|
+
{ key: 'age', parse: value => parseInt(value, 10) },
|
|
15
|
+
],
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Options
|
|
20
|
+
|
|
21
|
+
| Option | Type | Default | Description |
|
|
22
|
+
| -------- | --------------- | ------- | ----------------------------------------------------------------------- |
|
|
23
|
+
| `inputs` | `Array<object>` | — | Input configuration array; see shape below |
|
|
24
|
+
| `data` | `object` | `{}` | Initial form data. Replaced with an `Oxject` instance on each `build()` |
|
|
25
|
+
|
|
26
|
+
### `inputs` entry shape
|
|
27
|
+
|
|
28
|
+
| Field | Type | Default | Description |
|
|
29
|
+
| --- | --- | --- | --- |
|
|
30
|
+
| `key` | `string` | required | Key into `options.data` for reading and writing the value |
|
|
31
|
+
| `label` | `string\|object` | auto | Label text. Defaults to a human-readable version of `key` (camelCase converted). Pass an object to forward options to `Label` |
|
|
32
|
+
| `InputComponent` | `Component class` | `Input` | Component class to instantiate. Can be `Select`, `ColorPicker`, or any component that accepts `value` and `onChange` |
|
|
33
|
+
| `onChange` | `Function` | `() => {}` | Called after the data context is updated with the new value |
|
|
34
|
+
| `parse` | `Function(value, input)` | `v => v` | Transforms the raw event value before storing it in `options.data` |
|
|
35
|
+
| `validations` | `Array` | — | Passed directly to the `InputComponent` (see Input validation format) |
|
|
36
|
+
| `...inputOptions` | `any` | — | Any additional options forwarded to the `InputComponent` constructor |
|
|
37
|
+
|
|
38
|
+
### `data` Oxject lifecycle
|
|
39
|
+
|
|
40
|
+
On `build()`, any existing `options.data` Oxject instance is destroyed and replaced with a new one wrapping the current data values. Each input subscribes to its key via `options.data.subscriber(key)` so the input value stays reactive. When a user changes a field, `options.data[key]` is updated immediately.
|
|
41
|
+
|
|
42
|
+
Access live form data at any time via `form.options.data`.
|
|
43
|
+
|
|
44
|
+
## Methods
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
form.validate(options?): boolean
|
|
48
|
+
// Calls validate() on every input element that supports it.
|
|
49
|
+
// Returns true if any validation errors exist, false if the form is valid.
|
|
50
|
+
|
|
51
|
+
form.inputElements: { [key]: InputComponent }
|
|
52
|
+
// Map of key → input component instances, available after build().
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Events
|
|
56
|
+
|
|
57
|
+
Individual inputs emit their own events (`onChange`, `onInput`). The Form does not emit top-level events. Listen on `form.options.data` or supply `onChange` per input config.
|
|
58
|
+
|
|
59
|
+
## Example
|
|
60
|
+
|
|
61
|
+
Form with mixed input types, custom parse, and submit validation:
|
|
62
|
+
|
|
63
|
+
```js
|
|
64
|
+
import { Form } from '@vanilla-bean/components';
|
|
65
|
+
import { Select } from '@vanilla-bean/components';
|
|
66
|
+
import { Button } from '@vanilla-bean/components';
|
|
67
|
+
|
|
68
|
+
const form = new Form(
|
|
69
|
+
{
|
|
70
|
+
data: { name: '', role: 'viewer', active: true },
|
|
71
|
+
inputs: [
|
|
72
|
+
{ key: 'name', validations: [[/.+/, 'Name is required']] },
|
|
73
|
+
{ key: 'role', InputComponent: Select, options: ['viewer', 'editor', 'admin'] },
|
|
74
|
+
{ key: 'active' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
new Button({
|
|
78
|
+
textContent: 'Submit',
|
|
79
|
+
onPointerPress: () => {
|
|
80
|
+
if (form.validate()) return console.warn('Fix errors before submitting');
|
|
81
|
+
console.log('Submitting:', { ...form.options.data });
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Type auto-detection applies: `name` (string) becomes a text input, `active` (boolean) becomes a checkbox, automatically.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Form } from './Form';
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Component } from '../../Component';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Icon component that renders FontAwesome icons with animation support.
|
|
5
|
+
*
|
|
6
|
+
* Automatically applies FontAwesome classes based on icon name and animation.
|
|
7
|
+
* Handles class management for dynamic icon changes and supports all FontAwesome icon variants.
|
|
8
|
+
* @param {object} [options={}] - Icon configuration options
|
|
9
|
+
* @param {string} [options.icon] - FontAwesome icon name (without 'fa-' prefix)
|
|
10
|
+
* @param {string} [options.animation] - FontAwesome animation name (without 'fa-' prefix)
|
|
11
|
+
* @param {string} [options.content] - Text content to display alongside icon
|
|
12
|
+
* @param {string} [options.textContent] - Alternative text content property
|
|
13
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
14
|
+
* @returns {Icon} Icon component instance
|
|
15
|
+
*/
|
|
16
|
+
export default class Icon extends Component {
|
|
17
|
+
static handlers = {
|
|
18
|
+
icon() {
|
|
19
|
+
this._refreshIcon();
|
|
20
|
+
},
|
|
21
|
+
animation() {
|
|
22
|
+
this._refreshIcon();
|
|
23
|
+
},
|
|
24
|
+
content(value, next) {
|
|
25
|
+
next(value);
|
|
26
|
+
this._refreshIcon();
|
|
27
|
+
},
|
|
28
|
+
textContent(value, next) {
|
|
29
|
+
next(value);
|
|
30
|
+
this._refreshIcon();
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
_refreshIcon() {
|
|
35
|
+
this.removeClass(/\bfa-\S+\b/g);
|
|
36
|
+
|
|
37
|
+
const { icon, animation, content, textContent } = this.options;
|
|
38
|
+
|
|
39
|
+
if (icon || animation) {
|
|
40
|
+
this.addClass(
|
|
41
|
+
...(content || textContent ? [] : ['icon']),
|
|
42
|
+
...['support', animation, icon].filter(Boolean).map(v => `fa-${v}`),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const interactive = ['button', 'a', 'input', 'select', 'textarea'].includes(this.elem.tagName.toLowerCase());
|
|
47
|
+
const labeled = this.elem.hasAttribute('aria-label') || this.elem.hasAttribute('aria-labelledby');
|
|
48
|
+
if (!interactive && !content && !textContent && !labeled) {
|
|
49
|
+
this.elem.setAttribute('aria-hidden', 'true');
|
|
50
|
+
} else {
|
|
51
|
+
this.elem.removeAttribute('aria-hidden');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Icon
|
|
2
|
+
|
|
3
|
+
> ./Icon.js
|
|
4
|
+
|
|
5
|
+
FontAwesome icon wrapper that manages FA class state as options rather than raw class strings. The design decision: callers pass `icon: "star"` and the component owns the full FA class lifecycle; callers never manually add or remove FA classes.
|
|
6
|
+
|
|
7
|
+
## Changing icon leaves no residual classes from the previous value
|
|
8
|
+
|
|
9
|
+
- when the icon option changes, all previous FA icon classes are removed before the new one is applied; a caller can freely update the icon option without accumulating stale classes
|
|
10
|
+
- does an element that had icon 'star' and was updated to 'moon' have only the moon icon?
|
|
11
|
+
|
|
12
|
+
## Icon-only and icon-with-text render differently without the caller specifying which mode
|
|
13
|
+
|
|
14
|
+
- when there is no text content, the element receives the `icon` class, which CSS uses for icon-only layout; when text accompanies the icon, that class is absent
|
|
15
|
+
- does an Icon constructed without text receive the icon-only class?
|
|
16
|
+
- does an Icon constructed with text not receive the icon-only class?
|
|
17
|
+
|
|
18
|
+
## Animation and icon are independent options that compose
|
|
19
|
+
|
|
20
|
+
- setting `animation` applies an animation class independently of `icon`; an Icon with both options active carries both classes simultaneously; they do not interfere
|
|
21
|
+
- does an Icon with both icon and animation options active carry both classes at the same time?
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default as Icon } from './Icon';
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { Component } from '../../Component';
|
|
2
|
+
import { retry } from '../../utils';
|
|
3
|
+
import { updateValidationErrors, insertTabCharacter, adjustIndentation } from './utils';
|
|
4
|
+
|
|
5
|
+
const type_enum = Object.freeze([
|
|
6
|
+
'button',
|
|
7
|
+
'checkbox',
|
|
8
|
+
'color',
|
|
9
|
+
'date',
|
|
10
|
+
'datetime-local',
|
|
11
|
+
'email',
|
|
12
|
+
'file',
|
|
13
|
+
'hidden',
|
|
14
|
+
'image',
|
|
15
|
+
'month',
|
|
16
|
+
'number',
|
|
17
|
+
'password',
|
|
18
|
+
'radio',
|
|
19
|
+
'range',
|
|
20
|
+
'reset',
|
|
21
|
+
'search',
|
|
22
|
+
'submit',
|
|
23
|
+
'tel',
|
|
24
|
+
'text',
|
|
25
|
+
'time',
|
|
26
|
+
'url',
|
|
27
|
+
'week',
|
|
28
|
+
]);
|
|
29
|
+
const defaultOptions = {
|
|
30
|
+
tag: 'input',
|
|
31
|
+
value: '',
|
|
32
|
+
autocomplete: 'off',
|
|
33
|
+
autocapitalize: 'off',
|
|
34
|
+
autocorrect: 'off',
|
|
35
|
+
height: 'auto',
|
|
36
|
+
placeholder: '',
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const dataTypeToInputType = { number: 'number', boolean: 'checkbox', string: 'text' };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Versatile input component supporting all HTML input types and textarea functionality.
|
|
43
|
+
*
|
|
44
|
+
* Provides enhanced input behavior with auto-height for textareas, syntax highlighting,
|
|
45
|
+
* validation support, and automatic type detection based on value type.
|
|
46
|
+
* @param {object} [options={}] - Input configuration options
|
|
47
|
+
* @param {string} [options.tag='input'] - HTML tag ('input' or 'textarea')
|
|
48
|
+
* @param {string} [options.type] - Input type, auto-detected from value type if not specified
|
|
49
|
+
* @param {*} [options.value=''] - Initial input value
|
|
50
|
+
* @param {string} [options.placeholder=''] - Placeholder text
|
|
51
|
+
* @param {string} [options.autocomplete='off'] - Autocomplete behavior
|
|
52
|
+
* @param {string} [options.autocapitalize='off'] - Auto-capitalization behavior
|
|
53
|
+
* @param {string} [options.autocorrect='off'] - Auto-correction behavior
|
|
54
|
+
* @param {string|number} [options.height='auto'] - Height for textarea ('auto' for dynamic sizing)
|
|
55
|
+
* @param {boolean} [options.syntaxHighlighting] - Enable syntax highlighting for textarea
|
|
56
|
+
* @param {string} [options.language] - Programming language for syntax highlighting
|
|
57
|
+
* @param {Array<Function>} [options.validations] - Array of validation functions
|
|
58
|
+
* @param {...(Component|HTMLElement|string)} children - Child elements to append
|
|
59
|
+
* @returns {Input} Input component instance
|
|
60
|
+
*/
|
|
61
|
+
export default class Input extends Component {
|
|
62
|
+
defaultOptions = { ...super.defaultOptions, ...defaultOptions };
|
|
63
|
+
type_enum = type_enum;
|
|
64
|
+
|
|
65
|
+
constructor(options = {}, ...children) {
|
|
66
|
+
const rawValue = options.value?.__isDerived ? options.value.toJSON() : options.value;
|
|
67
|
+
super(
|
|
68
|
+
{
|
|
69
|
+
...((options.tag || 'input') === 'input' ? { type: dataTypeToInputType[typeof rawValue] || 'text' } : {}),
|
|
70
|
+
...defaultOptions,
|
|
71
|
+
...options,
|
|
72
|
+
},
|
|
73
|
+
...children,
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
this.initialValue = this.options.value;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
build() {
|
|
80
|
+
if (this.tag === 'textarea' && this.options.syntaxHighlighting) {
|
|
81
|
+
const handleKeydown = function (event) {
|
|
82
|
+
if (event.key == 'Tab') {
|
|
83
|
+
event.preventDefault();
|
|
84
|
+
|
|
85
|
+
this.selectionStart === this.selectionEnd && !event.shiftKey
|
|
86
|
+
? insertTabCharacter(this)
|
|
87
|
+
: adjustIndentation({ textarea: this, action: event.shiftKey ? 'remove' : 'add' });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
this.elem.addEventListener('keydown', handleKeydown);
|
|
92
|
+
this.replaceCleanup('syntaxHighlightingKeydown', () => this.elem.removeEventListener('keydown', handleKeydown));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static handlers = {
|
|
97
|
+
height(value) {
|
|
98
|
+
if (this.tag !== 'textarea') return;
|
|
99
|
+
|
|
100
|
+
if (value === 'auto') {
|
|
101
|
+
this.__updateAutoHeight = () => {
|
|
102
|
+
this.elem.style.height = this.options.syntaxHighlighting
|
|
103
|
+
? `calc(${((this.elem.value?.match(/\n/g) || '').length + 1) * 1.19}em + 10px)`
|
|
104
|
+
: `calc(${((this.elem.value?.match(/\n/g) || '').length + 1) * 1.25}em + 16px)`;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
this.__debouncedUpdateAutoHeight =
|
|
108
|
+
this.__debouncedUpdateAutoHeight ||
|
|
109
|
+
(() => {
|
|
110
|
+
clearTimeout(this.__autoHeightTimeout);
|
|
111
|
+
this.__autoHeightTimeout = setTimeout(this.__updateAutoHeight, 16);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this.__updateAutoHeight();
|
|
115
|
+
|
|
116
|
+
this.elem.addEventListener('input', this.__debouncedUpdateAutoHeight);
|
|
117
|
+
|
|
118
|
+
this.replaceCleanup('autoHeight', () => {
|
|
119
|
+
clearTimeout(this.__autoHeightTimeout);
|
|
120
|
+
this.elem.removeEventListener?.('input', this.__debouncedUpdateAutoHeight);
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
this.elem.removeEventListener('input', this.__debouncedUpdateAutoHeight);
|
|
124
|
+
clearTimeout(this.__autoHeightTimeout);
|
|
125
|
+
|
|
126
|
+
this.elem.style.height = typeof value === 'number' ? `${value + 1}em` : value;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
value(value) {
|
|
130
|
+
if (this.options.type === 'checkbox') this.elem.checked = value;
|
|
131
|
+
else this.elem.value = value;
|
|
132
|
+
|
|
133
|
+
if (this.rendered && this.options.validations?.length) retry(() => this.validate(), { delay: 500, max: 1 });
|
|
134
|
+
},
|
|
135
|
+
syntaxHighlighting(value) {
|
|
136
|
+
this[value ? 'addClass' : 'removeClass']('syntax-highlighting');
|
|
137
|
+
},
|
|
138
|
+
language(value) {
|
|
139
|
+
if (!this.options.syntaxHighlighting) return;
|
|
140
|
+
this.removeClass(/\blanguage-\S+\b/g);
|
|
141
|
+
this.addClass(`language-${value}`);
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Checks if the input value has changed from its initial value
|
|
147
|
+
* @returns {boolean} True if value is no longer the initial value
|
|
148
|
+
*/
|
|
149
|
+
get isDirty() {
|
|
150
|
+
return this.initialValue !== this.options.value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validates the input value against configured validation rules
|
|
155
|
+
* @param {object} options - Validation options
|
|
156
|
+
* @param {Array} options.validations - Custom validation rules to use
|
|
157
|
+
* @param {*} options.value - Custom value
|
|
158
|
+
* @returns {Array|undefined} Array of validation errors, or undefined if valid
|
|
159
|
+
*/
|
|
160
|
+
validate({ validations, value } = {}) {
|
|
161
|
+
const errors = updateValidationErrors({
|
|
162
|
+
elem: this.elem,
|
|
163
|
+
validations: validations ?? this.options.validations,
|
|
164
|
+
value: value ?? this.options.value,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
this[errors ? 'addClass' : 'removeClass']('validation-errors');
|
|
168
|
+
if (errors) this.elem.setAttribute('aria-invalid', 'true');
|
|
169
|
+
else this.elem.removeAttribute('aria-invalid');
|
|
170
|
+
|
|
171
|
+
return errors;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
> ./Input.js
|
|
4
|
+
|
|
5
|
+
Input element that infers its type from the value it receives rather than requiring callers to declare it. The deeper design decision is that `isDirty` makes unsaved-change detection a first-class property; forms don't need to track initial values themselves.
|
|
6
|
+
|
|
7
|
+
## Type is communicated by the value, not by a separate option
|
|
8
|
+
|
|
9
|
+
- callers pass a value; the input determines its type from that value's type
|
|
10
|
+
- does a number value produce an input that accepts numeric entry?
|
|
11
|
+
- does a boolean value produce an input that represents a checked/unchecked state?
|
|
12
|
+
|
|
13
|
+
## isDirty reflects whether the value has changed from its initial state
|
|
14
|
+
|
|
15
|
+
- the initial value is recorded at construction; `isDirty` answers "has the user changed this?" without external tracking
|
|
16
|
+
- does isDirty return false when the value matches the initial value?
|
|
17
|
+
- does isDirty return true after the value has been changed?
|
|
18
|
+
|
|
19
|
+
## Validation errors are surfaced as element state, not return values
|
|
20
|
+
|
|
21
|
+
- when a validation fails, the element enters an error state that CSS can target; passing clears it
|
|
22
|
+
- does a failing validation leave the input in an error state?
|
|
23
|
+
- does a passing validation clear the error state?
|
|
24
|
+
|
|
25
|
+
## Textarea height follows content automatically
|
|
26
|
+
|
|
27
|
+
- when `tag: 'textarea'`, the element resizes to fit its content without the caller managing height
|
|
28
|
+
- does a textarea grow taller when its content exceeds its current height?
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Input
|
|
2
|
+
|
|
3
|
+
Versatile input component supporting all HTML input types, textarea with auto-height, syntax highlighting, validation, and automatic type detection from value type.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { Input } from '@vanilla-bean/components';
|
|
9
|
+
|
|
10
|
+
const input = new Input({
|
|
11
|
+
type: 'text',
|
|
12
|
+
value: 'hello',
|
|
13
|
+
placeholder: 'Enter text...',
|
|
14
|
+
onChange: ({ value }) => console.log(value),
|
|
15
|
+
});
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Options
|
|
19
|
+
|
|
20
|
+
| Option | Type | Default | Description |
|
|
21
|
+
| --- | --- | --- | --- |
|
|
22
|
+
| `tag` | `string` | `'input'` | HTML tag: `'input'` or `'textarea'` |
|
|
23
|
+
| `type` | `string` | auto-detected | Input type (see `type_enum`). Auto-detected from value: `number` → `'number'`, `boolean` → `'checkbox'`, `string` → `'text'` |
|
|
24
|
+
| `value` | `any` | `''` | Initial value. Setting `options.value` reactively updates the element |
|
|
25
|
+
| `placeholder` | `string` | `''` | Placeholder text |
|
|
26
|
+
| `autocomplete` | `string` | `'off'` | Native autocomplete attribute |
|
|
27
|
+
| `autocapitalize` | `string` | `'off'` | Native autocapitalize attribute |
|
|
28
|
+
| `autocorrect` | `string` | `'off'` | Native autocorrect attribute |
|
|
29
|
+
| `height` | `string\|number` | `'auto'` | Height for textarea. `'auto'` dynamically sizes to content; a number sets `em` units |
|
|
30
|
+
| `syntaxHighlighting` | `boolean` | — | Adds `syntaxHighlighting` class and Tab key indentation handling (textarea only) |
|
|
31
|
+
| `language` | `string` | — | Adds `language-<value>` class for Prism-compatible highlighting (requires `syntaxHighlighting`) |
|
|
32
|
+
| `validations` | `Array` | — | Validation rules; see Validation section below |
|
|
33
|
+
| `onInput` | `Function` | — | Called on every keystroke with an enhanced event containing `.value` |
|
|
34
|
+
| `onChange` | `Function` | — | Called on change with an enhanced event containing `.value` |
|
|
35
|
+
| `onKeyUp` | `Function` | — | Called on keyup with an enhanced event containing `.value` |
|
|
36
|
+
|
|
37
|
+
### Supported `type` values
|
|
38
|
+
|
|
39
|
+
`button`, `checkbox`, `color`, `date`, `datetime-local`, `email`, `file`, `hidden`, `image`, `month`, `number`, `password`, `radio`, `range`, `reset`, `search`, `submit`, `tel`, `text`, `time`, `url`, `week`
|
|
40
|
+
|
|
41
|
+
### Validation rule format
|
|
42
|
+
|
|
43
|
+
Each entry in `validations` is a tuple: `[test, message]`
|
|
44
|
+
|
|
45
|
+
- `test`: a `RegExp` tested against the value, or a `Function(value) => boolean` (truthy = error)
|
|
46
|
+
- `message`: a `string` or `Function() => string` describing the error
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
validations: [
|
|
50
|
+
[/.+/, 'This field is required'],
|
|
51
|
+
[/^.{3,20}$/, 'Must be 3–20 characters'],
|
|
52
|
+
[value => value === 'forbidden', 'That value is not allowed'],
|
|
53
|
+
];
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Validation errors are applied to the underlying element via `setCustomValidity` and the `validationErrors` CSS class is toggled on the component.
|
|
57
|
+
|
|
58
|
+
## Methods
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
input.validate({ validations?, value? }): Array | undefined
|
|
62
|
+
// Runs validation rules. Returns array of error messages if invalid, undefined if valid.
|
|
63
|
+
// Calling with no args uses options.validations and options.value.
|
|
64
|
+
|
|
65
|
+
input.isDirty: boolean // getter — true if current value differs from the initial value
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Events
|
|
69
|
+
|
|
70
|
+
Input components emit enhanced native events. All handlers (`onInput`, `onChange`, `onKeyUp`, etc.) receive a standard DOM event augmented with a `.value` property containing the current input value.
|
|
71
|
+
|
|
72
|
+
The `close` event is registered by default via `registeredEvents`.
|
|
73
|
+
|
|
74
|
+
## Example
|
|
75
|
+
|
|
76
|
+
Textarea with auto-height and syntax highlighting:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
import { Input } from '@vanilla-bean/components';
|
|
80
|
+
|
|
81
|
+
const editor = new Input({
|
|
82
|
+
tag: 'textarea',
|
|
83
|
+
height: 'auto',
|
|
84
|
+
syntaxHighlighting: true,
|
|
85
|
+
language: 'javascript',
|
|
86
|
+
value: 'const x = 1;',
|
|
87
|
+
validations: [[/.+/, 'Editor must not be empty']],
|
|
88
|
+
onChange: ({ value }) => {
|
|
89
|
+
editor.options.value = value;
|
|
90
|
+
const errors = editor.validate();
|
|
91
|
+
if (errors) console.warn(errors);
|
|
92
|
+
},
|
|
93
|
+
appendTo: document.body,
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Tab and shift-Tab adjust indentation within the textarea when `syntaxHighlighting` is enabled.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { Component } from '../../Component';
|
|
2
|
+
import { styled } from '../../styled';
|
|
3
|
+
import { Button } from '../Button';
|
|
4
|
+
import { Popover } from '../Popover';
|
|
5
|
+
|
|
6
|
+
export const InputValidationError = styled(
|
|
7
|
+
Component,
|
|
8
|
+
({ colors }) => `
|
|
9
|
+
background-color: ${colors.red};
|
|
10
|
+
padding: 6px;
|
|
11
|
+
margin: 3px;
|
|
12
|
+
border-radius: 3px;
|
|
13
|
+
`,
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const updateValidationErrors = ({ elem, validations, value }) => {
|
|
17
|
+
if (!validations?.length) return;
|
|
18
|
+
|
|
19
|
+
// Destroy previous validation components before re-evaluating
|
|
20
|
+
const parent = elem.parentElement;
|
|
21
|
+
if (parent) {
|
|
22
|
+
parent.querySelectorAll('[data-validation-error]').forEach(el => {
|
|
23
|
+
el._component?.destroy?.() ?? el._elem?.destroy?.() ?? el.remove();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const errors = [];
|
|
28
|
+
|
|
29
|
+
validations.forEach(([validation, message]) => {
|
|
30
|
+
const isValid = validation instanceof RegExp ? validation.test(value) : validation(value);
|
|
31
|
+
|
|
32
|
+
const resolvedMessage = typeof message == 'function' ? message(value) : message;
|
|
33
|
+
|
|
34
|
+
if (!isValid) {
|
|
35
|
+
errors.push(resolvedMessage);
|
|
36
|
+
const validationError = new InputValidationError({ content: resolvedMessage });
|
|
37
|
+
validationError.elem.dataset.validationError = '';
|
|
38
|
+
|
|
39
|
+
const [inputLabel] = elem.parentElement.getElementsByTagName('label');
|
|
40
|
+
|
|
41
|
+
if (inputLabel) {
|
|
42
|
+
const errorPopover = new Popover({ autoOpen: false, state: 'auto' });
|
|
43
|
+
errorPopover.elem.dataset.validationError = '';
|
|
44
|
+
elem.parentElement.insertBefore(errorPopover.elem, elem);
|
|
45
|
+
|
|
46
|
+
const errorButton = new Button({
|
|
47
|
+
appendTo: inputLabel,
|
|
48
|
+
icon: 'exclamation',
|
|
49
|
+
styles: ({ colors }) => ({
|
|
50
|
+
marginLeft: '6px',
|
|
51
|
+
width: '18px',
|
|
52
|
+
height: '18px',
|
|
53
|
+
fontSize: '12px',
|
|
54
|
+
padding: 0,
|
|
55
|
+
backgroundColor: colors.red,
|
|
56
|
+
}),
|
|
57
|
+
onPointerPress: event => errorPopover.show({ x: event.clientX, y: event.clientY }),
|
|
58
|
+
});
|
|
59
|
+
errorButton.elem.dataset.validationError = '';
|
|
60
|
+
|
|
61
|
+
errorPopover.append(validationError.elem);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Insert directly before the input element in case there are other stacked elements
|
|
65
|
+
else elem.parentElement.insertBefore(validationError.elem, elem);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return errors.length > 0 ? errors : undefined;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const insertTabCharacter = input => {
|
|
73
|
+
const { value, selectionEnd } = input;
|
|
74
|
+
|
|
75
|
+
input.value = `${value.slice(0, Math.max(0, selectionEnd))}\t${value.slice(Math.max(0, selectionEnd))}`;
|
|
76
|
+
input.selectionStart = input.selectionEnd = selectionEnd + 1;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const adjustIndentation = ({ textarea, action = 'add' }) => {
|
|
80
|
+
const { value, selectionStart, selectionEnd } = textarea;
|
|
81
|
+
const remove = action === 'remove';
|
|
82
|
+
|
|
83
|
+
const startLine = value.slice(0, Math.max(0, selectionStart)).split('\n').length - 1;
|
|
84
|
+
const endLine = value.slice(0, Math.max(0, selectionEnd)).split('\n').length - 1;
|
|
85
|
+
|
|
86
|
+
let modifiedStartLine = false;
|
|
87
|
+
let modifiedLines = 0;
|
|
88
|
+
|
|
89
|
+
const updateLine = (line, index) => {
|
|
90
|
+
if (remove) {
|
|
91
|
+
if (!/^\t/.test(line)) return line;
|
|
92
|
+
|
|
93
|
+
++modifiedLines;
|
|
94
|
+
if (index === startLine) modifiedStartLine = true;
|
|
95
|
+
return line.replace(/^\t/, '');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
++modifiedLines;
|
|
99
|
+
if (index === startLine) modifiedStartLine = true;
|
|
100
|
+
return `\t${line}`;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const newValue = value
|
|
104
|
+
.split('\n')
|
|
105
|
+
.map((line, index) => (index >= startLine && index <= endLine ? updateLine(line, index) : line))
|
|
106
|
+
.join('\n');
|
|
107
|
+
|
|
108
|
+
const changedText = value !== newValue;
|
|
109
|
+
|
|
110
|
+
if (!changedText) {
|
|
111
|
+
textarea.selectionStart = selectionStart;
|
|
112
|
+
textarea.selectionEnd = selectionEnd;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
textarea.value = newValue;
|
|
117
|
+
|
|
118
|
+
const selectionModification = remove ? -1 : 1;
|
|
119
|
+
|
|
120
|
+
textarea.selectionStart = selectionStart + (modifiedStartLine ? selectionModification : 0);
|
|
121
|
+
textarea.selectionEnd = selectionEnd + modifiedLines * selectionModification;
|
|
122
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { styled } from '../../styled';
|
|
2
|
+
import { Button } from '../Button';
|
|
3
|
+
|
|
4
|
+
const StyledButton = styled(
|
|
5
|
+
Button,
|
|
6
|
+
() => `
|
|
7
|
+
user-select: none;
|
|
8
|
+
display: inline-block;
|
|
9
|
+
border: none;
|
|
10
|
+
padding: 0;
|
|
11
|
+
border-radius: unset;
|
|
12
|
+
margin-bottom: 1px;
|
|
13
|
+
margin-right: 1px;
|
|
14
|
+
height: 32px;
|
|
15
|
+
flex: 1;
|
|
16
|
+
|
|
17
|
+
${[1.25, 1.5, 1.75, 2, 2.25, 2.75, 6, 6.25, 7]
|
|
18
|
+
.map(unit => `&.u${unit.toString().replace('.', '_')} { flex: ${unit}; }`)
|
|
19
|
+
.join('\n\t\t')}
|
|
20
|
+
`,
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
class Key extends StyledButton {
|
|
24
|
+
constructor(options = {}, ...children) {
|
|
25
|
+
const { key, text, class: keyClass, ...optionsWithoutConfig } = options;
|
|
26
|
+
|
|
27
|
+
super(
|
|
28
|
+
{
|
|
29
|
+
textContent: text ?? key,
|
|
30
|
+
...optionsWithoutConfig,
|
|
31
|
+
addClass: [key, keyClass].concat(options.addClass),
|
|
32
|
+
},
|
|
33
|
+
...children,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export default Key;
|