├── .DS_Store
├── .gitignore
├── .idea
├── .gitignore
├── inspectionProfiles
│ └── profiles_settings.xml
├── lit-reactive-forms.iml
└── modules.xml
├── .vscode
├── settings.json
└── snippets.json
├── README.md
├── demo
├── counter.ts
├── custom_validators.ts
├── favicon.svg
├── my-element.styles.ts
├── my-element.ts
└── vite-env.d.ts
├── index.html
├── package-lock.json
├── package.json
├── src
├── abstract-control.ts
├── accessors
│ ├── accessors.ts
│ └── control-accessor.ts
├── controllers
│ ├── form-array.ts
│ ├── form-builder.ts
│ ├── form-control.ts
│ └── form-group.ts
├── directives
│ └── bind.directive.ts
├── index.ts
├── models.ts
└── validation
│ ├── models.ts
│ ├── pure-validators.ts
│ └── validators-with-effects.ts
├── test
├── form-array.test.ts
├── form-control.test.ts
└── form-group.test.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/UserGalileo/lit-reactive-forms/f3780e1521768fd2c5b7500ad2fceb1603a0bed8/.DS_Store
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | types
15 | coverage
16 |
17 | # Editor directories and files
18 | .vscode/*
19 | !.vscode/extensions.json
20 | .idea
21 | .DS_Store
22 | *.suo
23 | *.ntvs*
24 | *.njsproj
25 | *.sln
26 | *.sw?
27 |
--------------------------------------------------------------------------------
/.idea/.gitignore:
--------------------------------------------------------------------------------
1 | # Default ignored files
2 | /shelf/
3 | /workspace.xml
4 | # Editor-based HTTP Client requests
5 | /httpRequests/
6 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/lit-reactive-forms.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | // "editor.theme": "vs-dark",
3 | /**
4 | * Render vertical lines at the specified columns.
5 | * Defaults to empty array.
6 | */
7 | // "editor.rulers": [],
8 | /**
9 | * A string containing the word separators used when doing word navigation.
10 | * Defaults to `~!@#$%^&*()-=+[{]}\\|;:\'",.<>/?
11 | */
12 | // "editor.wordSeparators": "`~!@#$%^&*()-=+[{]}\\\\|;:\\'\",.<>/?",
13 | /**
14 | * Enable Linux primary clipboard.
15 | * Defaults to true.
16 | */
17 | // "editor.selectionClipboard": true,
18 | /**
19 | * Control the rendering of line numbers.
20 | * If it is a function, it will be invoked when rendering a line number and the return value will be rendered.
21 | * Otherwise, if it is a truey, line numbers will be rendered normally (equivalent of using an identity function).
22 | * Otherwise, line numbers will not be rendered.
23 | * Defaults to true.
24 | */
25 | // "editor.lineNumbers": "on",
26 | /**
27 | * Should the corresponding line be selected when clicking on the line number?
28 | * Defaults to true.
29 | */
30 | // "editor.selectOnLineNumbers": true,
31 | /**
32 | * Control the width of line numbers, by reserving horizontal space for rendering at least an amount of digits.
33 | * Defaults to 5.
34 | */
35 | // "editor.lineNumbersMinChars": 5,
36 | /**
37 | * Enable the rendering of the glyph margin.
38 | * Defaults to true in vscode and to false in monaco-editor.
39 | */
40 | // "editor.glyphMargin": true,
41 | /**
42 | * The width reserved for line decorations (in px).
43 | * Line decorations are placed between line numbers and the editor content.
44 | * You can pass in a string in the format floating point followed by "ch". e.g. 1.3ch.
45 | * Defaults to 10.
46 | */
47 | // "editor.lineDecorationsWidth": 10,
48 | /**
49 | * When revealing the cursor, a virtual padding (px) is added to the cursor, turning it into a rectangle.
50 | * This virtual padding ensures that the cursor gets revealed before hitting the edge of the viewport.
51 | * Defaults to 30 (px).
52 | */
53 | // "editor.revealHorizontalRightPadding": 30,
54 | /**
55 | * Render the editor selection with rounded borders.
56 | * Defaults to true.
57 | */
58 | // "editor.roundedSelection": true,
59 | /**
60 | * Control the behavior and rendering of the minimap.
61 | */
62 | // "editor.minimap": {
63 | // "enabled": false
64 | // },
65 | /**
66 | * Display overflow widgets as `fixed`.
67 | * Defaults to `false`.
68 | */
69 | // "editor.fixedOverflowWidgets": false,
70 | /**
71 | * The number of vertical lanes the overview ruler should render.
72 | * Defaults to 2.
73 | */
74 | // "editor.overviewRulerLanes": 2,
75 | /**
76 | * Controls if a border should be drawn around the overview ruler.
77 | * Defaults to `true`.
78 | */
79 | // "editor.overviewRulerBorder": true,
80 | /**
81 | * Control the cursor animation style, possible values are 'blink', 'smooth', 'phase', 'expand' and 'solid'.
82 | * Defaults to 'blink'.
83 | */
84 | // "editor.cursorBlinking": "blink",
85 | /**
86 | * Zoom the font in the editor when using the mouse wheel in combination with holding Ctrl.
87 | * Defaults to false.
88 | */
89 | // "editor.mouseWheelZoom": false,
90 | /**
91 | * Control the cursor style, either 'block' or 'line'.
92 | * Defaults to 'line'.
93 | */
94 | // "editor.cursorStyle": "line",
95 | /**
96 | * Control the width of the cursor when cursorStyle is set to 'line'
97 | */
98 | // "editor.cursorWidth": 2,
99 | /**
100 | * Enable font ligatures.
101 | * Defaults to false.
102 | */
103 | // "editor.fontLigatures": false,
104 | /**
105 | * Disable the use of `will-change` for the editor margin and lines layers.
106 | * The usage of `will-change` acts as a hint for browsers to create an extra layer.
107 | * Defaults to false.
108 | */
109 | // "editor.disableLayerHinting": false,
110 | /**
111 | * Disable the optimizations for monospace fonts.
112 | * Defaults to false.
113 | */
114 | // "editor.disableMonospaceOptimizations": false,
115 | /**
116 | * Should the cursor be hidden in the overview ruler.
117 | * Defaults to false.
118 | */
119 | // "editor.hideCursorInOverviewRuler": false,
120 | /**
121 | * Enable that scrolling can go one screen size after the last line.
122 | * Defaults to true.
123 | */
124 | // "editor.scrollBeyondLastLine": true,
125 | /**
126 | * Enable that scrolling can go beyond the last column by a number of columns.
127 | * Defaults to 5.
128 | */
129 | // "editor.scrollBeyondLastColumn": 5,
130 | /**
131 | * Enable that the editor animates scrolling to a position.
132 | * Defaults to false.
133 | */
134 | // "editor.smoothScrolling": false,
135 | /**
136 | * Enable that the editor will install an interval to check if its container dom node size has changed.
137 | * Enabling this might have a severe performance impact.
138 | * Defaults to false.
139 | */
140 | // "editor.automaticLayout": false,
141 | /**
142 | * Control the wrapping of the editor.
143 | * When `wordWrap` = "off", the lines will never wrap.
144 | * When `wordWrap` = "on", the lines will wrap at the viewport width.
145 | * When `wordWrap` = "wordWrapColumn", the lines will wrap at `wordWrapColumn`.
146 | * When `wordWrap` = "bounded", the lines will wrap at min(viewport width, wordWrapColumn).
147 | * Defaults to "off".
148 | */
149 | // "editor.wordWrap": "on",
150 | /**
151 | * Control the wrapping of the editor.
152 | * When `wordWrap` = "off", the lines will never wrap.
153 | * When `wordWrap` = "on", the lines will wrap at the viewport width.
154 | * When `wordWrap` = "wordWrapColumn", the lines will wrap at `wordWrapColumn`.
155 | * When `wordWrap` = "bounded", the lines will wrap at min(viewport width, wordWrapColumn).
156 | * Defaults to 80.
157 | */
158 | // "editor.wordWrapColumn": 0,
159 | /**
160 | * Force word wrapping when the text appears to be of a minified/generated file.
161 | * Defaults to true.
162 | */
163 | // "editor.wordWrapMinified": true,
164 | /**
165 | * Control indentation of wrapped lines. Can be: 'none', 'same', 'indent' or 'deepIndent'.
166 | * Defaults to 'same' in vscode and to 'none' in monaco-editor.
167 | */
168 | // "editor.wrappingIndent": "same",
169 | /**
170 | * Configure word wrapping characters. A break will be introduced before these characters.
171 | * Defaults to '{([+'.
172 | */
173 | // "editor.wordWrapBreakBeforeCharacters": "{([+",
174 | /**
175 | * Configure word wrapping characters. A break will be introduced after these characters.
176 | * Defaults to ' \t})]?|&,;'.
177 | */
178 | // "editor.wordWrapBreakAfterCharacters": " \t})]?|&,;",
179 | /**
180 | * Configure word wrapping characters. A break will be introduced after these characters only if no `wordWrapBreakBeforeCharacters` or `wordWrapBreakAfterCharacters` were found.
181 | * Defaults to '.'.
182 | */
183 | // "editor.wordWrapBreakObtrusiveCharacters": ".",
184 | /**
185 | * Performance guard: Stop rendering a line after x characters.
186 | * Defaults to 10000.
187 | * Use -1 to never stop rendering
188 | */
189 | // "editor.stopRenderingLineAfter": -1,
190 | /**
191 | * Configure the editor's hover.
192 | */
193 | // "editor.hover": {
194 | /**
195 | * Enable the hover.
196 | * Defaults to true.
197 | */
198 | // "enabled": true,
199 | /**
200 | * Delay for showing the hover.
201 | * Defaults to 300.
202 | */
203 | // "delay": 300,
204 | /**
205 | * Is the hover sticky such that it can be clicked and its contents selected?
206 | * Defaults to true.
207 | */
208 | // "sticky": true
209 | // },
210 | /**
211 | * Enable detecting links and making them clickable.
212 | * Defaults to true.
213 | */
214 | // "editor.links": true,
215 | /**
216 | * Enable inline color decorators and color picker rendering.
217 | */
218 | // "editor.colorDecorators": true,
219 | /**
220 | * Enable custom contextmenu.
221 | * Defaults to true.
222 | */
223 | // "editor.contextmenu": true,
224 | /**
225 | * A multiplier to be used on the `deltaX` and `deltaY` of mouse wheel scroll events.
226 | * Defaults to 1.
227 | */
228 | // "editor.mouseWheelScrollSensitivity": 1,
229 | /**
230 | * The modifier to be used to add multiple cursors with the mouse.
231 | * Defaults to 'alt'
232 | */
233 | // "editor.multiCursorModifier": "alt",
234 | /**
235 | * Merge overlapping selections.
236 | * Defaults to true
237 | */
238 | // "editor.multiCursorMergeOverlapping": true,
239 | /**
240 | * Configure the editor's accessibility support.
241 | * Defaults to 'auto'. It is best to leave this to 'auto'.
242 | */
243 | // "editor.accessibilitySupport": "auto",
244 | /**
245 | * Suggest options.
246 | */
247 | // "editor.suggest": {
248 | /**
249 | * Enable graceful matching. Defaults to true.
250 | */
251 | // "filterGraceful": true,
252 | /**
253 | * Prevent quick suggestions when a snippet is active. Defaults to true.
254 | */
255 | // "snippetsPreventQuickSuggestions": true
256 | // },
257 | /**
258 | * Enable quick suggestions (shadow suggestions)
259 | * Defaults to true.
260 | */
261 | // "editor.quickSuggestions": true,
262 | /**
263 | * Quick suggestions show delay (in ms)
264 | * Defaults to 500 (ms)
265 | */
266 | // "editor.quickSuggestionsDelay": 10,
267 | /**
268 | * Parameter hint options.
269 | */
270 | // "editor.parameterHints": true,
271 | /**
272 | * Render icons in suggestions box.
273 | * Defaults to true.
274 | */
275 | // "editor.iconsInSuggestions": true,
276 | /**
277 | * Options for auto closing brackets.
278 | * Defaults to language defined behavior.
279 | */
280 | // "editor.autoClosingBrackets": true,
281 | /**
282 | * Controls whether the editor should automatically adjust the indentation
283 | * when users type, paste, move or indent lines.
284 | * Defaults to advanced.
285 | */
286 | // "editor.autoIndent": "full",
287 | /**
288 | * Enable format on type.
289 | * Defaults to false.
290 | */
291 | // "editor.formatOnType": false,
292 | /**
293 | * Enable format on paste.
294 | * Defaults to false.
295 | */
296 | // "editor.formatOnPaste": false,
297 | /**
298 | * Enable format on save.
299 | * Defaults to true.
300 | */
301 | // "editor.formatOnSave": false,
302 | /**
303 | * Controls if the editor should allow to move selections via drag and drop.
304 | * Defaults to false.
305 | */
306 | // "editor.dragAndDrop": false,
307 | /**
308 | * Enable the suggestion box to pop-up on trigger characters.
309 | * Defaults to true.
310 | */
311 | // "editor.suggestOnTriggerCharacters": true,
312 | /**
313 | * Accept suggestions on ENTER.
314 | * Defaults to 'on'.
315 | */
316 | // "editor.acceptSuggestionOnEnter": "on",
317 | /**
318 | * Accept suggestions on provider defined characters.
319 | * Defaults to true.
320 | */
321 | // "editor.acceptSuggestionOnCommitCharacter": true,
322 | /**
323 | * Enable snippet suggestions. Default to 'true'.
324 | */
325 | // "editor.snippetSuggestions": "top",
326 | /**
327 | * Copying without a selection copies the current line.
328 | */
329 | // "editor.emptySelectionClipboard": true,
330 | /**
331 | * Enable word based suggestions. Defaults to 'true'
332 | */
333 | // "editor.wordBasedSuggestions": true,
334 | /**
335 | * The history mode for suggestions.
336 | */
337 | // "editor.suggestSelection": "recentlyUsedByPrefix",
338 | /**
339 | * Enable selection highlight.
340 | * Defaults to true.
341 | */
342 | // "editor.selectionHighlight": true,
343 | /**
344 | * Enable semantic occurrences highlight.
345 | * Defaults to true.
346 | */
347 | // "editor.occurrencesHighlight": true,
348 | /**
349 | * Show code lens
350 | * Defaults to true.
351 | */
352 | // "editor.codeLens": true,
353 | /**
354 | * Control the behavior and rendering of the code action lightbulb.
355 | */
356 | // "editor.lightbulb": {
357 | // "enabled": true
358 | // },
359 | /**
360 | * Enable code folding
361 | * Defaults to true.
362 | */
363 | // "editor.folding": true,
364 | /**
365 | * Selects the folding strategy. 'auto' uses the strategies contributed for the current document, 'indentation' uses the indentation based folding strategy.
366 | * Defaults to 'auto'.
367 | */
368 | // "editor.foldingStrategy": "auto",
369 | /**
370 | * Controls whether the fold actions in the gutter stay always visible or hide unless the mouse is over the gutter.
371 | * Defaults to 'mouseover'.
372 | */
373 | // "editor.showFoldingControls": "mouseover",
374 | /**
375 | * Enable highlighting of matching brackets.
376 | * Defaults to true.
377 | */
378 | // "editor.matchBrackets": true,
379 | /**
380 | * Enable rendering of whitespace.
381 | * Defaults to none.
382 | */
383 | // "editor.renderWhitespace": "none",
384 | /**
385 | * Enable rendering of control characters.
386 | * Defaults to false.
387 | */
388 | // "editor.renderControlCharacters": false,
389 | /**
390 | * Enable rendering of indent guides.
391 | * Defaults to true.
392 | */
393 | // "editor.renderIndentGuides": true,
394 | /**
395 | * Enable highlighting of the active indent guide.
396 | * Defaults to true.
397 | */
398 | // "editor.highlightActiveIndentGuide": true,
399 | /**
400 | * Enable rendering of current line highlight.
401 | * Defaults to all.
402 | */
403 | // "editor.renderLineHighlight": "all",
404 | /**
405 | * Inserting and deleting whitespace follows tab stops.
406 | */
407 | // "editor.useTabStops": true,
408 | /**
409 | * The font family
410 | */
411 | // "editor.fontFamily": "Fira Code, Menlo, Monaco, 'Courier New', monospace",
412 | /**
413 | * The font weight
414 | */
415 | // "editor.fontWeight": "normal",
416 | /**
417 | * The font size
418 | */
419 | // "editor.fontSize": 12,
420 | /**
421 | * The line height
422 | */
423 | // "editor.lineHeight": 18,
424 | /**
425 | * The letter spacing
426 | */
427 | // "editor.letterSpacing": 0,
428 | /**
429 | * Controls fading out of unused variables.
430 | */
431 | // "editor.showUnused": true
432 | }
433 |
--------------------------------------------------------------------------------
/.vscode/snippets.json:
--------------------------------------------------------------------------------
1 | {
2 | // Place your global snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
3 | // description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
4 | // is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
5 | // used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
6 | // $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
7 | // Placeholders with the same ids are connected.
8 | // Example:
9 | // "Print to console": {
10 | // "scope": "javascript,typescript",
11 | // "prefix": "log",
12 | // "body": [
13 | // "console.log('$1');",
14 | // "$2"
15 | // ],
16 | // "description": "Log output to console"
17 | // }
18 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Lit Reactive Forms
2 |
3 | Heavily inspired by Angular Forms, this package provides utilities for complex Model-driven form management in Lit-based Web Components.
4 |
5 | ## Status
6 |
7 | 🚧 This library is published in order to get feedback, it's not production ready, and it's not yet published to npm. 🚧
8 |
9 | ## Features
10 |
11 | - Single source of truth (Model-driven)
12 | - Fully type-safe, no nullable values unless explicitly defined
13 | - 3 composable Controllers: `FormControl`, `FormGroup`, `FormArray`
14 | - Template bindings
15 | - State tracking (_dirty_, _touched_, _blurred_)
16 | - UI State helpers (_disabled_, _readonly_)
17 | - Imperative manipulation (_set_, _patch_, _setDirty_, _setTouched_...)
18 | - Dynamic forms (_FormArray_) with convenience methods: _move_, _swap_, _insertAt_, _removeAt_, _append_...
19 | - Support for binding multiple elements to the same control
20 | - Built-in validators which automatically add attributes to the bound elements (optional)
21 | - Utility for creating custom validators with side-effects (eg. change the default a11y attributes)
22 | - Asynchronous Validators
23 | - Validation status (`VALID`, `INVALID`, `PENDING`)
24 | - Cross-field validation (_FormGroup_, _FormArray_)
25 | - Imperatively add/remove Validators
26 | - Imperatively re-run validators
27 | - **RxJS Observables**
28 | - Support for custom controls
29 | - Create your own `ControlAccessor`s for custom-elements, in order to:
30 | - Manipulate its value
31 | - React to custom events
32 | - React to a `ValidationState` change (eg. set custom attributes)
33 | - React to a `UIState` change (eg. set `disabled` or `readonly` attributes)
34 |
35 | ## Philosophy
36 |
37 | This package provides 3 main classes which you can use to compose your forms:
38 | - `FormControl`, represents a single control
39 | - `FormGroup`, represents a group of controls
40 | - `FormArray`, represents an array of controls
41 |
42 | Nested controls are allowed, so you can represent any kind of hierarchy.
43 |
44 | A convenience class is also exported to make it easier to compose forms, called `FormBuilder`. It can reduce the boilerplate and apply the same configuration to all the `FormControl`s it generates.
45 |
46 | The form's model is fully typed and expects default values on declaration, which will be used upon calling `reset()`.
47 |
48 | ## Validation
49 |
50 | Validators are also provided to validate your `FormControl`s:
51 |
52 | - `required`
53 | - `requiredTrue`
54 | - `minLength`
55 | - `maxLength`
56 | - `min`
57 | - `max`
58 | - `email`
59 | - `pattern`
60 |
61 | You're free to write and use your owns as simple functions.
62 |
63 | This library also lets you specify Asynchronous Validators (which must return a Promise), and both Synchronous and Asynchronous Validators for `FormGroup`s and `FormArray`s (Cross-field Validation).
64 |
65 | Given the hierarchical nature of `FormGroup`s, JavaScript is expected to handle form submissions. No progressive enchancement feature is planned, because nested objects would not be sent in a regular form submission.
66 |
67 | **RxJS** is a required peerDependency, as the library provides some Observables to observe field changes and uses Observables intensively.
68 |
69 | ### FormControl
70 |
71 | Represents a single control.
72 |
73 | ```ts
74 | // Standard syntax (only `defaultValue` is mandatory)
75 | name = new FormControl(this, {
76 | defaultValue: 'John', // The control is inferred as FormControl
77 | validators: [],
78 | asyncValidators: [],
79 | ...
80 | })
81 |
82 | // With FormBuilder
83 | fb = new FormBuilder(this);
84 |
85 | name = this.fb.control('John');
86 |
87 |
88 | // Binding
89 | render() {
90 | return html`
91 |
92 | `
93 | }
94 | ```
95 |
96 | #### API
97 | - `config: FormControlConfig`: the configuration for the control. It contains:
98 | - `validators: Validator[]`, an array of validators
99 | - `asyncValidators: AsyncValidator[]`, an array of asynchronous validators
100 | - `updateOn: 'input' | 'blur'`, strategy for when the model should be updated
101 | - `accessorFactory: ControlAccessorFactory`, a factory function which accepts an `HTMLElement` and returns a `ControlAccessor`
102 | - `bind`: a Lit Directive to bind the control to an HTMLElement
103 | - `value: T`: the current value
104 | - `status: ValidationStatus`: either `VALID`, `INVALID` or `PENDING`
105 | - `errors: string[]`: current errors
106 | - `hasError(error: string): boolean`: if the control has a particular error
107 | - `reset(clearStates = true): void`: sets to the default value, `clearStates` sets dirty/touched/blurred to `false`
108 | - `set(value: T): void`: sets a new value
109 | - `isDirty: boolean`: if the value has *ever* been changed by the user
110 | - `isTouched: boolean`: if the field has been touched by the user
111 | - `isBlurred: boolean`: if the field has been blurred by the user
112 | - `uiState: UIState`: either `ENABLED`, `DISABLED` or `READONLY`
113 | - `setDirty(is = true): void`
114 | - `setTouched(is = true): void`
115 | - `setBlurred(is = true): void`
116 | - `setUIState(state: UIState): void`
117 | - `setFixedErrors(errors: ValidationError[]): void`: use this to set custom errors, they won't be erased by validators
118 | - `setValidators(validators: Validator[]): void`: replaces the validators
119 | - `setAsyncValidators(asyncValidators: AsyncValidator[]): void`: replaces the async validators
120 | - `rerunValidators`
121 | - `rerunAsyncValidators`
122 | - `valueChanges(): Observable`
123 | - `uiStateChanges(): Observable`
124 | - `statusChanges(): Observable`
125 |
126 | ### FormGroup
127 |
128 | Represents a group of controls.
129 |
130 | ```ts
131 | fb = new FormBuilder(this);
132 |
133 | form = this.fb.group({
134 | user: this.fb.group({
135 | name: this.fb.control(''),
136 | surname: this.fb.control(''),
137 | }),
138 | consent: this.fb.control(false)
139 | }, {
140 | validators: [],
141 | asyncValidators: [],
142 | });
143 |
144 |
145 | // Binding (dotted syntax for nested FormGroups)
146 | render() {
147 | const { bind } = this.form;
148 |
149 | return html`
150 |
151 |
152 |
153 | `
154 | }
155 | ```
156 |
157 | #### API
158 | - `controls: T`: the structure you provided
159 | - `config: FormGroupConfig`: the configuration for the group. It contains:
160 | - `validators: Validator[]`, an array of validators
161 | - `asyncValidators: AsyncValidator[]`, an array of asynchronous validators
162 | - `bind: (key: BindKey, config: BindConfig) => Directive`: a Lit Directive to bind the controls to HTMLElements
163 | - `bindWith: (config) => (key) => Directive`: a curried version of bind with the argument in reverse order, useful for reusing the same configuration for every field
164 | - `value: GroupValue`: the current value of the entire form
165 | - `enabledValue: EnabledGroupValue`: the current value of the entire form, without disabled fields
166 | - `status: ValidationStatus`: either `VALID`, `INVALID` or `PENDING`. It combines child validators with the group's validators. Invalid or pending if one child is invalid or pending.
167 | - `errors: string[]`: current cross-field errors
168 | - `hasError(error: string): boolean`: if the form has a particular cross-field error
169 | - `get(key: K): T[K]`: retrieves a control
170 | - `reset(clearStates = true): void`: sets to the default value, `clearStates` sets dirty/touched/blurred to `false` for each control
171 | - `set(value: GroupValue): void`: sets a new value, use this method if you want to be sure to set every field
172 | - `patch(value: Partial>): void`: sets a new value (partial)
173 | - `isDirty: boolean`: if at least one child is dirty
174 | - `isTouched: boolean`: if at least one child is touched
175 | - `isBlurred: boolean`: if at least one child is blurred
176 | - `setFixedErrors(errors: ValidationError[]): void`: use this to set custom errors, they won't be touched by validators
177 | - `setValidators(validators: Validator[]): void`: replaces the validators
178 | - `setAsyncValidators(asyncValidators: AsyncValidator[]): void`: replaces the async validators
179 | - `rerunValidators`
180 | - `rerunAsyncValidators`
181 | - `valueChanges(): Observable>`
182 | - `statusChanges(): Observable`
183 | - `addControl(name: string, control: AbstractControl)`: adds a control to the group [_experimental_]
184 | - `setControl(name: string, control: AbstractControl)`: replaces a control of the group [_experimental_]
185 | - `removeControl(name: string)`: removes a control of the group [_experimental_]
186 |
187 | ### FormArray
188 |
189 | Represents an array of controls. They can be any of the 3 classes (FormControl, FormGroup or FormArray).
190 |
191 | ```ts
192 | fb = new FormBuilder(this);
193 | // The first argument is the initial controls, there's no "default" controls with FormArray's.
194 | phones = this.fb.array>([]),
195 |
196 | // Binding
197 | render() {
198 | return html`
199 | ${this.phones.controls.map(c => html`
200 |
201 | `)}
202 | `
203 | }
204 | ```
205 |
206 | #### API
207 | - `controls: T[]`: the controls at each moment
208 | - `config`: the configuration for the array. It contains:
209 | - `initialItems: T[]`: initial items to be added to the array
210 | - `validators: Validator[]`, an array of validators
211 | - `asyncValidators: AsyncValidator[]`, an array of asynchronous validators
212 | - `bind`: a Lit Directive to bind the controls to HTMLElements
213 | - `value: ArrayValue[]`: the current value of the array
214 | - `status: ValidationStatus`: either `VALID`, `INVALID` or `PENDING`. It combines child validators with the array's validators. Invalid or pending if one child is invalid or pending.
215 | - `errors: string[]`: current cross-field errors
216 | - `hasError(error: string): boolean`: if the form has a particular cross-field error
217 | - `get(index: number): T | null`: retrieves a control
218 | - `reset(clearStates = true): void`: resets each child (does not reset the array)
219 | - `set(value: ArrayValue[]): void`: sets a new value for the array, if compatible. Does NOT create new controls
220 | - `clear(): void`: removes all controls
221 | - `isDirty: boolean`: if at least one child is dirty
222 | - `isTouched: boolean`: if at least one child is touched
223 | - `isBlurred: boolean`: if at least one child is blurred
224 | - `setFixedErrors(errors: ValidationError[]): void`: use this to set custom errors, they won't be touched by validators
225 | - `setValidators(validators: Validator[]): void`: replaces the validators
226 | - `setAsyncValidators(asyncValidators: AsyncValidator[]): void`: replaces the async validators
227 | - `rerunValidators`
228 | - `rerunAsyncValidators`
229 | - `valueChanges(): Observable[]>`
230 | - `valueChanges(index: number): Observable | null>`
231 | - `statusChanges(): Observable`
232 | - `insertAt(control: T, index: number): void`
233 | - `append(control: T): void`
234 | - `prepend(control: T): void`
235 | - `removeAt(index: number): void`
236 | - `pop(): void`
237 | - `swap(indexA: number, indexB: number): void`: swaps only if both indexes are valid
238 | - `move(from: number, to: number): void`: moves only if both indexes are valid
239 |
240 | ## FAQ
241 |
242 | #### Disabled & Readonly
243 | The `value` property of a `FormControl` is not nullable by default, even if the field gets disabled you'll be able to retrieve its value. Same goes with `FormGroup`s, its value always respects its shape.
244 |
245 | But if you need a way to strip disabled fields from a `FormGroup`, you can use the `enabledValue` property which makes all `FormControl`s optional. However, `FormGroup`s and `FormArray`s will always be there in the value: they cannot be `disabled` per-se, it doesn't make sense. So, in case of nested forms, you'll have groups and arrays' properties in your final object.
246 |
247 | The library also supports the `readonly` state: a `FormControl` can either be `ENABLED`, `DISABLED` or `READONLY` (one at a time). Controls which are marked as `readonly` will always be there even in the `enabledValue`. This attribute may be useful for accessibility, but watch out: not all native controls support it! But if you want to use it in certain cases, you could write your own `FieldAccessor` to set the underlying control as `disabled` even though it's in a `READONLY` state.
248 |
249 | #### Native validation
250 | The library exports a set of `ValidatorsWithEffects` which resemble the native ones (`required`, `minLength`, `pattern`...). They'll automatically set a11y attributes on your bound elements. If you don't want this behavior, use `PureValidators`, which have no side-effects on the DOM.
251 |
252 | You're free to not use the library's validators and use other libraries for that (eg. `Yup`). The library provides an utility method for all controls, called `setFixedErrors`, which lets you append custom errors to your controls and won't be erased unless you call the function again with new errors. Think of it as a "cauldron" for errors, it may be useful.
253 |
254 | You can write your own validators if you're not satisfied with the built-in effects: for example, you may want to support Custom Elements which require `maxLength` (camelCase) instead of `maxlength`. You can reuse the same built-in logic and add your own effects like this:
255 |
256 | ```ts
257 | import { addEffectsToValidator, PureValidators } from 'lit-reactive-forms';
258 |
259 | // Simple validator
260 | const requiredTrue = addEffectsToValidator(PureValidators.requiredTrue,
261 | // This function will be called when the validator is connected...
262 | (el) => { el.setAttribute('whatever', '') },
263 | // ...and this one when it's disconnected
264 | (el) => { el.removeAttribute('whatever') }
265 | );
266 |
267 | // Validator factory
268 | function maxLength(n: number) {
269 | return addEffectsToValidator(PureValidators.maxLength(n),
270 | (el) => { el.setAttribute('maxLength', '' + n) },
271 | (el) => { el.removeAttribute('maxLength') }
272 | );
273 | }
274 | ```
275 |
276 | #### Asynchronous Validators
277 | If you come from *Angular* (which is the main inspiration for this project), you'll know that validators behave in an interesting way: they don't run if the field is already invalidated by synchronous validators. Same goes for cross-field validation: if a child is invalid, they don't run. Also, disabled fields are not validated.
278 |
279 | Although this is a cool feature and can potentially save resources, many developers always want to know all the errors for a field, and therefore all validators must run. It can get frustrating pretty easily, forcing you to wrap your controls in nested groups just because otherwise validators wouldn't run.
280 |
281 | This library **always** runs asynchronous validators for a field when its value changes and it doesn't care if its disabled or not. Some may use the `disabled` state just to stop interaction, but may want to validate the control anyway.
282 |
283 | If you want to, you can debounce your validators yourself with a helper, knowing that the library will stop the API call and abort the Promise should the value change in the meantime. This way, the API call will be made either way but at least you won't make too many calls while the user is typing. Another option would be to not use asynchronous validators but listen to the form by yourself via the provided Observables (`valueChanges`, `statusChanges`). This way, you can fine-tune your calls and use `setFixedErrors` to set your errors manually.
284 |
285 | Beware that synchronous validators always have precedence: this means that if a field is "synchronously" invalid, its asynchronous validators will run, but its state will be `INVALID`, not `PENDING` in the meantime.
286 |
287 | #### Accessing nested controls
288 | The `bind` directive lets you bind to nested controls this way:
289 |
290 | ```ts
291 | bind('user.name')
292 | ```
293 |
294 | But if you're dealing with a `FormArray`, you should map its controls yourself and bind each one individually.
295 |
296 | Either way, if you need to *get* a control, `FormGroup` and `FormArray` both have a `get` method, which takes a property for the former or an index for the latter.
297 |
298 | You can access nested controls this way:
299 |
300 | ```ts
301 | form.get('user').get('name');
302 | ```
303 |
304 | #### DefaultValues vs initialValues
305 | A `FormControl` must have a default value, which will be used when calling `reset`. This way, there are no nullable values by default. The default value is also used initially.
306 |
307 | A `FormGroup` doesn't really have a "value", it has controls. Its shape is fixed and cannot change: calling `reset` on it will cause the calling of `reset` on every child, nothing strange.
308 |
309 | A `FormArray` works a bit differently. Since it doesn't work with values but with other controls, there's no "default value" for it, in order not to cause problems with cloning. Calling `reset` will *not* empty the array, but it will call `reset` on every child. If you wish to empty the array, use `clear`.
310 |
311 | However, a `FormArray` can have an initial value: an array of controls. Beware that these are *not* default values, as calling `reset` doesn't care about them being there or not: it doesn't care.
312 |
313 | #### Progressive Enhancement
314 | This library is fundamentally different from how native forms work: for example, with native forms it's not possible to send nested objects. Also, disabled fields are a controversial topic: some developers use `disabled` to interrupt interaction, but they want the value anyway, but this is not how native form submissions work. And in case of nested controls: should the property be there or not? That's an opinion.
315 |
316 | This library is opinionated and meant to work with JavaScript enabled in order for you to submit your values via API call. For this reason, it makes no attempt to be "progressively enhanced" in any way (as, for example, *Remix* does).
317 |
318 | #### `ControlAccessor`s for Custom Elements
319 |
320 | Different controls yield different values: for example, an `` works with strings, `` works with numbers.
321 |
322 | This library detects what kind of element is bound with the `bind` directive and sets up an appropriate `ControlAccessor`, which provides methods to interact with the element.
323 |
324 | There are different Accessors, you'll probably never touch them: `TextAccessor`, `NumberAccessor`, `SelectMultipleAccessor`...
325 |
326 | If the library encounters a Custom Element, it cannot know how to communicate with it. By default, it tries with the `BaseControlAccessor` which treats it like an ``.
327 |
328 | You may want to write your own `ControlAccessor`s for your Custom Elements: it's pretty easy! They're just classes.
329 |
330 | This is the interface they have to implement:
331 |
332 | ```ts
333 | interface ControlAccessor {
334 | getValue(): T;
335 | setValue(value: T): void;
336 | setUIState?(state: UIState): void;
337 | setValidity?(status: ValidationStatus | null): void;
338 | registerOnChange(fn: () => void): void;
339 | registerOnTouch?(fn: () => void): void;
340 | registerOnBlur?(fn: () => void): void;
341 | onDisconnect?(): void;
342 | }
343 | ```
344 |
345 | Instead of writing your accessor from zero, it's convenient to extend the `BaseControlAccessor` which implements all methods and already has a constructor setup correctly (must accept an element instance) and properties for saving the 3 callbacks for the `registerOn` methods.
346 |
347 | Suppose we have a `Counter` element (``), which deals with a `number`. This is what we could do:
348 |
349 | ```ts
350 | export class CounterAccessor extends BaseControlAccessor {
351 |
352 | // Here we tell the library how to retrieve its value (DOM -> Model).
353 | getValue() {
354 | return this.el.value;
355 | }
356 |
357 | // Here we tell the library how to set its value (Model -> DOM).
358 | setValue(x: number) {
359 | this.el.value = x;
360 | }
361 |
362 | // This gets called whenever the UIState changes.
363 | setUIState(uiState: UIState) {
364 | this.el.disabled = uiState === 'DISABLED' || uiState === 'READONLY';
365 | }
366 |
367 | // The element may emit a custom event which is not called `input`: here we setup event listeners.
368 | // We must notify the library when the value changes. The value isn't needed: it'll take it from `getValue`.
369 | // We save the callback function to remove the listener later.
370 | registerOnChange(fn: () => void) {
371 | this.onChange = fn;
372 | this.el.addEventListener('counterChange', this.onChange);
373 | }
374 |
375 | // Same as above, but for the `isTouched` property. We could also use the standard `focus` event.
376 | registerOnTouch(fn: () => void) {
377 | this.onTouch = fn;
378 | this.el.addEventListener('counterFocus', this.onTouch);
379 | }
380 |
381 | // Same as above, but for the `isBlurred` property.
382 | registerOnBlur(fn: () => void) {
383 | this.onBlur = fn;
384 | this.el.addEventListener('counterBlur', this.onBlur);
385 | }
386 |
387 | // Here we remove all the listeners.
388 | onDisconnect() {
389 | this.el.removeEventListener('counterChange', this.onChange);
390 | this.el.removeEventListener('counterFocus', this.onTouch);
391 | this.el.removeEventListener('counterBlur', this.onBlur);
392 | }
393 | }
394 | ```
395 |
396 | Once you have this, you can pass it to the `bind` directive when binding the element:
397 |
398 | ```ts
399 | html`
400 |
401 | `
402 | ```
403 |
404 | However passing it every time can cause a lot of noise: more on this in the next section.
405 |
406 | #### FormBuilder
407 |
408 | You can use `FormBuilder` to remove a lot of boilerplate. For example, you can set a custom configuration which will be used by all controls:
409 |
410 | ```ts
411 | // Every control will update the model on blur
412 | fb = new FormBuilder(this, {
413 | updateOn: 'blur'
414 | });
415 | ```
416 |
417 | But you can always override this "group" configuration with the `bind` directive:
418 |
419 | ```ts
420 | html`
421 |
422 |
423 | `
424 | ```
425 |
426 | Or, if you're using custom elements which require `ControlAccessor`s, you can replace the `accessorFactory`, the function which chooses the correct Accessor for each element:
427 |
428 | ```ts
429 | fb = new FormBuilder(this, {
430 | accessorFactory: myAccessorFactory
431 | });
432 | ```
433 |
434 | This way you don't have to specify the accessor every time with the `bind` directive.
435 |
436 | This is what the default `ControlAccessorFactory` looks like:
437 |
438 | ```ts
439 | export const getControlAccessor: ControlAccessorFactory = (el) => {
440 | if (el.localName === 'input' && el.getAttribute('type') === 'checkbox') {
441 | return new CheckboxAccessor(el as HTMLInputElement);
442 | }
443 | if (el.localName === 'input' && el.getAttribute('type') === 'number') {
444 | return new NumberAccessor(el as HTMLInputElement);
445 | }
446 | ...
447 | return new BaseControlAccessor(el);
448 | }
449 | ```
450 |
451 | You may want to reuse it, like this:
452 |
453 | ```ts
454 | export const myAccessorFactory: ControlAccessorFactory = (el) => {
455 | if (el.localName === 'my-counter') {
456 | return new CounterAccessor(el as Counter);
457 | }
458 | return getControlAccessor(el);
459 | }
460 | ```
461 |
--------------------------------------------------------------------------------
/demo/counter.ts:
--------------------------------------------------------------------------------
1 | import { html, LitElement } from 'lit';
2 | import { customElement, property } from 'lit/decorators.js';
3 | import { BaseControlAccessor, UIState } from '../src';
4 |
5 | @customElement('app-counter')
6 | export class Counter extends LitElement {
7 |
8 | @property({ type: Number })
9 | value = 0;
10 |
11 | @property({ type: Boolean })
12 | disabled = false;
13 |
14 | render() {
15 | return html`
16 |
17 |
18 | ${this.value}
19 |
20 |
21 | `;
22 | }
23 |
24 | minus() {
25 | this.value--;
26 | this.onChange();
27 | }
28 |
29 | plus() {
30 | this.value++;
31 | this.onChange();
32 | }
33 |
34 | onChange() {
35 | this.dispatchEvent(new CustomEvent('counterChange', {
36 | detail: this.value
37 | }))
38 | }
39 |
40 | onFocus() {
41 | this.dispatchEvent(new CustomEvent('counterFocus'));
42 | }
43 |
44 | onBlur() {
45 | this.dispatchEvent(new CustomEvent('counterBlur'));
46 | }
47 | }
48 |
49 | /**
50 | * Our "app-counter" is a custom control. Its properties and events have custom names.
51 | * Here we tell the library how its events are called and how to manipulate its values from the outside.
52 | */
53 | export class CounterAccessor extends BaseControlAccessor {
54 |
55 | getValue() {
56 | return this.el.value;
57 | }
58 |
59 | setValue(x: number) {
60 | this.el.value = x;
61 | }
62 |
63 | setUIState(uiState: UIState) {
64 | this.el.disabled = uiState === 'DISABLED' || uiState === 'READONLY';
65 | }
66 |
67 | registerOnChange(fn: () => void) {
68 | this.onChange = fn;
69 | this.el.addEventListener('counterChange', this.onChange);
70 | }
71 |
72 | registerOnTouch(fn: () => void) {
73 | this.onTouch = fn;
74 | this.el.addEventListener('counterFocus', this.onTouch);
75 | }
76 |
77 | registerOnBlur(fn: () => void) {
78 | this.onBlur = fn;
79 | this.el.addEventListener('counterBlur', this.onBlur);
80 | }
81 |
82 | onDisconnect() {
83 | this.el.removeEventListener('counterChange', this.onChange);
84 | this.el.removeEventListener('counterFocus', this.onTouch);
85 | this.el.removeEventListener('counterBlur', this.onBlur);
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/demo/custom_validators.ts:
--------------------------------------------------------------------------------
1 | import { FormArray, FormControl, FormGroup, AsyncValidator, Validator, PureValidators } from '../src';
2 |
3 | // Validators for testing purposes
4 |
5 | export const allRequired: Validator>> = (a) => {
6 | return a.controls.every(c => !PureValidators.required(c)) ? null : 'allRequired';
7 | }
8 |
9 | export const sameLength = (...fields: string[]): Validator> => (g) => {
10 | const hasError = fields.some(field => {
11 | const array = g.get(field) as FormArray;
12 | const firstArray = g.get(fields[0]) as FormArray;
13 | return array?.controls.length !== firstArray?.controls.length;
14 | })
15 | return hasError ? 'sameLength' : null;
16 | }
17 |
18 | export const forbiddenCredentials = (
19 | forbiddenName: string,
20 | forbiddenSurname: string
21 | ): AsyncValidator> => (g) => {
22 | return new Promise((res) => {
23 | setTimeout(() => {
24 | const name = g.get('name') as FormControl;
25 | const surname = g.get('surname') as FormControl;
26 | res(
27 | name?.value === forbiddenName && surname?.value === forbiddenSurname
28 | ? 'forbiddenCredentials'
29 | : null
30 | )
31 | }, 1000);
32 | })
33 | }
34 |
--------------------------------------------------------------------------------
/demo/favicon.svg:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/demo/my-element.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from "lit";
2 |
3 | export const styles = css`
4 |
5 | input[valid], select[valid], textarea[valid] {
6 | border-color: green !important;
7 | }
8 |
9 | input[invalid], select[invalid], textarea[invalid] {
10 | border-color: red !important;
11 | }
12 |
13 | input[pending], select[pending], textarea[pending] {
14 | border-color: orange !important;
15 | }
16 |
17 | input, select, textarea {
18 | outline: 0;
19 | }
20 |
21 | input[disabled], select[disabled], textarea[disabled] {
22 | opacity: .3;
23 | }
24 |
25 | input[required] {
26 | background-image: radial-gradient(black 35%, transparent 35%);
27 | background-size: 1em 1em;
28 | background-position: top right;
29 | background-repeat: no-repeat
30 | }
31 | `
--------------------------------------------------------------------------------
/demo/my-element.ts:
--------------------------------------------------------------------------------
1 | import { html, LitElement } from 'lit';
2 | import { customElement } from 'lit/decorators.js';
3 | import { FormGroup, FormControl, FormBuilder, ValidatorsWithEffects as V, BindConfig } from '../src';
4 | import * as CV from './custom_validators';
5 | import { styles } from './my-element.styles';
6 | import { Counter, CounterAccessor } from './counter';
7 |
8 | const counterBindConfig: Partial> = {
9 | accessor: CounterAccessor,
10 | }
11 |
12 | @customElement('my-element')
13 | export class MyElement extends LitElement {
14 |
15 | static styles = styles;
16 |
17 | private fb = new FormBuilder(this);
18 |
19 | form = this.fb.group({
20 | user: this.fb.group({
21 | name: this.fb.control('Michele', [V.required, V.minLength(2)]),
22 | surname: this.fb.control('Stieven'),
23 | gender: this.fb.control<'M' | 'F'>('M'),
24 | }, {
25 | asyncValidators: [CV.forbiddenCredentials('mario', 'rossi')]
26 | }),
27 | agreement: this.fb.control(false),
28 | counter: this.fb.control(1),
29 | phones: this.fb.array>([], [CV.allRequired]),
30 | addresses: this.fb.array,
32 | nr: FormControl
33 | }>>(),
34 | }, {
35 | validators: [CV.sameLength('phones', 'addresses')]
36 | });
37 |
38 | submit(e: SubmitEvent) {
39 | e.preventDefault();
40 | console.log(this.form.value);
41 | }
42 |
43 | render() {
44 | const { bind } = this.form;
45 |
46 | return html`
47 |