├── .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 | 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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 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 |

Form

48 |
49 | 50 | 51 | 56 | 57 | 58 |
59 | Phones: ${this.form.get('phones').controls.map(c => html` 60 |
61 | `)} 62 |
63 | 64 | 65 |
66 | Addresses: ${this.form.get('addresses').controls.map(c => html` 67 |
68 | 69 | 70 | `)} 71 |
72 | 73 | 74 | 75 |
76 | 77 | 78 | 81 | 84 | 85 |
86 | ${this.renderDebugForm()} 87 | `; 88 | } 89 | 90 | renderDebugForm(form = this.form) { 91 | return html` 92 |
93 | Value:
${JSON.stringify(form.value, null, 2)}
94 | Enabled Value:
${JSON.stringify(form.enabledValue, null, 2)}
95 | Name dirty: ${form.get('user').get('name').isDirty}
96 | Name touched: ${form.get('user').get('name').isTouched}
97 | Name blurred: ${form.get('user').get('name').isBlurred}
98 | Name UI State: ${form.get('user').get('name').uiState}
99 | Name status: ${form.get('user').get('name').status}
100 | Name errors: ${JSON.stringify(form.get('user').get('name').errors)}
101 | User status: ${form.get('user').status}
102 | User errors: ${JSON.stringify(form.get('user').errors)}
103 | Phones status: ${form.get('phones').status}
104 | Phones errors: ${JSON.stringify(form.get('phones').errors)}
105 | Form status: ${form.status}
106 | Form errors: ${JSON.stringify(form.errors)}
107 | ` 108 | } 109 | 110 | addPhone() { 111 | this.form.get('phones').append( 112 | this.fb.control('') 113 | ); 114 | } 115 | 116 | removePhone() { 117 | this.form.get('phones').pop(); 118 | } 119 | 120 | addAddress() { 121 | this.form.get('addresses').append( 122 | this.fb.group({ 123 | street: this.fb.control('', [V.required]), 124 | nr: this.fb.control('', [V.required]) 125 | }) 126 | ); 127 | } 128 | 129 | removeAddress() { 130 | this.form.get('addresses').pop(); 131 | } 132 | 133 | patchForm() { 134 | this.form.patch({ 135 | user: { 136 | name: 'new name', 137 | surname: 'new surname', 138 | gender: 'F', 139 | }, 140 | agreement: true, 141 | counter: 3 142 | }) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /demo/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Lit Reactive Forms - Demo 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lit-reactive-forms", 3 | "version": "0.0.1", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@bcoe/v8-coverage": { 8 | "version": "0.2.3", 9 | "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", 10 | "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", 11 | "dev": true 12 | }, 13 | "@istanbuljs/schema": { 14 | "version": "0.1.3", 15 | "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", 16 | "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", 17 | "dev": true 18 | }, 19 | "@jridgewell/resolve-uri": { 20 | "version": "3.0.6", 21 | "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.6.tgz", 22 | "integrity": "sha512-R7xHtBSNm+9SyvpJkdQl+qrM3Hm2fea3Ef197M3mUug+v+yR+Rhfbs7PBtcBUVnIWJ4JcAdjvij+c8hXS9p5aw==", 23 | "dev": true 24 | }, 25 | "@jridgewell/sourcemap-codec": { 26 | "version": "1.4.11", 27 | "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz", 28 | "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg==", 29 | "dev": true 30 | }, 31 | "@jridgewell/trace-mapping": { 32 | "version": "0.3.9", 33 | "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", 34 | "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", 35 | "dev": true, 36 | "requires": { 37 | "@jridgewell/resolve-uri": "^3.0.3", 38 | "@jridgewell/sourcemap-codec": "^1.4.10" 39 | } 40 | }, 41 | "@lit/reactive-element": { 42 | "version": "1.2.1", 43 | "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.2.1.tgz", 44 | "integrity": "sha512-03FYfMguIWo9E1y1qcTpXzoO8Ukpn0j5o4GjNFq/iHqJEPY6pYopsU44e7NSFIgCTorr8wdUU5PfVy8VeD6Rwg==" 45 | }, 46 | "@polka/url": { 47 | "version": "1.0.0-next.21", 48 | "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", 49 | "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", 50 | "dev": true 51 | }, 52 | "@types/chai": { 53 | "version": "4.3.1", 54 | "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.1.tgz", 55 | "integrity": "sha512-/zPMqDkzSZ8t3VtxOa4KPq7uzzW978M9Tvh+j7GHKuo6k6GTLxPJ4J5gE5cjfJ26pnXst0N5Hax8Sr0T2Mi9zQ==", 56 | "dev": true 57 | }, 58 | "@types/chai-subset": { 59 | "version": "1.3.3", 60 | "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", 61 | "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==", 62 | "dev": true, 63 | "requires": { 64 | "@types/chai": "*" 65 | } 66 | }, 67 | "@types/concat-stream": { 68 | "version": "1.6.1", 69 | "resolved": "https://registry.npmjs.org/@types/concat-stream/-/concat-stream-1.6.1.tgz", 70 | "integrity": "sha512-eHE4cQPoj6ngxBZMvVf6Hw7Mh4jMW4U9lpGmS5GBPB9RYxlFg+CHaVN7ErNY4W9XfLIEn20b4VDYaIrbq0q4uA==", 71 | "dev": true, 72 | "requires": { 73 | "@types/node": "*" 74 | } 75 | }, 76 | "@types/form-data": { 77 | "version": "0.0.33", 78 | "resolved": "https://registry.npmjs.org/@types/form-data/-/form-data-0.0.33.tgz", 79 | "integrity": "sha1-yayFsqX9GENbjIXZ7LUObWyJP/g=", 80 | "dev": true, 81 | "requires": { 82 | "@types/node": "*" 83 | } 84 | }, 85 | "@types/istanbul-lib-coverage": { 86 | "version": "2.0.4", 87 | "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", 88 | "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", 89 | "dev": true 90 | }, 91 | "@types/node": { 92 | "version": "10.17.60", 93 | "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", 94 | "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", 95 | "dev": true 96 | }, 97 | "@types/qs": { 98 | "version": "6.9.7", 99 | "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", 100 | "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", 101 | "dev": true 102 | }, 103 | "@types/trusted-types": { 104 | "version": "2.0.2", 105 | "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", 106 | "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" 107 | }, 108 | "@vitest/ui": { 109 | "version": "0.10.0", 110 | "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-0.10.0.tgz", 111 | "integrity": "sha512-GMxAQV5b14YJg1so9rmaXRASsnCY04acNHKoNkJegHpVCxFQkMavZM14KT1qAxg2xLdFbUd903JDAncpyJDBTw==", 112 | "dev": true, 113 | "requires": { 114 | "sirv": "^2.0.2" 115 | } 116 | }, 117 | "ansi-regex": { 118 | "version": "5.0.1", 119 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", 120 | "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", 121 | "dev": true 122 | }, 123 | "ansi-styles": { 124 | "version": "4.3.0", 125 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", 126 | "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", 127 | "dev": true, 128 | "requires": { 129 | "color-convert": "^2.0.1" 130 | } 131 | }, 132 | "asap": { 133 | "version": "2.0.6", 134 | "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", 135 | "integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=", 136 | "dev": true 137 | }, 138 | "assertion-error": { 139 | "version": "1.1.0", 140 | "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", 141 | "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", 142 | "dev": true 143 | }, 144 | "asynckit": { 145 | "version": "0.4.0", 146 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 147 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", 148 | "dev": true 149 | }, 150 | "balanced-match": { 151 | "version": "1.0.2", 152 | "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", 153 | "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", 154 | "dev": true 155 | }, 156 | "brace-expansion": { 157 | "version": "1.1.11", 158 | "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", 159 | "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", 160 | "dev": true, 161 | "requires": { 162 | "balanced-match": "^1.0.0", 163 | "concat-map": "0.0.1" 164 | } 165 | }, 166 | "buffer-from": { 167 | "version": "1.1.2", 168 | "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", 169 | "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", 170 | "dev": true 171 | }, 172 | "c8": { 173 | "version": "7.11.2", 174 | "resolved": "https://registry.npmjs.org/c8/-/c8-7.11.2.tgz", 175 | "integrity": "sha512-6ahJSrhS6TqSghHm+HnWt/8Y2+z0hM/FQyB1ybKhAR30+NYL9CTQ1uwHxuWw6U7BHlHv6wvhgOrH81I+lfCkxg==", 176 | "dev": true, 177 | "requires": { 178 | "@bcoe/v8-coverage": "^0.2.3", 179 | "@istanbuljs/schema": "^0.1.3", 180 | "find-up": "^5.0.0", 181 | "foreground-child": "^2.0.0", 182 | "istanbul-lib-coverage": "^3.2.0", 183 | "istanbul-lib-report": "^3.0.0", 184 | "istanbul-reports": "^3.1.4", 185 | "rimraf": "^3.0.2", 186 | "test-exclude": "^6.0.0", 187 | "v8-to-istanbul": "^9.0.0", 188 | "yargs": "^16.2.0", 189 | "yargs-parser": "^20.2.9" 190 | } 191 | }, 192 | "call-bind": { 193 | "version": "1.0.2", 194 | "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", 195 | "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", 196 | "dev": true, 197 | "requires": { 198 | "function-bind": "^1.1.1", 199 | "get-intrinsic": "^1.0.2" 200 | } 201 | }, 202 | "caseless": { 203 | "version": "0.12.0", 204 | "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", 205 | "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", 206 | "dev": true 207 | }, 208 | "chai": { 209 | "version": "4.3.6", 210 | "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", 211 | "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", 212 | "dev": true, 213 | "requires": { 214 | "assertion-error": "^1.1.0", 215 | "check-error": "^1.0.2", 216 | "deep-eql": "^3.0.1", 217 | "get-func-name": "^2.0.0", 218 | "loupe": "^2.3.1", 219 | "pathval": "^1.1.1", 220 | "type-detect": "^4.0.5" 221 | } 222 | }, 223 | "check-error": { 224 | "version": "1.0.2", 225 | "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", 226 | "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", 227 | "dev": true 228 | }, 229 | "cliui": { 230 | "version": "7.0.4", 231 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", 232 | "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", 233 | "dev": true, 234 | "requires": { 235 | "string-width": "^4.2.0", 236 | "strip-ansi": "^6.0.0", 237 | "wrap-ansi": "^7.0.0" 238 | } 239 | }, 240 | "color-convert": { 241 | "version": "2.0.1", 242 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", 243 | "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", 244 | "dev": true, 245 | "requires": { 246 | "color-name": "~1.1.4" 247 | } 248 | }, 249 | "color-name": { 250 | "version": "1.1.4", 251 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", 252 | "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", 253 | "dev": true 254 | }, 255 | "combined-stream": { 256 | "version": "1.0.8", 257 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 258 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 259 | "dev": true, 260 | "requires": { 261 | "delayed-stream": "~1.0.0" 262 | } 263 | }, 264 | "concat-map": { 265 | "version": "0.0.1", 266 | "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", 267 | "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", 268 | "dev": true 269 | }, 270 | "concat-stream": { 271 | "version": "1.6.2", 272 | "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", 273 | "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", 274 | "dev": true, 275 | "requires": { 276 | "buffer-from": "^1.0.0", 277 | "inherits": "^2.0.3", 278 | "readable-stream": "^2.2.2", 279 | "typedarray": "^0.0.6" 280 | } 281 | }, 282 | "convert-source-map": { 283 | "version": "1.8.0", 284 | "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz", 285 | "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==", 286 | "dev": true, 287 | "requires": { 288 | "safe-buffer": "~5.1.1" 289 | } 290 | }, 291 | "core-util-is": { 292 | "version": "1.0.3", 293 | "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", 294 | "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", 295 | "dev": true 296 | }, 297 | "cross-spawn": { 298 | "version": "7.0.3", 299 | "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", 300 | "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", 301 | "dev": true, 302 | "requires": { 303 | "path-key": "^3.1.0", 304 | "shebang-command": "^2.0.0", 305 | "which": "^2.0.1" 306 | } 307 | }, 308 | "css.escape": { 309 | "version": "1.5.1", 310 | "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", 311 | "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=", 312 | "dev": true 313 | }, 314 | "deep-eql": { 315 | "version": "3.0.1", 316 | "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", 317 | "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", 318 | "dev": true, 319 | "requires": { 320 | "type-detect": "^4.0.0" 321 | } 322 | }, 323 | "delayed-stream": { 324 | "version": "1.0.0", 325 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 326 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", 327 | "dev": true 328 | }, 329 | "emoji-regex": { 330 | "version": "8.0.0", 331 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", 332 | "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", 333 | "dev": true 334 | }, 335 | "esbuild": { 336 | "version": "0.14.38", 337 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.38.tgz", 338 | "integrity": "sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==", 339 | "dev": true, 340 | "requires": { 341 | "esbuild-android-64": "0.14.38", 342 | "esbuild-android-arm64": "0.14.38", 343 | "esbuild-darwin-64": "0.14.38", 344 | "esbuild-darwin-arm64": "0.14.38", 345 | "esbuild-freebsd-64": "0.14.38", 346 | "esbuild-freebsd-arm64": "0.14.38", 347 | "esbuild-linux-32": "0.14.38", 348 | "esbuild-linux-64": "0.14.38", 349 | "esbuild-linux-arm": "0.14.38", 350 | "esbuild-linux-arm64": "0.14.38", 351 | "esbuild-linux-mips64le": "0.14.38", 352 | "esbuild-linux-ppc64le": "0.14.38", 353 | "esbuild-linux-riscv64": "0.14.38", 354 | "esbuild-linux-s390x": "0.14.38", 355 | "esbuild-netbsd-64": "0.14.38", 356 | "esbuild-openbsd-64": "0.14.38", 357 | "esbuild-sunos-64": "0.14.38", 358 | "esbuild-windows-32": "0.14.38", 359 | "esbuild-windows-64": "0.14.38", 360 | "esbuild-windows-arm64": "0.14.38" 361 | } 362 | }, 363 | "esbuild-android-64": { 364 | "version": "0.14.38", 365 | "resolved": "https://registry.npmjs.org/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz", 366 | "integrity": "sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw==", 367 | "dev": true, 368 | "optional": true 369 | }, 370 | "esbuild-android-arm64": { 371 | "version": "0.14.38", 372 | "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz", 373 | "integrity": "sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==", 374 | "dev": true, 375 | "optional": true 376 | }, 377 | "esbuild-darwin-64": { 378 | "version": "0.14.38", 379 | "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz", 380 | "integrity": "sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==", 381 | "dev": true, 382 | "optional": true 383 | }, 384 | "esbuild-darwin-arm64": { 385 | "version": "0.14.38", 386 | "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz", 387 | "integrity": "sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==", 388 | "dev": true, 389 | "optional": true 390 | }, 391 | "esbuild-freebsd-64": { 392 | "version": "0.14.38", 393 | "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz", 394 | "integrity": "sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==", 395 | "dev": true, 396 | "optional": true 397 | }, 398 | "esbuild-freebsd-arm64": { 399 | "version": "0.14.38", 400 | "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz", 401 | "integrity": "sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==", 402 | "dev": true, 403 | "optional": true 404 | }, 405 | "esbuild-linux-32": { 406 | "version": "0.14.38", 407 | "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz", 408 | "integrity": "sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==", 409 | "dev": true, 410 | "optional": true 411 | }, 412 | "esbuild-linux-64": { 413 | "version": "0.14.38", 414 | "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz", 415 | "integrity": "sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==", 416 | "dev": true, 417 | "optional": true 418 | }, 419 | "esbuild-linux-arm": { 420 | "version": "0.14.38", 421 | "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz", 422 | "integrity": "sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==", 423 | "dev": true, 424 | "optional": true 425 | }, 426 | "esbuild-linux-arm64": { 427 | "version": "0.14.38", 428 | "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz", 429 | "integrity": "sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==", 430 | "dev": true, 431 | "optional": true 432 | }, 433 | "esbuild-linux-mips64le": { 434 | "version": "0.14.38", 435 | "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz", 436 | "integrity": "sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==", 437 | "dev": true, 438 | "optional": true 439 | }, 440 | "esbuild-linux-ppc64le": { 441 | "version": "0.14.38", 442 | "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz", 443 | "integrity": "sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==", 444 | "dev": true, 445 | "optional": true 446 | }, 447 | "esbuild-linux-riscv64": { 448 | "version": "0.14.38", 449 | "resolved": "https://registry.npmjs.org/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz", 450 | "integrity": "sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ==", 451 | "dev": true, 452 | "optional": true 453 | }, 454 | "esbuild-linux-s390x": { 455 | "version": "0.14.38", 456 | "resolved": "https://registry.npmjs.org/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz", 457 | "integrity": "sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ==", 458 | "dev": true, 459 | "optional": true 460 | }, 461 | "esbuild-netbsd-64": { 462 | "version": "0.14.38", 463 | "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz", 464 | "integrity": "sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==", 465 | "dev": true, 466 | "optional": true 467 | }, 468 | "esbuild-openbsd-64": { 469 | "version": "0.14.38", 470 | "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz", 471 | "integrity": "sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==", 472 | "dev": true, 473 | "optional": true 474 | }, 475 | "esbuild-sunos-64": { 476 | "version": "0.14.38", 477 | "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz", 478 | "integrity": "sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==", 479 | "dev": true, 480 | "optional": true 481 | }, 482 | "esbuild-windows-32": { 483 | "version": "0.14.38", 484 | "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz", 485 | "integrity": "sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==", 486 | "dev": true, 487 | "optional": true 488 | }, 489 | "esbuild-windows-64": { 490 | "version": "0.14.38", 491 | "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz", 492 | "integrity": "sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==", 493 | "dev": true, 494 | "optional": true 495 | }, 496 | "esbuild-windows-arm64": { 497 | "version": "0.14.38", 498 | "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz", 499 | "integrity": "sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==", 500 | "dev": true, 501 | "optional": true 502 | }, 503 | "escalade": { 504 | "version": "3.1.1", 505 | "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", 506 | "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", 507 | "dev": true 508 | }, 509 | "find-up": { 510 | "version": "5.0.0", 511 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", 512 | "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", 513 | "dev": true, 514 | "requires": { 515 | "locate-path": "^6.0.0", 516 | "path-exists": "^4.0.0" 517 | } 518 | }, 519 | "foreground-child": { 520 | "version": "2.0.0", 521 | "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", 522 | "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", 523 | "dev": true, 524 | "requires": { 525 | "cross-spawn": "^7.0.0", 526 | "signal-exit": "^3.0.2" 527 | } 528 | }, 529 | "form-data": { 530 | "version": "2.5.1", 531 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", 532 | "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", 533 | "dev": true, 534 | "requires": { 535 | "asynckit": "^0.4.0", 536 | "combined-stream": "^1.0.6", 537 | "mime-types": "^2.1.12" 538 | } 539 | }, 540 | "fs.realpath": { 541 | "version": "1.0.0", 542 | "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", 543 | "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", 544 | "dev": true 545 | }, 546 | "fsevents": { 547 | "version": "2.3.2", 548 | "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", 549 | "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", 550 | "dev": true, 551 | "optional": true 552 | }, 553 | "function-bind": { 554 | "version": "1.1.1", 555 | "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", 556 | "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", 557 | "dev": true 558 | }, 559 | "get-caller-file": { 560 | "version": "2.0.5", 561 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 562 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 563 | "dev": true 564 | }, 565 | "get-func-name": { 566 | "version": "2.0.0", 567 | "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", 568 | "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", 569 | "dev": true 570 | }, 571 | "get-intrinsic": { 572 | "version": "1.1.1", 573 | "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz", 574 | "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==", 575 | "dev": true, 576 | "requires": { 577 | "function-bind": "^1.1.1", 578 | "has": "^1.0.3", 579 | "has-symbols": "^1.0.1" 580 | } 581 | }, 582 | "get-port": { 583 | "version": "3.2.0", 584 | "resolved": "https://registry.npmjs.org/get-port/-/get-port-3.2.0.tgz", 585 | "integrity": "sha1-3Xzn3hh8Bsi/NTeWrHHgmfCYDrw=", 586 | "dev": true 587 | }, 588 | "glob": { 589 | "version": "7.2.0", 590 | "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", 591 | "integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==", 592 | "dev": true, 593 | "requires": { 594 | "fs.realpath": "^1.0.0", 595 | "inflight": "^1.0.4", 596 | "inherits": "2", 597 | "minimatch": "^3.0.4", 598 | "once": "^1.3.0", 599 | "path-is-absolute": "^1.0.0" 600 | } 601 | }, 602 | "happy-dom": { 603 | "version": "3.1.0", 604 | "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-3.1.0.tgz", 605 | "integrity": "sha512-BewZQwLdu6JS9HYT7enB2toju80OjSjl44+3HXMB3hT+2skC9Mja+/N/b+SbtnwJCMbQqiZVzy/RXevPPuBIXQ==", 606 | "dev": true, 607 | "requires": { 608 | "css.escape": "^1.5.1", 609 | "he": "^1.2.0", 610 | "node-fetch": "^2.x.x", 611 | "sync-request": "^6.1.0", 612 | "webidl-conversions": "^7.0.0", 613 | "whatwg-encoding": "^2.0.0", 614 | "whatwg-mimetype": "^3.0.0" 615 | } 616 | }, 617 | "has": { 618 | "version": "1.0.3", 619 | "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", 620 | "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", 621 | "dev": true, 622 | "requires": { 623 | "function-bind": "^1.1.1" 624 | } 625 | }, 626 | "has-flag": { 627 | "version": "4.0.0", 628 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", 629 | "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", 630 | "dev": true 631 | }, 632 | "has-symbols": { 633 | "version": "1.0.3", 634 | "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", 635 | "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", 636 | "dev": true 637 | }, 638 | "he": { 639 | "version": "1.2.0", 640 | "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", 641 | "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", 642 | "dev": true 643 | }, 644 | "html-escaper": { 645 | "version": "2.0.2", 646 | "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", 647 | "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", 648 | "dev": true 649 | }, 650 | "http-basic": { 651 | "version": "8.1.3", 652 | "resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz", 653 | "integrity": "sha512-/EcDMwJZh3mABI2NhGfHOGOeOZITqfkEO4p/xK+l3NpyncIHUQBoMvCSF/b5GqvKtySC2srL/GGG3+EtlqlmCw==", 654 | "dev": true, 655 | "requires": { 656 | "caseless": "^0.12.0", 657 | "concat-stream": "^1.6.2", 658 | "http-response-object": "^3.0.1", 659 | "parse-cache-control": "^1.0.1" 660 | } 661 | }, 662 | "http-response-object": { 663 | "version": "3.0.2", 664 | "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", 665 | "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", 666 | "dev": true, 667 | "requires": { 668 | "@types/node": "^10.0.3" 669 | } 670 | }, 671 | "iconv-lite": { 672 | "version": "0.6.3", 673 | "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", 674 | "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", 675 | "dev": true, 676 | "requires": { 677 | "safer-buffer": ">= 2.1.2 < 3.0.0" 678 | } 679 | }, 680 | "inflight": { 681 | "version": "1.0.6", 682 | "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", 683 | "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", 684 | "dev": true, 685 | "requires": { 686 | "once": "^1.3.0", 687 | "wrappy": "1" 688 | } 689 | }, 690 | "inherits": { 691 | "version": "2.0.4", 692 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 693 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 694 | "dev": true 695 | }, 696 | "is-core-module": { 697 | "version": "2.8.1", 698 | "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz", 699 | "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==", 700 | "dev": true, 701 | "requires": { 702 | "has": "^1.0.3" 703 | } 704 | }, 705 | "is-fullwidth-code-point": { 706 | "version": "3.0.0", 707 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", 708 | "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", 709 | "dev": true 710 | }, 711 | "isarray": { 712 | "version": "1.0.0", 713 | "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", 714 | "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", 715 | "dev": true 716 | }, 717 | "isexe": { 718 | "version": "2.0.0", 719 | "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", 720 | "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", 721 | "dev": true 722 | }, 723 | "istanbul-lib-coverage": { 724 | "version": "3.2.0", 725 | "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", 726 | "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", 727 | "dev": true 728 | }, 729 | "istanbul-lib-report": { 730 | "version": "3.0.0", 731 | "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", 732 | "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", 733 | "dev": true, 734 | "requires": { 735 | "istanbul-lib-coverage": "^3.0.0", 736 | "make-dir": "^3.0.0", 737 | "supports-color": "^7.1.0" 738 | } 739 | }, 740 | "istanbul-reports": { 741 | "version": "3.1.4", 742 | "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz", 743 | "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==", 744 | "dev": true, 745 | "requires": { 746 | "html-escaper": "^2.0.0", 747 | "istanbul-lib-report": "^3.0.0" 748 | } 749 | }, 750 | "lit": { 751 | "version": "2.1.2", 752 | "resolved": "https://registry.npmjs.org/lit/-/lit-2.1.2.tgz", 753 | "integrity": "sha512-XacK89dJXF7BJbpiZSMvzT4RxHag7Wt+yNx7tErEVgGVlOFAeN871bj7ivotCMgYeBFWVp/hjKF/PDTk6L7gMA==", 754 | "requires": { 755 | "@lit/reactive-element": "^1.1.0", 756 | "lit-element": "^3.1.0", 757 | "lit-html": "^2.1.0" 758 | } 759 | }, 760 | "lit-element": { 761 | "version": "3.1.2", 762 | "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.1.2.tgz", 763 | "integrity": "sha512-5VLn5a7anAFH7oz6d7TRG3KiTZQ5GEFsAgOKB8Yc+HDyuDUGOT2cL1CYTz/U4b/xlJxO+euP14pyji+z3Z3kOg==", 764 | "requires": { 765 | "@lit/reactive-element": "^1.1.0", 766 | "lit-html": "^2.1.0" 767 | } 768 | }, 769 | "lit-html": { 770 | "version": "2.1.2", 771 | "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.1.2.tgz", 772 | "integrity": "sha512-fp7oBzUdc7SEmOoSUNUZ6PM8se8eaIvc3pviQ5M+iCYuCpv9E23Nnb4hlxVzGhLWMnHSrnRVooNio0aAgjjrFw==", 773 | "requires": { 774 | "@types/trusted-types": "^2.0.2" 775 | } 776 | }, 777 | "local-pkg": { 778 | "version": "0.4.1", 779 | "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz", 780 | "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==", 781 | "dev": true 782 | }, 783 | "locate-path": { 784 | "version": "6.0.0", 785 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", 786 | "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", 787 | "dev": true, 788 | "requires": { 789 | "p-locate": "^5.0.0" 790 | } 791 | }, 792 | "loupe": { 793 | "version": "2.3.4", 794 | "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", 795 | "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", 796 | "dev": true, 797 | "requires": { 798 | "get-func-name": "^2.0.0" 799 | } 800 | }, 801 | "make-dir": { 802 | "version": "3.1.0", 803 | "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", 804 | "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", 805 | "dev": true, 806 | "requires": { 807 | "semver": "^6.0.0" 808 | } 809 | }, 810 | "mime-db": { 811 | "version": "1.52.0", 812 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", 813 | "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", 814 | "dev": true 815 | }, 816 | "mime-types": { 817 | "version": "2.1.35", 818 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", 819 | "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", 820 | "dev": true, 821 | "requires": { 822 | "mime-db": "1.52.0" 823 | } 824 | }, 825 | "minimatch": { 826 | "version": "3.1.2", 827 | "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", 828 | "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", 829 | "dev": true, 830 | "requires": { 831 | "brace-expansion": "^1.1.7" 832 | } 833 | }, 834 | "mrmime": { 835 | "version": "1.0.0", 836 | "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.0.tgz", 837 | "integrity": "sha512-a70zx7zFfVO7XpnQ2IX1Myh9yY4UYvfld/dikWRnsXxbyvMcfz+u6UfgNAtH+k2QqtJuzVpv6eLTx1G2+WKZbQ==", 838 | "dev": true 839 | }, 840 | "nanoid": { 841 | "version": "3.3.3", 842 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", 843 | "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", 844 | "dev": true 845 | }, 846 | "node-fetch": { 847 | "version": "2.6.7", 848 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", 849 | "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", 850 | "dev": true, 851 | "requires": { 852 | "whatwg-url": "^5.0.0" 853 | } 854 | }, 855 | "object-inspect": { 856 | "version": "1.12.0", 857 | "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz", 858 | "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==", 859 | "dev": true 860 | }, 861 | "once": { 862 | "version": "1.4.0", 863 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 864 | "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", 865 | "dev": true, 866 | "requires": { 867 | "wrappy": "1" 868 | } 869 | }, 870 | "p-limit": { 871 | "version": "3.1.0", 872 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", 873 | "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", 874 | "dev": true, 875 | "requires": { 876 | "yocto-queue": "^0.1.0" 877 | } 878 | }, 879 | "p-locate": { 880 | "version": "5.0.0", 881 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", 882 | "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", 883 | "dev": true, 884 | "requires": { 885 | "p-limit": "^3.0.2" 886 | } 887 | }, 888 | "parse-cache-control": { 889 | "version": "1.0.1", 890 | "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", 891 | "integrity": "sha1-juqz5U+laSD+Fro493+iGqzC104=", 892 | "dev": true 893 | }, 894 | "path-exists": { 895 | "version": "4.0.0", 896 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", 897 | "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", 898 | "dev": true 899 | }, 900 | "path-is-absolute": { 901 | "version": "1.0.1", 902 | "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", 903 | "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", 904 | "dev": true 905 | }, 906 | "path-key": { 907 | "version": "3.1.1", 908 | "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", 909 | "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", 910 | "dev": true 911 | }, 912 | "path-parse": { 913 | "version": "1.0.7", 914 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", 915 | "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", 916 | "dev": true 917 | }, 918 | "pathval": { 919 | "version": "1.1.1", 920 | "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", 921 | "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", 922 | "dev": true 923 | }, 924 | "picocolors": { 925 | "version": "1.0.0", 926 | "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", 927 | "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", 928 | "dev": true 929 | }, 930 | "postcss": { 931 | "version": "8.4.12", 932 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.12.tgz", 933 | "integrity": "sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==", 934 | "dev": true, 935 | "requires": { 936 | "nanoid": "^3.3.1", 937 | "picocolors": "^1.0.0", 938 | "source-map-js": "^1.0.2" 939 | } 940 | }, 941 | "process-nextick-args": { 942 | "version": "2.0.1", 943 | "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", 944 | "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", 945 | "dev": true 946 | }, 947 | "promise": { 948 | "version": "8.1.0", 949 | "resolved": "https://registry.npmjs.org/promise/-/promise-8.1.0.tgz", 950 | "integrity": "sha512-W04AqnILOL/sPRXziNicCjSNRruLAuIHEOVBazepu0545DDNGYHz7ar9ZgZ1fMU8/MA4mVxp5rkBWRi6OXIy3Q==", 951 | "dev": true, 952 | "requires": { 953 | "asap": "~2.0.6" 954 | } 955 | }, 956 | "qs": { 957 | "version": "6.10.3", 958 | "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", 959 | "integrity": "sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ==", 960 | "dev": true, 961 | "requires": { 962 | "side-channel": "^1.0.4" 963 | } 964 | }, 965 | "readable-stream": { 966 | "version": "2.3.7", 967 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz", 968 | "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==", 969 | "dev": true, 970 | "requires": { 971 | "core-util-is": "~1.0.0", 972 | "inherits": "~2.0.3", 973 | "isarray": "~1.0.0", 974 | "process-nextick-args": "~2.0.0", 975 | "safe-buffer": "~5.1.1", 976 | "string_decoder": "~1.1.1", 977 | "util-deprecate": "~1.0.1" 978 | } 979 | }, 980 | "require-directory": { 981 | "version": "2.1.1", 982 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 983 | "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", 984 | "dev": true 985 | }, 986 | "resolve": { 987 | "version": "1.22.0", 988 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", 989 | "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==", 990 | "dev": true, 991 | "requires": { 992 | "is-core-module": "^2.8.1", 993 | "path-parse": "^1.0.7", 994 | "supports-preserve-symlinks-flag": "^1.0.0" 995 | } 996 | }, 997 | "rimraf": { 998 | "version": "3.0.2", 999 | "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", 1000 | "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", 1001 | "dev": true, 1002 | "requires": { 1003 | "glob": "^7.1.3" 1004 | } 1005 | }, 1006 | "rollup": { 1007 | "version": "2.66.1", 1008 | "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.66.1.tgz", 1009 | "integrity": "sha512-crSgLhSkLMnKr4s9iZ/1qJCplgAgrRY+igWv8KhG/AjKOJ0YX/WpmANyn8oxrw+zenF3BXWDLa7Xl/QZISH+7w==", 1010 | "dev": true, 1011 | "requires": { 1012 | "fsevents": "~2.3.2" 1013 | } 1014 | }, 1015 | "rxjs": { 1016 | "version": "7.5.4", 1017 | "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.4.tgz", 1018 | "integrity": "sha512-h5M3Hk78r6wAheJF0a5YahB1yRQKCsZ4MsGdZ5O9ETbVtjPcScGfrMmoOq7EBsCRzd4BDkvDJ7ogP8Sz5tTFiQ==", 1019 | "requires": { 1020 | "tslib": "^2.1.0" 1021 | } 1022 | }, 1023 | "safe-buffer": { 1024 | "version": "5.1.2", 1025 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", 1026 | "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", 1027 | "dev": true 1028 | }, 1029 | "safer-buffer": { 1030 | "version": "2.1.2", 1031 | "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 1032 | "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 1033 | "dev": true 1034 | }, 1035 | "semver": { 1036 | "version": "6.3.0", 1037 | "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", 1038 | "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", 1039 | "dev": true 1040 | }, 1041 | "shebang-command": { 1042 | "version": "2.0.0", 1043 | "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", 1044 | "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", 1045 | "dev": true, 1046 | "requires": { 1047 | "shebang-regex": "^3.0.0" 1048 | } 1049 | }, 1050 | "shebang-regex": { 1051 | "version": "3.0.0", 1052 | "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", 1053 | "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", 1054 | "dev": true 1055 | }, 1056 | "side-channel": { 1057 | "version": "1.0.4", 1058 | "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", 1059 | "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", 1060 | "dev": true, 1061 | "requires": { 1062 | "call-bind": "^1.0.0", 1063 | "get-intrinsic": "^1.0.2", 1064 | "object-inspect": "^1.9.0" 1065 | } 1066 | }, 1067 | "signal-exit": { 1068 | "version": "3.0.7", 1069 | "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", 1070 | "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", 1071 | "dev": true 1072 | }, 1073 | "sirv": { 1074 | "version": "2.0.2", 1075 | "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.2.tgz", 1076 | "integrity": "sha512-4Qog6aE29nIjAOKe/wowFTxOdmbEZKb+3tsLljaBRzJwtqto0BChD2zzH0LhgCSXiI+V7X+Y45v14wBZQ1TK3w==", 1077 | "dev": true, 1078 | "requires": { 1079 | "@polka/url": "^1.0.0-next.20", 1080 | "mrmime": "^1.0.0", 1081 | "totalist": "^3.0.0" 1082 | } 1083 | }, 1084 | "source-map-js": { 1085 | "version": "1.0.2", 1086 | "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", 1087 | "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", 1088 | "dev": true 1089 | }, 1090 | "string-width": { 1091 | "version": "4.2.3", 1092 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", 1093 | "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", 1094 | "dev": true, 1095 | "requires": { 1096 | "emoji-regex": "^8.0.0", 1097 | "is-fullwidth-code-point": "^3.0.0", 1098 | "strip-ansi": "^6.0.1" 1099 | } 1100 | }, 1101 | "string_decoder": { 1102 | "version": "1.1.1", 1103 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", 1104 | "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", 1105 | "dev": true, 1106 | "requires": { 1107 | "safe-buffer": "~5.1.0" 1108 | } 1109 | }, 1110 | "strip-ansi": { 1111 | "version": "6.0.1", 1112 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", 1113 | "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", 1114 | "dev": true, 1115 | "requires": { 1116 | "ansi-regex": "^5.0.1" 1117 | } 1118 | }, 1119 | "supports-color": { 1120 | "version": "7.2.0", 1121 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", 1122 | "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", 1123 | "dev": true, 1124 | "requires": { 1125 | "has-flag": "^4.0.0" 1126 | } 1127 | }, 1128 | "supports-preserve-symlinks-flag": { 1129 | "version": "1.0.0", 1130 | "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", 1131 | "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", 1132 | "dev": true 1133 | }, 1134 | "sync-request": { 1135 | "version": "6.1.0", 1136 | "resolved": "https://registry.npmjs.org/sync-request/-/sync-request-6.1.0.tgz", 1137 | "integrity": "sha512-8fjNkrNlNCrVc/av+Jn+xxqfCjYaBoHqCsDz6mt030UMxJGr+GSfCV1dQt2gRtlL63+VPidwDVLr7V2OcTSdRw==", 1138 | "dev": true, 1139 | "requires": { 1140 | "http-response-object": "^3.0.1", 1141 | "sync-rpc": "^1.2.1", 1142 | "then-request": "^6.0.0" 1143 | } 1144 | }, 1145 | "sync-rpc": { 1146 | "version": "1.3.6", 1147 | "resolved": "https://registry.npmjs.org/sync-rpc/-/sync-rpc-1.3.6.tgz", 1148 | "integrity": "sha512-J8jTXuZzRlvU7HemDgHi3pGnh/rkoqR/OZSjhTyyZrEkkYQbk7Z33AXp37mkPfPpfdOuj7Ex3H/TJM1z48uPQw==", 1149 | "dev": true, 1150 | "requires": { 1151 | "get-port": "^3.1.0" 1152 | } 1153 | }, 1154 | "test-exclude": { 1155 | "version": "6.0.0", 1156 | "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", 1157 | "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", 1158 | "dev": true, 1159 | "requires": { 1160 | "@istanbuljs/schema": "^0.1.2", 1161 | "glob": "^7.1.4", 1162 | "minimatch": "^3.0.4" 1163 | } 1164 | }, 1165 | "then-request": { 1166 | "version": "6.0.2", 1167 | "resolved": "https://registry.npmjs.org/then-request/-/then-request-6.0.2.tgz", 1168 | "integrity": "sha512-3ZBiG7JvP3wbDzA9iNY5zJQcHL4jn/0BWtXIkagfz7QgOL/LqjCEOBQuJNZfu0XYnv5JhKh+cDxCPM4ILrqruA==", 1169 | "dev": true, 1170 | "requires": { 1171 | "@types/concat-stream": "^1.6.0", 1172 | "@types/form-data": "0.0.33", 1173 | "@types/node": "^8.0.0", 1174 | "@types/qs": "^6.2.31", 1175 | "caseless": "~0.12.0", 1176 | "concat-stream": "^1.6.0", 1177 | "form-data": "^2.2.0", 1178 | "http-basic": "^8.1.1", 1179 | "http-response-object": "^3.0.1", 1180 | "promise": "^8.0.0", 1181 | "qs": "^6.4.0" 1182 | }, 1183 | "dependencies": { 1184 | "@types/node": { 1185 | "version": "8.10.66", 1186 | "resolved": "https://registry.npmjs.org/@types/node/-/node-8.10.66.tgz", 1187 | "integrity": "sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==", 1188 | "dev": true 1189 | } 1190 | } 1191 | }, 1192 | "tinypool": { 1193 | "version": "0.1.3", 1194 | "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.1.3.tgz", 1195 | "integrity": "sha512-2IfcQh7CP46XGWGGbdyO4pjcKqsmVqFAPcXfPxcPXmOWt9cYkTP9HcDmGgsfijYoAEc4z9qcpM/BaBz46Y9/CQ==", 1196 | "dev": true 1197 | }, 1198 | "tinyspy": { 1199 | "version": "0.3.2", 1200 | "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-0.3.2.tgz", 1201 | "integrity": "sha512-2+40EP4D3sFYy42UkgkFFB+kiX2Tg3URG/lVvAZFfLxgGpnWl5qQJuBw1gaLttq8UOS+2p3C0WrhJnQigLTT2Q==", 1202 | "dev": true 1203 | }, 1204 | "totalist": { 1205 | "version": "3.0.0", 1206 | "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.0.tgz", 1207 | "integrity": "sha512-eM+pCBxXO/njtF7vdFsHuqb+ElbxqtI4r5EAvk6grfAFyJ6IvWlSkfZ5T9ozC6xWw3Fj1fGoSmrl0gUs46JVIw==", 1208 | "dev": true 1209 | }, 1210 | "tr46": { 1211 | "version": "0.0.3", 1212 | "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", 1213 | "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=", 1214 | "dev": true 1215 | }, 1216 | "tslib": { 1217 | "version": "2.3.1", 1218 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz", 1219 | "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==" 1220 | }, 1221 | "type-detect": { 1222 | "version": "4.0.8", 1223 | "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", 1224 | "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", 1225 | "dev": true 1226 | }, 1227 | "typedarray": { 1228 | "version": "0.0.6", 1229 | "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", 1230 | "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", 1231 | "dev": true 1232 | }, 1233 | "typescript": { 1234 | "version": "4.5.5", 1235 | "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", 1236 | "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", 1237 | "dev": true 1238 | }, 1239 | "util-deprecate": { 1240 | "version": "1.0.2", 1241 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 1242 | "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", 1243 | "dev": true 1244 | }, 1245 | "v8-to-istanbul": { 1246 | "version": "9.0.0", 1247 | "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.0.0.tgz", 1248 | "integrity": "sha512-HcvgY/xaRm7isYmyx+lFKA4uQmfUbN0J4M0nNItvzTvH/iQ9kW5j/t4YSR+Ge323/lrgDAWJoF46tzGQHwBHFw==", 1249 | "dev": true, 1250 | "requires": { 1251 | "@jridgewell/trace-mapping": "^0.3.7", 1252 | "@types/istanbul-lib-coverage": "^2.0.1", 1253 | "convert-source-map": "^1.6.0" 1254 | } 1255 | }, 1256 | "vite": { 1257 | "version": "2.9.6", 1258 | "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.6.tgz", 1259 | "integrity": "sha512-3IffdrByHW95Yjv0a13TQOQfJs7L5dVlSPuTt432XLbRMriWbThqJN2k/IS6kXn5WY4xBLhK9XoaWay1B8VzUw==", 1260 | "dev": true, 1261 | "requires": { 1262 | "esbuild": "^0.14.27", 1263 | "fsevents": "~2.3.2", 1264 | "postcss": "^8.4.12", 1265 | "resolve": "^1.22.0", 1266 | "rollup": "^2.59.0" 1267 | } 1268 | }, 1269 | "vitest": { 1270 | "version": "0.10.0", 1271 | "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.10.0.tgz", 1272 | "integrity": "sha512-8UXemUg9CA4QYppDTsDV76nH0e1p6C8lV9q+o9i0qMSK9AQ7vA2sjoxtkDP0M+pwNmc3ZGYetBXgSJx0M1D/gg==", 1273 | "dev": true, 1274 | "requires": { 1275 | "@types/chai": "^4.3.1", 1276 | "@types/chai-subset": "^1.3.3", 1277 | "chai": "^4.3.6", 1278 | "local-pkg": "^0.4.1", 1279 | "tinypool": "^0.1.2", 1280 | "tinyspy": "^0.3.2", 1281 | "vite": "^2.9.5" 1282 | }, 1283 | "dependencies": { 1284 | "esbuild": { 1285 | "version": "0.14.38", 1286 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.38.tgz", 1287 | "integrity": "sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA==", 1288 | "dev": true, 1289 | "requires": { 1290 | "esbuild-android-64": "0.14.38", 1291 | "esbuild-android-arm64": "0.14.38", 1292 | "esbuild-darwin-64": "0.14.38", 1293 | "esbuild-darwin-arm64": "0.14.38", 1294 | "esbuild-freebsd-64": "0.14.38", 1295 | "esbuild-freebsd-arm64": "0.14.38", 1296 | "esbuild-linux-32": "0.14.38", 1297 | "esbuild-linux-64": "0.14.38", 1298 | "esbuild-linux-arm": "0.14.38", 1299 | "esbuild-linux-arm64": "0.14.38", 1300 | "esbuild-linux-mips64le": "0.14.38", 1301 | "esbuild-linux-ppc64le": "0.14.38", 1302 | "esbuild-linux-riscv64": "0.14.38", 1303 | "esbuild-linux-s390x": "0.14.38", 1304 | "esbuild-netbsd-64": "0.14.38", 1305 | "esbuild-openbsd-64": "0.14.38", 1306 | "esbuild-sunos-64": "0.14.38", 1307 | "esbuild-windows-32": "0.14.38", 1308 | "esbuild-windows-64": "0.14.38", 1309 | "esbuild-windows-arm64": "0.14.38" 1310 | } 1311 | }, 1312 | "esbuild-android-arm64": { 1313 | "version": "0.14.38", 1314 | "resolved": "https://registry.npmjs.org/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz", 1315 | "integrity": "sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA==", 1316 | "dev": true, 1317 | "optional": true 1318 | }, 1319 | "esbuild-darwin-64": { 1320 | "version": "0.14.38", 1321 | "resolved": "https://registry.npmjs.org/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz", 1322 | "integrity": "sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA==", 1323 | "dev": true, 1324 | "optional": true 1325 | }, 1326 | "esbuild-darwin-arm64": { 1327 | "version": "0.14.38", 1328 | "resolved": "https://registry.npmjs.org/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz", 1329 | "integrity": "sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ==", 1330 | "dev": true, 1331 | "optional": true 1332 | }, 1333 | "esbuild-freebsd-64": { 1334 | "version": "0.14.38", 1335 | "resolved": "https://registry.npmjs.org/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz", 1336 | "integrity": "sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig==", 1337 | "dev": true, 1338 | "optional": true 1339 | }, 1340 | "esbuild-freebsd-arm64": { 1341 | "version": "0.14.38", 1342 | "resolved": "https://registry.npmjs.org/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz", 1343 | "integrity": "sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ==", 1344 | "dev": true, 1345 | "optional": true 1346 | }, 1347 | "esbuild-linux-32": { 1348 | "version": "0.14.38", 1349 | "resolved": "https://registry.npmjs.org/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz", 1350 | "integrity": "sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g==", 1351 | "dev": true, 1352 | "optional": true 1353 | }, 1354 | "esbuild-linux-64": { 1355 | "version": "0.14.38", 1356 | "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz", 1357 | "integrity": "sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q==", 1358 | "dev": true, 1359 | "optional": true 1360 | }, 1361 | "esbuild-linux-arm": { 1362 | "version": "0.14.38", 1363 | "resolved": "https://registry.npmjs.org/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz", 1364 | "integrity": "sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA==", 1365 | "dev": true, 1366 | "optional": true 1367 | }, 1368 | "esbuild-linux-arm64": { 1369 | "version": "0.14.38", 1370 | "resolved": "https://registry.npmjs.org/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz", 1371 | "integrity": "sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA==", 1372 | "dev": true, 1373 | "optional": true 1374 | }, 1375 | "esbuild-linux-mips64le": { 1376 | "version": "0.14.38", 1377 | "resolved": "https://registry.npmjs.org/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz", 1378 | "integrity": "sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ==", 1379 | "dev": true, 1380 | "optional": true 1381 | }, 1382 | "esbuild-linux-ppc64le": { 1383 | "version": "0.14.38", 1384 | "resolved": "https://registry.npmjs.org/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz", 1385 | "integrity": "sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q==", 1386 | "dev": true, 1387 | "optional": true 1388 | }, 1389 | "esbuild-netbsd-64": { 1390 | "version": "0.14.38", 1391 | "resolved": "https://registry.npmjs.org/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz", 1392 | "integrity": "sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q==", 1393 | "dev": true, 1394 | "optional": true 1395 | }, 1396 | "esbuild-openbsd-64": { 1397 | "version": "0.14.38", 1398 | "resolved": "https://registry.npmjs.org/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz", 1399 | "integrity": "sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ==", 1400 | "dev": true, 1401 | "optional": true 1402 | }, 1403 | "esbuild-sunos-64": { 1404 | "version": "0.14.38", 1405 | "resolved": "https://registry.npmjs.org/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz", 1406 | "integrity": "sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA==", 1407 | "dev": true, 1408 | "optional": true 1409 | }, 1410 | "esbuild-windows-32": { 1411 | "version": "0.14.38", 1412 | "resolved": "https://registry.npmjs.org/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz", 1413 | "integrity": "sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw==", 1414 | "dev": true, 1415 | "optional": true 1416 | }, 1417 | "esbuild-windows-64": { 1418 | "version": "0.14.38", 1419 | "resolved": "https://registry.npmjs.org/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz", 1420 | "integrity": "sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw==", 1421 | "dev": true, 1422 | "optional": true 1423 | }, 1424 | "esbuild-windows-arm64": { 1425 | "version": "0.14.38", 1426 | "resolved": "https://registry.npmjs.org/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz", 1427 | "integrity": "sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw==", 1428 | "dev": true, 1429 | "optional": true 1430 | }, 1431 | "nanoid": { 1432 | "version": "3.3.3", 1433 | "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz", 1434 | "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==", 1435 | "dev": true 1436 | }, 1437 | "postcss": { 1438 | "version": "8.4.12", 1439 | "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.12.tgz", 1440 | "integrity": "sha512-lg6eITwYe9v6Hr5CncVbK70SoioNQIq81nsaG86ev5hAidQvmOeETBqs7jm43K2F5/Ley3ytDtriImV6TpNiSg==", 1441 | "dev": true, 1442 | "requires": { 1443 | "nanoid": "^3.3.1", 1444 | "picocolors": "^1.0.0", 1445 | "source-map-js": "^1.0.2" 1446 | } 1447 | }, 1448 | "vite": { 1449 | "version": "2.9.6", 1450 | "resolved": "https://registry.npmjs.org/vite/-/vite-2.9.6.tgz", 1451 | "integrity": "sha512-3IffdrByHW95Yjv0a13TQOQfJs7L5dVlSPuTt432XLbRMriWbThqJN2k/IS6kXn5WY4xBLhK9XoaWay1B8VzUw==", 1452 | "dev": true, 1453 | "requires": { 1454 | "esbuild": "^0.14.27", 1455 | "fsevents": "~2.3.2", 1456 | "postcss": "^8.4.12", 1457 | "resolve": "^1.22.0", 1458 | "rollup": "^2.59.0" 1459 | } 1460 | } 1461 | } 1462 | }, 1463 | "webidl-conversions": { 1464 | "version": "7.0.0", 1465 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", 1466 | "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", 1467 | "dev": true 1468 | }, 1469 | "whatwg-encoding": { 1470 | "version": "2.0.0", 1471 | "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", 1472 | "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", 1473 | "dev": true, 1474 | "requires": { 1475 | "iconv-lite": "0.6.3" 1476 | } 1477 | }, 1478 | "whatwg-mimetype": { 1479 | "version": "3.0.0", 1480 | "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", 1481 | "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", 1482 | "dev": true 1483 | }, 1484 | "whatwg-url": { 1485 | "version": "5.0.0", 1486 | "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", 1487 | "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", 1488 | "dev": true, 1489 | "requires": { 1490 | "tr46": "~0.0.3", 1491 | "webidl-conversions": "^3.0.0" 1492 | }, 1493 | "dependencies": { 1494 | "webidl-conversions": { 1495 | "version": "3.0.1", 1496 | "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", 1497 | "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=", 1498 | "dev": true 1499 | } 1500 | } 1501 | }, 1502 | "which": { 1503 | "version": "2.0.2", 1504 | "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", 1505 | "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", 1506 | "dev": true, 1507 | "requires": { 1508 | "isexe": "^2.0.0" 1509 | } 1510 | }, 1511 | "wrap-ansi": { 1512 | "version": "7.0.0", 1513 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", 1514 | "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", 1515 | "dev": true, 1516 | "requires": { 1517 | "ansi-styles": "^4.0.0", 1518 | "string-width": "^4.1.0", 1519 | "strip-ansi": "^6.0.0" 1520 | } 1521 | }, 1522 | "wrappy": { 1523 | "version": "1.0.2", 1524 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 1525 | "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", 1526 | "dev": true 1527 | }, 1528 | "y18n": { 1529 | "version": "5.0.8", 1530 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", 1531 | "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", 1532 | "dev": true 1533 | }, 1534 | "yargs": { 1535 | "version": "16.2.0", 1536 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", 1537 | "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", 1538 | "dev": true, 1539 | "requires": { 1540 | "cliui": "^7.0.2", 1541 | "escalade": "^3.1.1", 1542 | "get-caller-file": "^2.0.5", 1543 | "require-directory": "^2.1.1", 1544 | "string-width": "^4.2.0", 1545 | "y18n": "^5.0.5", 1546 | "yargs-parser": "^20.2.2" 1547 | } 1548 | }, 1549 | "yargs-parser": { 1550 | "version": "20.2.9", 1551 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", 1552 | "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", 1553 | "dev": true 1554 | }, 1555 | "yocto-queue": { 1556 | "version": "0.1.0", 1557 | "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", 1558 | "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", 1559 | "dev": true 1560 | } 1561 | } 1562 | } 1563 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lit-reactive-forms", 3 | "version": "0.0.1", 4 | "main": "dist/lit-reactive-forms.es.js", 5 | "exports": { 6 | ".": "./dist/lit-reactive-forms.es.js" 7 | }, 8 | "types": "types/lit-reactive-forms.d.ts", 9 | "files": [ 10 | "dist", 11 | "types" 12 | ], 13 | "scripts": { 14 | "dev": "vite", 15 | "build": "tsc && vite build", 16 | "test": "vitest --ui", 17 | "coverage": "vitest run --coverage" 18 | }, 19 | "dependencies": { 20 | "lit": "^2.0.2", 21 | "rxjs": "^7.5.0" 22 | }, 23 | "devDependencies": { 24 | "@vitest/ui": "^0.10.0", 25 | "c8": "^7.11.2", 26 | "happy-dom": "^3.1.0", 27 | "typescript": "^4.5.4", 28 | "vite": "^2.9.6", 29 | "vitest": "^0.10.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/abstract-control.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from "rxjs"; 2 | import { ValidationError, ValidationStatus } from "./validation/models"; 3 | 4 | export interface AbstractControl { 5 | value: T; 6 | reset(clearStates?: boolean): void; 7 | set(value: T): void; 8 | readonly isDirty: boolean; 9 | readonly isTouched: boolean; 10 | readonly isBlurred: boolean; 11 | valueChanges(): Observable; 12 | statusChanges(): Observable; 13 | // Validation 14 | readonly status: ValidationStatus; 15 | readonly errors: ValidationError[]; 16 | hasError(error: string): boolean; 17 | setFixedErrors(errors: ValidationError[]): void; 18 | } -------------------------------------------------------------------------------- /src/accessors/accessors.ts: -------------------------------------------------------------------------------- 1 | import { BaseControlAccessor, ControlAccessor } from "./control-accessor"; 2 | 3 | /** 4 | * Accessors for any kind of native HTML control. 5 | */ 6 | export class TextAccessor extends BaseControlAccessor {} 7 | export class SelectAccessor extends BaseControlAccessor {} 8 | 9 | export class NumberAccessor extends BaseControlAccessor { 10 | getValue() { 11 | return +this.el.value; 12 | } 13 | 14 | setValue(value: number) { 15 | this.el.value = '' + value; 16 | } 17 | } 18 | 19 | export class CheckboxAccessor extends BaseControlAccessor { 20 | getValue() { 21 | return this.el.checked; 22 | } 23 | 24 | setValue(value: boolean) { 25 | this.el.checked = !!value; 26 | } 27 | } 28 | 29 | export class SelectMultipleAccessor extends BaseControlAccessor { 30 | getValue() { 31 | const options = this.el.selectedOptions; 32 | let results: string[] = []; 33 | for (let i = 0; i < options.length; i++) { 34 | results.push(options[i].value); 35 | } 36 | return results; 37 | } 38 | 39 | setValue(value: string[]) { 40 | for (let i = 0; i < this.el.options.length; i++) { 41 | this.el.options[i].selected = value.includes(this.el.options[i].value); 42 | } 43 | } 44 | } 45 | export class RadioAccessor extends BaseControlAccessor { 46 | getValue() { 47 | return this.el.checked ? this.el.value : null; 48 | } 49 | 50 | setValue(value: string) { 51 | this.el.checked = value === this.el.value; 52 | } 53 | } 54 | 55 | export type ControlAccessorFactory = (el: C) => ControlAccessor; 56 | 57 | export const getControlAccessor: ControlAccessorFactory = (el) => { 58 | if (el.localName === 'input' && el.getAttribute('type') === 'checkbox') { 59 | return new CheckboxAccessor(el as HTMLInputElement); 60 | } 61 | if (el.localName === 'input' && el.getAttribute('type') === 'number') { 62 | return new NumberAccessor(el as HTMLInputElement); 63 | } 64 | if (el.localName === 'input' && el.getAttribute('type') === 'range') { 65 | return new NumberAccessor(el as HTMLInputElement); 66 | } 67 | if (el.localName === 'input' && el.getAttribute('type') === 'text') { 68 | return new TextAccessor(el as HTMLInputElement); 69 | } 70 | if (el.localName === 'input' && el.getAttribute('type') === 'radio') { 71 | return new RadioAccessor(el as HTMLInputElement); 72 | } 73 | if (el.localName === 'select' && el.hasAttribute('multiple')) { 74 | return new SelectMultipleAccessor(el as HTMLSelectElement); 75 | } 76 | if (el.localName === 'select') { 77 | return new SelectAccessor(el as HTMLSelectElement); 78 | } 79 | return new BaseControlAccessor(el); 80 | } 81 | -------------------------------------------------------------------------------- /src/accessors/control-accessor.ts: -------------------------------------------------------------------------------- 1 | import { UIState } from "../models"; 2 | import { ValidationStatus } from "../validation/models"; 3 | 4 | export interface ControlAccessor { 5 | getValue(): T; 6 | setValue(value: T): void; 7 | setUIState?(state: UIState): void; 8 | setValidity?(status: ValidationStatus | null): void; 9 | registerOnChange(fn: () => void): void; 10 | registerOnTouch?(fn: () => void): void; 11 | registerOnBlur?(fn: () => void): void; 12 | onDisconnect?(): void; 13 | } 14 | 15 | export class BaseControlAccessor implements ControlAccessor { 16 | onChange = () => {}; 17 | onTouch = () => {}; 18 | onBlur = () => {}; 19 | 20 | constructor(public el: E) {} 21 | 22 | getValue() { 23 | if ('value' in this.el) return (this.el as any).value; 24 | } 25 | 26 | setValue(value: T) { 27 | if ('value' in this.el) (this.el as any).value = value; 28 | } 29 | 30 | setUIState(uiState: UIState | null) { 31 | switch (uiState) { 32 | case 'DISABLED': { 33 | if ('disabled' in this.el) { (this.el as any).disabled = true; } 34 | if ('readOnly' in this.el) { (this.el as any).readOnly = false; } 35 | break; 36 | } 37 | case 'READONLY': { 38 | if ('disabled' in this.el) { (this.el as any).disabled = false; } 39 | if ('readOnly' in this.el) { (this.el as any).readOnly = true; } 40 | break; 41 | } 42 | default: { 43 | if ('disabled' in this.el) { (this.el as any).disabled = false; } 44 | if ('readOnly' in this.el) { (this.el as any).readOnly = false; } 45 | break; 46 | } 47 | } 48 | } 49 | 50 | setValidity(status: ValidationStatus) { 51 | switch (status) { 52 | case 'VALID': 53 | this.el.setAttribute('valid', ''); 54 | this.el.removeAttribute('invalid'); 55 | this.el.removeAttribute('pending'); 56 | break; 57 | case 'INVALID': 58 | this.el.setAttribute('invalid', ''); 59 | this.el.removeAttribute('valid'); 60 | this.el.removeAttribute('pending'); 61 | break; 62 | case 'PENDING': 63 | this.el.setAttribute('pending', ''); 64 | this.el.removeAttribute('valid'); 65 | this.el.removeAttribute('invalid'); 66 | break; 67 | default: 68 | this.el.removeAttribute('pending'); 69 | this.el.removeAttribute('valid'); 70 | this.el.removeAttribute('invalid'); 71 | } 72 | } 73 | 74 | registerOnChange(fn: () => void) { 75 | this.onChange = fn; 76 | this.el.addEventListener('input', this.onChange); 77 | } 78 | 79 | registerOnTouch(fn: () => void) { 80 | this.onTouch = fn; 81 | this.el.addEventListener('focus', this.onTouch); 82 | } 83 | 84 | registerOnBlur(fn: () => void) { 85 | this.onBlur = fn; 86 | this.el.addEventListener('blur', this.onBlur); 87 | } 88 | 89 | onDisconnect() { 90 | this.el.removeEventListener('input', this.onChange); 91 | this.el.removeEventListener('focus', this.onTouch); 92 | this.el.removeEventListener('blur', this.onBlur); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/controllers/form-array.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveController, ReactiveControllerHost } from 'lit'; 2 | import { EMPTY, merge, Observable, ReplaySubject, switchMap, map, Subscription, from, of, combineLatest, BehaviorSubject, catchError, distinctUntilChanged, tap } from 'rxjs'; 3 | import { AbstractControl } from '../abstract-control'; 4 | import { AsyncValidator, ValidationError, ValidationStatus, Validator } from '../validation/models'; 5 | import { FormControl } from './form-control'; 6 | import { FormGroup } from './form-group'; 7 | 8 | type ArrayValue = T extends FormControl ? C : (T extends FormGroup ? F : never); 9 | 10 | export interface FormArrayConfig { 11 | initialItems: T[]; 12 | validators: Validator>[]; 13 | asyncValidators: AsyncValidator>[]; 14 | } 15 | 16 | export class FormArray implements AbstractControl, ReactiveController { 17 | public readonly config!: FormArrayConfig; 18 | public controls: T[] = []; 19 | private runningAsyncValidatorsCount = 0; 20 | private errorsSync$ = new BehaviorSubject([]); 21 | private errorsAsync$ = new BehaviorSubject([]); 22 | private errorsFixed$ = new BehaviorSubject([]); 23 | private validatorsSub?: Subscription; 24 | private asyncValidatorsSub?: Subscription; 25 | private structureChanged$ = new ReplaySubject(1); 26 | 27 | get value(): ArrayValue[] { 28 | return this.controls.map(c => c.value); 29 | } 30 | 31 | get errors() { 32 | return Array.from(new Set([ 33 | ...this.errorsSync$.getValue(), 34 | ...this.errorsAsync$.getValue(), 35 | ...this.errorsFixed$.getValue() 36 | ])); 37 | } 38 | 39 | get status() { 40 | if ( 41 | this.errorsSync$.getValue().length > 0 42 | || this.errorsAsync$.getValue().length > 0 43 | || this.errorsFixed$.getValue().length > 0 44 | ) return 'INVALID'; 45 | const invalid = this.controls.find(c => c.status === 'INVALID'); 46 | if (invalid) { return 'INVALID' } 47 | const pending = this.controls.find(c => c.status === 'PENDING'); 48 | if (pending) { return 'PENDING' } 49 | if (this.runningAsyncValidatorsCount > 0) return 'PENDING'; 50 | return 'VALID'; 51 | } 52 | 53 | constructor( 54 | public host: ReactiveControllerHost, 55 | config?: Partial> 56 | ) { 57 | this.host.addController(this); 58 | this.config = { 59 | initialItems: config?.initialItems ?? [], 60 | validators: config?.validators || [], 61 | asyncValidators: config?.asyncValidators || [] 62 | }; 63 | this.controls = this.config.initialItems; 64 | this.structureChanged$.next(); 65 | } 66 | 67 | hostConnected() { 68 | this.rerunValidators(); 69 | this.rerunAsyncValidators(); 70 | } 71 | 72 | hostDisconnected() { 73 | this.validatorsSub?.unsubscribe(); 74 | this.asyncValidatorsSub?.unsubscribe(); 75 | } 76 | 77 | /** 78 | * Retrieves a direct child by index. 79 | */ 80 | get(index: number): T | undefined { 81 | return this.controls[index] ?? undefined; 82 | } 83 | 84 | /** 85 | * Calls `reset` on every child. 86 | * @param clearStates - Clears the states of every child. 87 | */ 88 | reset(clearStates = true) { 89 | this.controls.forEach(c => c.reset(clearStates)); 90 | } 91 | 92 | /** 93 | * Empties the array. 94 | */ 95 | clear() { 96 | this.controls = []; 97 | this.structureChanged$.next(); 98 | this.host.requestUpdate(); 99 | } 100 | 101 | /** 102 | * Tries to set a new value for the array. 103 | * No controls are created by this method. 104 | */ 105 | set(value: ArrayValue[]) { 106 | this.controls.forEach((c, i) => { 107 | if (i + 1 <= value.length) { 108 | c.set(value[i]); 109 | } 110 | }) 111 | } 112 | 113 | /** 114 | * true if at least one child is dirty. 115 | */ 116 | get isDirty() { 117 | return this.controls.some(c => c.isDirty); 118 | } 119 | 120 | /** 121 | * true if at least one child is touched. 122 | */ 123 | get isTouched() { 124 | return this.controls.some(c => c.isTouched); 125 | } 126 | 127 | /** 128 | * true if at least one child is blurred. 129 | */ 130 | get isBlurred() { 131 | return this.controls.some(c => c.isBlurred); 132 | } 133 | 134 | /** 135 | * Returns true if the FormArray has the specified error. 136 | */ 137 | hasError(error: string) { 138 | return this.errors.includes(error); 139 | } 140 | 141 | /** 142 | * Observable of the value of the array, including the initial value. 143 | * @param index - When specified, returns an Observable of the value of a specific control. 144 | */ 145 | valueChanges(): Observable[]>; 146 | valueChanges(index: number): Observable | undefined>; 147 | valueChanges(index?: number): Observable | ArrayValue[] | undefined> { 148 | if (index) { 149 | return this.structureChanged$.pipe( 150 | switchMap(() => this.get(index)?.valueChanges() ?? EMPTY), 151 | ); 152 | } 153 | return this.structureChanged$.pipe( 154 | switchMap(() => { 155 | if (this.controls.length === 0) return of([]); 156 | const observables = this.controls.map(c => c.valueChanges()); 157 | return merge(...observables).pipe( 158 | map(() => this.value), 159 | ); 160 | }) 161 | ); 162 | } 163 | 164 | /** 165 | * Observable of the validation status of the array, including the initial status. 166 | */ 167 | statusChanges(): Observable { 168 | return merge(this.errorsSync$, this.errorsAsync$, this.errorsFixed$).pipe( 169 | map(() => this.status), 170 | distinctUntilChanged() 171 | ); 172 | } 173 | 174 | /** 175 | * Inserts a new control at the specified index (if possible). 176 | */ 177 | insertAt(control: T, index: number) { 178 | if (index <= this.controls.length) { 179 | this.controls.splice(index, 0, control); 180 | this.structureChanged$.next(); 181 | this.host.requestUpdate(); 182 | } 183 | } 184 | 185 | /** 186 | * Inserts a new control at the head of the array. 187 | */ 188 | append(control: T) { 189 | this.insertAt(control, this.controls.length); 190 | } 191 | 192 | /** 193 | * Inserts a new control at the tail of the array. 194 | */ 195 | prepend(control: T) { 196 | this.insertAt(control, 0); 197 | } 198 | 199 | /** 200 | * Removes the control at the specified index (if possible). 201 | */ 202 | removeAt(index: number) { 203 | if (this.controls.length >= (index + 1)) { 204 | this.controls.splice(index, 1); 205 | this.structureChanged$.next(); 206 | this.host.requestUpdate(); 207 | } 208 | } 209 | 210 | /** 211 | * Removes the last control of the array. 212 | */ 213 | pop() { 214 | this.removeAt(this.controls.length - 1); 215 | } 216 | 217 | /** 218 | * Swaps the control at the first index with the one at the second index. 219 | */ 220 | swap(indexA: number, indexB: number) { 221 | const validA = this.controls.length >= (indexA + 1); 222 | const validB = this.controls.length >= (indexB + 1); 223 | if (validA && validB) { 224 | const previousA = this.controls[indexA]; 225 | this.controls[indexA] = this.controls[indexB]; 226 | this.controls[indexB] = previousA; 227 | this.structureChanged$.next(); 228 | this.host.requestUpdate(); 229 | } 230 | } 231 | 232 | /** 233 | * Moves the control at the first index to the second index. 234 | */ 235 | move(from: number, to: number) { 236 | const validFrom = this.controls.length >= (from + 1); 237 | const validTo = this.controls.length >= (to + 1); 238 | if (validFrom && validTo) { 239 | const previousFrom = this.controls[from]; 240 | this.controls.splice(from, 1); 241 | this.controls.splice(to, 0, previousFrom); 242 | this.structureChanged$.next(); 243 | this.host.requestUpdate(); 244 | } 245 | } 246 | 247 | /** 248 | * Sets custom errors on the array. These errors won't be touched by validators. 249 | * You can later remove them by calling it again with new errors. 250 | */ 251 | setFixedErrors(errors: ValidationError[]) { 252 | this.errorsFixed$.next(errors); 253 | this.host.requestUpdate(); 254 | } 255 | 256 | /** 257 | * Replaces all validators. 258 | */ 259 | setValidators(validators: Validator>[]) { 260 | this.config.validators = validators; 261 | this.rerunValidators(); 262 | this.host.requestUpdate(); 263 | } 264 | 265 | /** 266 | * Replaces all asynchronous validators. 267 | */ 268 | setAsyncValidators(asyncValidators: AsyncValidator>[]) { 269 | this.config.asyncValidators = asyncValidators; 270 | this.rerunAsyncValidators(); 271 | this.host.requestUpdate(); 272 | } 273 | 274 | rerunValidators() { 275 | this.validatorsSub?.unsubscribe(); 276 | 277 | this.validatorsSub = merge(this.structureChanged$, this.valueChanges()).subscribe(() => { 278 | let errors: ValidationError[] = []; 279 | 280 | this.config.validators.forEach(validator => { 281 | const error = validator(this); 282 | if (error && !errors.includes(error)) { 283 | errors.push(error); 284 | } 285 | }); 286 | this.errorsSync$.next(errors); 287 | this.host.requestUpdate(); 288 | }) 289 | } 290 | 291 | rerunAsyncValidators() { 292 | this.asyncValidatorsSub?.unsubscribe(); 293 | 294 | this.asyncValidatorsSub = merge(this.structureChanged$, this.valueChanges()).pipe( 295 | switchMap(() => { 296 | this.runningAsyncValidatorsCount = 0; 297 | const observables = this.config.asyncValidators.map(v => { 298 | this.runningAsyncValidatorsCount++; 299 | this.host.requestUpdate(); 300 | return from(v(this)).pipe( 301 | tap(() => { 302 | this.runningAsyncValidatorsCount--; 303 | this.host.requestUpdate(); 304 | }), 305 | catchError(() => { 306 | this.runningAsyncValidatorsCount--; 307 | this.host.requestUpdate(); 308 | return of(null) 309 | }), 310 | ) 311 | }); 312 | 313 | if (observables.length < 1) { 314 | return of([]); 315 | } 316 | 317 | return combineLatest(observables).pipe( 318 | map((values) => values.filter((v) => v !== null)) 319 | ); 320 | }) 321 | ).subscribe(e => { 322 | this.errorsAsync$.next(e as string[]); 323 | this.host.requestUpdate(); 324 | }); 325 | } 326 | } 327 | -------------------------------------------------------------------------------- /src/controllers/form-builder.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveController, ReactiveControllerHost } from 'lit'; 2 | import { FormArray } from './form-array'; 3 | import { FormControl } from './form-control'; 4 | import { FormGroup, GroupShape } from './form-group'; 5 | import { Validator, AsyncValidator } from '../validation/models'; 6 | import { AbstractControl } from '../abstract-control'; 7 | import { ControlAccessorFactory } from '../accessors/accessors'; 8 | 9 | export interface FormBuilderConfig { 10 | updateOn: 'input' | 'blur'; 11 | accessorFactory: ControlAccessorFactory; 12 | } 13 | 14 | /** 15 | * Convenience methods to create any kind of control. 16 | * If passed a config, it'll use the same config for all FormControl's. 17 | */ 18 | export class FormBuilder implements ReactiveController { 19 | 20 | constructor( 21 | public host: ReactiveControllerHost, 22 | private config?: Partial 23 | ) { 24 | this.host.addController(this); 25 | } 26 | 27 | control( 28 | defaultValue: T, 29 | validators: Validator[] = [], 30 | asyncValidators: AsyncValidator[] = [], 31 | ) { 32 | return new FormControl(this.host, { 33 | defaultValue, 34 | validators, 35 | asyncValidators, 36 | accessorFactory: this.config?.accessorFactory, 37 | updateOn: this.config?.updateOn 38 | }); 39 | } 40 | 41 | group( 42 | shape: T, 43 | config?: { 44 | validators?: Validator>[], 45 | asyncValidators?: AsyncValidator>[] 46 | } 47 | ) { 48 | return new FormGroup(this.host, shape, { 49 | validators: config?.validators || [], 50 | asyncValidators: config?.asyncValidators || [], 51 | }); 52 | } 53 | 54 | array( 55 | initialItems: T[] = [], 56 | validators: Validator>[] = [], 57 | asyncValidators: AsyncValidator>[] = [] 58 | ) { 59 | return new FormArray(this.host, { 60 | initialItems, 61 | validators, 62 | asyncValidators 63 | }); 64 | } 65 | 66 | hostConnected() {} 67 | 68 | hostDisconnected() {} 69 | } 70 | -------------------------------------------------------------------------------- /src/controllers/form-control.ts: -------------------------------------------------------------------------------- 1 | import { ReactiveController, ReactiveControllerHost } from 'lit'; 2 | import { bindFactory } from '../directives/bind.directive'; 3 | import { 4 | Observable, 5 | Subscription, 6 | from, 7 | combineLatest, 8 | of, 9 | BehaviorSubject, 10 | merge, 11 | ReplaySubject, 12 | catchError, 13 | distinctUntilChanged, 14 | map, 15 | switchMap, 16 | tap, 17 | } from 'rxjs'; 18 | import { AbstractControl } from '../abstract-control'; 19 | import { UIState } from "../models"; 20 | import { AsyncValidator, ValidationError, ValidationStatus, Validator } from '../validation/models'; 21 | import { ControlAccessorFactory, getControlAccessor } from '../accessors/accessors'; 22 | 23 | export interface ControlConfig { 24 | defaultValue: T; 25 | validators: Validator>[]; 26 | asyncValidators: AsyncValidator>[]; 27 | accessorFactory: ControlAccessorFactory; 28 | updateOn: 'input' | 'blur' 29 | } 30 | 31 | export class FormControl implements AbstractControl, ReactiveController { 32 | public readonly config!: ControlConfig; 33 | public readonly bind = bindFactory(this); 34 | private _isDirty = false; 35 | private _isTouched = false; 36 | private _isBlurred = false; 37 | private value$: BehaviorSubject; 38 | private uiState$ = new BehaviorSubject('ENABLED'); 39 | private errorsSync$ = new BehaviorSubject([]); 40 | private errorsAsync$ = new BehaviorSubject([]); 41 | private errorsFixed$ = new BehaviorSubject([]); 42 | private validatorsSub?: Subscription; 43 | private asyncValidatorsSub?: Subscription; 44 | private runningAsyncValidatorsCount = 0; 45 | public validatorsChanged$ = new ReplaySubject(1); 46 | 47 | get value() { 48 | return this.value$.getValue(); 49 | } 50 | 51 | get errors() { 52 | return Array.from(new Set([ 53 | ...this.errorsSync$.getValue(), 54 | ...this.errorsAsync$.getValue(), 55 | ...this.errorsFixed$.getValue() 56 | ])); 57 | } 58 | 59 | get status() { 60 | if (this.errorsSync$.getValue().length > 0) return 'INVALID'; 61 | if (this.errorsFixed$.getValue().length > 0) return 'INVALID'; 62 | if (this.runningAsyncValidatorsCount > 0) return 'PENDING'; 63 | return this.errorsAsync$.getValue().length < 1 ? 'VALID' : 'INVALID'; 64 | } 65 | 66 | constructor( 67 | public host: ReactiveControllerHost, 68 | config: Pick, 'defaultValue'> & Partial> 69 | ) { 70 | this.config = { 71 | defaultValue: config.defaultValue, 72 | validators: config.validators ?? [], 73 | asyncValidators: config.asyncValidators ?? [], 74 | accessorFactory: config.accessorFactory ?? getControlAccessor, 75 | updateOn: config.updateOn ?? 'input' 76 | }; 77 | this.value$ = new BehaviorSubject(config.defaultValue); 78 | this.host.addController(this); 79 | } 80 | 81 | hostConnected() { 82 | this.validatorsChanged$.next(); 83 | this.rerunValidators(); 84 | this.rerunAsyncValidators(); 85 | } 86 | 87 | hostDisconnected() { 88 | this.validatorsChanged$.next(); 89 | this.validatorsSub?.unsubscribe(); 90 | this.asyncValidatorsSub?.unsubscribe(); 91 | } 92 | 93 | reset(clearStates = true) { 94 | this.value$.next(this.config.defaultValue); 95 | if (clearStates) { 96 | this._isDirty = false; 97 | this._isTouched = false; 98 | this._isBlurred = false; 99 | } 100 | this.host.requestUpdate(); 101 | } 102 | 103 | set(value: T) { 104 | this.value$.next(value); 105 | this.host.requestUpdate(); 106 | } 107 | 108 | /** 109 | * Returns true if the control has ever changed value. 110 | */ 111 | get isDirty() { 112 | return this._isDirty; 113 | } 114 | 115 | /** 116 | * Returns true if the control has ever been touched. 117 | */ 118 | get isTouched() { 119 | return this._isTouched; 120 | } 121 | 122 | /** 123 | * Returns true if the control has ever been blurred. 124 | */ 125 | get isBlurred() { 126 | return this._isBlurred; 127 | } 128 | 129 | /** 130 | * The UI State of this control. 131 | * It's either `ENABLED`, `DISABLED` or `READONLY`. 132 | */ 133 | get uiState() { 134 | return this.uiState$.getValue(); 135 | } 136 | 137 | setDirty(isDirty = true) { 138 | this._isDirty = isDirty; 139 | this.host.requestUpdate(); 140 | } 141 | 142 | setTouched(isTouched = true) { 143 | this._isTouched = isTouched; 144 | this.host.requestUpdate(); 145 | } 146 | 147 | setBlurred(isBlurred = true) { 148 | this._isBlurred = isBlurred; 149 | this.host.requestUpdate(); 150 | } 151 | 152 | setUIState(state: UIState) { 153 | this.uiState$.next(state); 154 | this.host.requestUpdate(); 155 | } 156 | 157 | /** 158 | * Returns true if the control has a specific error. 159 | */ 160 | hasError(error: string) { 161 | return this.errors.includes(error); 162 | } 163 | 164 | /** 165 | * Observable of the UI State of the control, including the initial state. 166 | */ 167 | uiStateChanges(): Observable { 168 | return this.uiState$.pipe( 169 | distinctUntilChanged(), 170 | ) 171 | } 172 | 173 | /** 174 | * Observable of the value of the control, including the initial value. 175 | */ 176 | valueChanges(): Observable { 177 | return this.value$.asObservable(); 178 | } 179 | 180 | /** 181 | * Observable of the validation status of the control, including the initial status. 182 | */ 183 | statusChanges(): Observable { 184 | return merge(this.errorsSync$, this.errorsAsync$, this.errorsFixed$).pipe( 185 | map(() => this.status), 186 | distinctUntilChanged() 187 | ); 188 | } 189 | 190 | /** 191 | * Sets custom errors on the control. These errors won't be touched by validators. 192 | * You can later remove them by calling it again with new errors. 193 | */ 194 | setFixedErrors(errors: ValidationError[]) { 195 | this.errorsFixed$.next(errors); 196 | this.host.requestUpdate(); 197 | } 198 | 199 | /** 200 | * Replaces all validators. 201 | */ 202 | setValidators(validators: Validator>[]) { 203 | this.config.validators = validators; 204 | this.validatorsChanged$.next(); 205 | this.rerunValidators(); 206 | this.host.requestUpdate(); 207 | } 208 | 209 | /** 210 | * Replaces all asynchronous validators. 211 | */ 212 | setAsyncValidators(asyncValidators: AsyncValidator>[]) { 213 | this.config.asyncValidators = asyncValidators; 214 | this.validatorsChanged$.next(); 215 | this.rerunAsyncValidators(); 216 | this.host.requestUpdate(); 217 | } 218 | 219 | rerunValidators() { 220 | this.validatorsSub?.unsubscribe(); 221 | 222 | this.validatorsSub = this.valueChanges().subscribe(() => { 223 | let errors: ValidationError[] = []; 224 | this.config.validators.forEach(validator => { 225 | const error = validator(this); 226 | if (error && !errors.includes(error)) { 227 | errors.push(error); 228 | } 229 | }); 230 | this.errorsSync$.next(errors); 231 | this.host.requestUpdate(); 232 | }); 233 | } 234 | 235 | rerunAsyncValidators() { 236 | this.asyncValidatorsSub?.unsubscribe(); 237 | 238 | this.asyncValidatorsSub = this.valueChanges().pipe( 239 | switchMap(() => { 240 | this.runningAsyncValidatorsCount = 0; 241 | const observables = this.config.asyncValidators.map(v => { 242 | this.runningAsyncValidatorsCount++; 243 | this.host.requestUpdate(); 244 | return from(v(this)).pipe( 245 | tap(() => { 246 | this.runningAsyncValidatorsCount--; 247 | this.host.requestUpdate(); 248 | }), 249 | catchError(() => { 250 | this.runningAsyncValidatorsCount--; 251 | this.host.requestUpdate(); 252 | return of(null) 253 | }), 254 | ) 255 | }); 256 | if (observables.length < 1) { 257 | return of([]); 258 | } 259 | return combineLatest(observables).pipe( 260 | map((values) => values.filter((v) => v !== null)) 261 | ); 262 | }) 263 | ).subscribe(e => { 264 | this.errorsAsync$.next(e as string[]); 265 | this.host.requestUpdate(); 266 | }); 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/controllers/form-group.ts: -------------------------------------------------------------------------------- 1 | import { nothing, ReactiveController, ReactiveControllerHost } from 'lit'; 2 | import { Observable, merge, ReplaySubject, Subscription, from, of, combineLatest, BehaviorSubject, map, switchMap, tap, catchError, distinctUntilChanged } from 'rxjs'; 3 | import { FormControl } from './form-control'; 4 | import { AbstractControl } from '../abstract-control'; 5 | import { BindKey, EnabledValueOf, ValueOf } from '../models'; 6 | import { DirectiveResult } from 'lit/directive'; 7 | import { AsyncValidator, ValidationError, ValidationStatus, Validator } from '../validation/models'; 8 | import { BindConfig } from '../directives/bind.directive'; 9 | 10 | export type GroupShape = Record; 11 | 12 | export interface FormGroupConfig { 13 | validators: Validator>[]; 14 | asyncValidators: AsyncValidator>[]; 15 | } 16 | 17 | export class FormGroup implements AbstractControl, ReactiveController { 18 | public readonly config!: FormGroupConfig; 19 | private structureChanged$ = new ReplaySubject(1); 20 | private runningAsyncValidatorsCount = 0; 21 | private errorsSync$ = new BehaviorSubject([]); 22 | private errorsAsync$ = new BehaviorSubject([]); 23 | private errorsFixed$ = new BehaviorSubject([]); 24 | private validatorsSub?: Subscription; 25 | private asyncValidatorsSub?: Subscription; 26 | 27 | get value(): ValueOf { 28 | let value: ValueOf = {} as any; 29 | Object.keys(this.controls).forEach((key: keyof T) => { 30 | value[key] = this.controls[key].value 31 | }); 32 | return value; 33 | } 34 | 35 | /** 36 | * Strips disabled FormControl's 37 | */ 38 | get enabledValue(): EnabledValueOf { 39 | let value: EnabledValueOf = {} as any; 40 | Object.keys(this.controls).forEach((key: keyof T) => { 41 | const control = this.controls[key]; 42 | if (control instanceof FormControl && control.uiState === 'DISABLED') { 43 | return; 44 | } 45 | if (control instanceof FormGroup) { 46 | value[key] = control.enabledValue as any; 47 | return; 48 | } 49 | value[key] = control.value 50 | }); 51 | return value; 52 | } 53 | 54 | get errors() { 55 | return Array.from(new Set([ 56 | ...this.errorsSync$.getValue(), 57 | ...this.errorsAsync$.getValue(), 58 | ...this.errorsFixed$.getValue() 59 | ])); 60 | } 61 | 62 | get status() { 63 | if ( 64 | this.errorsSync$.getValue().length > 0 65 | || this.errorsAsync$.getValue().length > 0 66 | || this.errorsFixed$.getValue().length > 0 67 | ) return 'INVALID'; 68 | const invalid = Object.values(this.controls).find(c => c.status === 'INVALID'); 69 | if (invalid) { return 'INVALID' } 70 | const pending = Object.values(this.controls).find(c => c.status === 'PENDING'); 71 | if (pending) { return 'PENDING' } 72 | if (this.runningAsyncValidatorsCount > 0) return 'PENDING'; 73 | return 'VALID'; 74 | } 75 | 76 | constructor( 77 | public host: ReactiveControllerHost, 78 | public controls: T, 79 | config?: Partial> 80 | ) { 81 | this.host.addController(this); 82 | this.config = { 83 | validators: config?.validators ?? [], 84 | asyncValidators: config?.asyncValidators ?? [] 85 | }; 86 | this.structureChanged$.next(); 87 | this.reset(); 88 | } 89 | 90 | hostConnected() { 91 | this.rerunValidators(); 92 | this.rerunAsyncValidators(); 93 | } 94 | 95 | hostDisconnected() { 96 | this.validatorsSub?.unsubscribe(); 97 | this.asyncValidatorsSub?.unsubscribe(); 98 | } 99 | 100 | /** 101 | * Bind to every nested control (only FormControl's). 102 | */ 103 | bind: (field: BindKey>, config?: Partial) => DirectiveResult = (field, config) => { 104 | const control = this.get(field); 105 | 106 | if (control instanceof FormControl) { 107 | return control.bind(config ?? {}); 108 | } 109 | const splitted = ('' + field).split('.'); 110 | const [firstKey, ...nested] = splitted; 111 | const x = this.get(firstKey); 112 | 113 | if (x instanceof FormGroup) { 114 | return x.bind(nested.join('.'), config ?? {}); 115 | } 116 | return nothing; 117 | } 118 | 119 | /** 120 | * Curried utility for using the same configuration on multiple fields. 121 | */ 122 | bindWith = (config: Partial) => (field: BindKey>) => { 123 | return this.bind(field, config); 124 | } 125 | 126 | /** 127 | * Retrieves a direct child by key. 128 | */ 129 | get(key: K): T[K] { 130 | return this.controls[key]; 131 | } 132 | 133 | /** 134 | * Calls `reset` on every child. 135 | * @param clearStates - Clears the states of every child. 136 | */ 137 | reset(clearStates = true) { 138 | Object.keys(this.controls).forEach((key) => { 139 | this.get(key as keyof T).reset(clearStates); 140 | }); 141 | } 142 | 143 | /** 144 | * Tries to set a value for this group. Use this to make sure you specify all properties. 145 | * @param value - The complete value of the group. 146 | */ 147 | set(value: ValueOf) { 148 | this.patch(value); 149 | } 150 | 151 | /** 152 | * Tries to set a value for this group. 153 | * @param value - A partial value of the group. 154 | */ 155 | patch(value: Partial>) { 156 | Object.keys(value).forEach((key: keyof T) => { 157 | this.controls[key]?.set(value[key]); 158 | }); 159 | } 160 | 161 | /** 162 | * true if at least one child is dirty. 163 | */ 164 | get isDirty() { 165 | return Object.keys(this.controls).some(key => this.controls[key].isDirty); 166 | } 167 | 168 | /** 169 | * true if at least one child is touched. 170 | */ 171 | get isTouched() { 172 | return Object.keys(this.controls).some(key => this.controls[key].isTouched); 173 | } 174 | 175 | /** 176 | * true if at least one child is blurred. 177 | */ 178 | get isBlurred() { 179 | return Object.keys(this.controls).some(key => this.controls[key].isBlurred); 180 | } 181 | 182 | /** 183 | * Returns true if the group has a specific error. 184 | */ 185 | hasError(error: string) { 186 | return this.errors.includes(error); 187 | } 188 | 189 | /** 190 | * Observable of the value of the group, including the initial value. 191 | */ 192 | valueChanges(): Observable> { 193 | return this.structureChanged$.pipe( 194 | switchMap(() => { 195 | if (Object.keys(this.controls).length === 0) return of(this.value); 196 | const observables = Object.keys(this.controls).map(key => this.get(key).valueChanges()) 197 | return merge(...observables).pipe( 198 | map(() => this.value), 199 | ); 200 | }) 201 | ); 202 | } 203 | 204 | /** 205 | * Observable of the validation status of the group, including the initial status. 206 | */ 207 | statusChanges(): Observable { 208 | return merge(this.errorsSync$, this.errorsAsync$, this.errorsFixed$).pipe( 209 | map(() => this.status), 210 | distinctUntilChanged() 211 | ); 212 | } 213 | 214 | /** 215 | * Sets custom errors on the group. These errors won't be touched by validators. 216 | * You can later remove them by calling it again with new errors. 217 | */ 218 | setFixedErrors(errors: ValidationError[]) { 219 | this.errorsFixed$.next(errors); 220 | this.host.requestUpdate(); 221 | } 222 | 223 | /** 224 | * Replaces all validators. 225 | */ 226 | setValidators(validators: Validator>[]) { 227 | this.config.validators = validators; 228 | this.rerunValidators(); 229 | this.host.requestUpdate(); 230 | } 231 | 232 | /** 233 | * Replaces all asynchronous validators. 234 | */ 235 | setAsyncValidators(asyncValidators: AsyncValidator>[]) { 236 | this.config.asyncValidators = asyncValidators; 237 | this.rerunAsyncValidators(); 238 | this.host.requestUpdate(); 239 | } 240 | 241 | rerunValidators() { 242 | this.validatorsSub?.unsubscribe(); 243 | 244 | this.validatorsSub = merge(this.structureChanged$, this.valueChanges()).subscribe(() => { 245 | let errors: ValidationError[] = []; 246 | 247 | this.config.validators.forEach(validator => { 248 | const error = validator(this); 249 | if (error && !errors.includes(error)) { 250 | errors.push(error); 251 | } 252 | }); 253 | this.errorsSync$.next(errors); 254 | this.host.requestUpdate(); 255 | }) 256 | } 257 | 258 | rerunAsyncValidators() { 259 | this.asyncValidatorsSub?.unsubscribe(); 260 | 261 | this.asyncValidatorsSub = merge(this.structureChanged$, this.valueChanges()).pipe( 262 | switchMap(() => { 263 | this.runningAsyncValidatorsCount = 0; 264 | const observables = this.config.asyncValidators.map(v => { 265 | this.runningAsyncValidatorsCount++; 266 | this.host.requestUpdate(); 267 | return from(v(this)).pipe( 268 | tap(() => { 269 | this.runningAsyncValidatorsCount--; 270 | this.host.requestUpdate(); 271 | }), 272 | catchError(() => { 273 | this.runningAsyncValidatorsCount--; 274 | this.host.requestUpdate(); 275 | return of(null) 276 | }), 277 | ) 278 | }); 279 | 280 | if (observables.length < 1) { 281 | return of([]); 282 | } 283 | 284 | return combineLatest(observables).pipe( 285 | map((values) => values.filter((v) => v !== null)) 286 | ); 287 | }) 288 | ).subscribe(e => { 289 | this.errorsAsync$.next(e as string[]); 290 | this.host.requestUpdate(); 291 | }); 292 | } 293 | 294 | /** 295 | * Experimental: the Group must be typed accordingly! The base type wont' include the new control 296 | */ 297 | addControl(name: string, control: AbstractControl) { 298 | if (name in this.controls) { 299 | throw new Error(`There's already a control named ${name}.`); 300 | } 301 | (this.controls as any)[name] = control; 302 | this.structureChanged$.next(); 303 | this.host.requestUpdate(); 304 | } 305 | 306 | /** 307 | * Experimental: the Group must be typed accordingly! The base type will have the old control type 308 | */ 309 | setControl(name: string, control: AbstractControl) { 310 | if (!(name in this.controls)) { 311 | throw new Error(`There's no control named ${name}.`); 312 | } 313 | (this.controls as any)[name] = control; 314 | this.structureChanged$.next(); 315 | this.host.requestUpdate(); 316 | } 317 | 318 | /** 319 | * Experimental: the Group must be typed accordingly! The base type will include the removed control 320 | */ 321 | removeControl(name: string) { 322 | if (!(name in this.controls)) { 323 | throw new Error(`There's no control named ${name}.`); 324 | } 325 | delete this.controls[name]; 326 | this.structureChanged$.next(); 327 | this.host.requestUpdate(); 328 | } 329 | } 330 | -------------------------------------------------------------------------------- /src/directives/bind.directive.ts: -------------------------------------------------------------------------------- 1 | import { ElementPart, nothing, PropertyPart } from 'lit'; 2 | import { DirectiveParameters, PartInfo, PartType } from 'lit/directive.js'; 3 | import { AsyncDirective, directive } from 'lit/async-directive.js'; 4 | import { Subscription, map, startWith } from 'rxjs'; 5 | import { FormControl } from '../controllers/form-control'; 6 | import { AsyncValidator, Validator, ValidatorWithEffects } from '../validation/models'; 7 | import { ControlAccessor } from '../accessors/control-accessor'; 8 | 9 | /** 10 | * The `bind` Directive accepts an optional configuration for each element it binds to. 11 | */ 12 | export interface BindConfig { 13 | accessor?: { new(el: T): ControlAccessor }, 14 | updateOn: 'input' | 'blur' 15 | } 16 | 17 | export const bindFactory = (control: FormControl) => { 18 | 19 | return directive( 20 | class Bind extends AsyncDirective { 21 | element!: HTMLElement; 22 | accessor!: ControlAccessor; 23 | isSetup = false; 24 | validators: Array | AsyncValidator> = []; 25 | sub?: Subscription; 26 | config!: BindConfig; 27 | 28 | inputListener = () => { 29 | control.setDirty(); 30 | if (this.config.updateOn === 'input') { 31 | this.setModel(); 32 | } 33 | }; 34 | 35 | focusListener = () => { 36 | control.setTouched(); 37 | }; 38 | 39 | blurListener = () => { 40 | control.setBlurred(); 41 | if (this.config.updateOn === 'blur') { 42 | this.setModel(); 43 | } 44 | }; 45 | 46 | constructor(partInfo: PartInfo) { 47 | super(partInfo); 48 | if (partInfo.type !== PartType.ELEMENT) { 49 | throw new Error('Use as ") { 54 | this.element = part.element as HTMLElement; 55 | if (config?.accessor && config?.accessor !== this.config?.accessor) { 56 | this.accessor = new config.accessor(this.element); 57 | } 58 | this.config = { 59 | ...config, 60 | updateOn: config?.updateOn ?? control.config.updateOn 61 | }; 62 | if (!this.isSetup) { 63 | /** 64 | * TODO: this code has been put here to avoid performance issues. 65 | * However, if the element's attributes change (eg. type) the accessor stays the same. 66 | * What can be done here? 67 | */ 68 | if (this.config.accessor) { 69 | this.accessor = new this.config.accessor(this.element); 70 | } else { 71 | this.accessor = control.config.accessorFactory(this.element); 72 | } 73 | this.reconnected(); 74 | this.isSetup = true; 75 | } 76 | return this.render(config ?? {}); 77 | } 78 | 79 | render(_config?: Partial) { 80 | return nothing; 81 | } 82 | 83 | disconnected() { 84 | this.sub?.unsubscribe(); 85 | // Remove all validator attributes 86 | this.validators.forEach(_v => { 87 | const v = _v as ValidatorWithEffects; 88 | v.disconnected?.(this.element, control); 89 | }); 90 | this.validators = []; 91 | this.accessor.setValidity?.(null); 92 | this.accessor.onDisconnect?.(); 93 | } 94 | 95 | reconnected() { 96 | // View to Model 97 | this.accessor.registerOnChange(this.inputListener); 98 | this.accessor.registerOnTouch?.(this.focusListener); 99 | this.accessor.registerOnBlur?.(this.blurListener); 100 | // Model to View 101 | this.sub = new Subscription(); 102 | 103 | // Update the DOM when the value changes 104 | this.sub.add( 105 | control.valueChanges().pipe( 106 | startWith(null) 107 | ).subscribe(() => { 108 | this.setView(); 109 | }) 110 | ); 111 | 112 | // Update the DOM when the UI State changes (disabled, readonly) 113 | this.sub.add( 114 | control.uiStateChanges().subscribe(uiState => { 115 | this.accessor.setUIState?.(uiState); 116 | }) 117 | ); 118 | 119 | // Update the DOM when the validation status changes (valid, invalid, pending) 120 | this.sub.add( 121 | control.statusChanges().subscribe(status => { 122 | this.accessor.setValidity?.(status); 123 | }) 124 | ); 125 | 126 | // Update the DOM when validators change (they could add attributes) 127 | this.sub.add( 128 | control.validatorsChanged$.pipe( 129 | map(() => [...control.config.validators, ...control.config.asyncValidators]), 130 | ).subscribe(newValidators => { 131 | 132 | this.validators.forEach(v => { 133 | (v as ValidatorWithEffects).disconnected?.(this.element, control); 134 | }) 135 | 136 | newValidators.forEach(v => { 137 | (v as ValidatorWithEffects).connected?.(this.element, control); 138 | }) 139 | 140 | this.validators = newValidators; 141 | }) 142 | ); 143 | } 144 | 145 | setView() { 146 | const domValue = this.accessor.getValue(); 147 | if (control.value != domValue) { 148 | this.accessor.setValue(control.value ?? null); 149 | } 150 | } 151 | 152 | setModel() { 153 | const domValue = this.accessor.getValue(); 154 | if (control.value != domValue) { 155 | control.set(domValue); 156 | } 157 | } 158 | } 159 | ); 160 | } 161 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './directives/bind.directive'; 2 | export * from './controllers/form-group'; 3 | export * from './controllers/form-control'; 4 | export * from './controllers/form-builder'; 5 | export * from './controllers/form-array'; 6 | export * from './validation/validators-with-effects'; 7 | export * from './validation/pure-validators'; 8 | export * from './validation/models'; 9 | export * from './models'; 10 | export * from './accessors/accessors'; 11 | export * from './accessors/control-accessor'; 12 | -------------------------------------------------------------------------------- /src/models.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl } from "./abstract-control"; 2 | import { FormArray } from "./controllers/form-array"; 3 | import { FormControl } from "./controllers/form-control"; 4 | import { FormGroup } from "./controllers/form-group"; 5 | 6 | type DotPrefix = T extends "" ? "" : `.${T}` 7 | 8 | /** 9 | * A field can be in one of these states at a time. 10 | */ 11 | export type UIState = 'ENABLED' | 'DISABLED' | 'READONLY'; 12 | 13 | /** 14 | * Given a FormGroup, returns all the possible keys for bindings with FormControls. 15 | * Includes nested keys (dotted syntax) only for nested FormGroups. 16 | * 17 | * Examples: 18 | * 19 | * OK: 'consent' (if it's a FormControl) 20 | * OK: 'user.name' (if "user" is a FormGroup and "name" is a FormControl) 21 | * NOT OK: 'user' (if "user" is a FormGroup, we can only bind FormControl's) 22 | * NOT OK: 'addresses[0].street' (FormArrays must be mapped in the template with map/repeat...) 23 | */ 24 | export type BindKey = ( 25 | T extends FormGroup 26 | ? { [K in Exclude]: 27 | ( 28 | Shape[K] extends FormControl 29 | ? K 30 | : Shape[K] extends FormGroup 31 | ? `${K}${DotPrefix>>}` 32 | : never 33 | ) 34 | }[Exclude] 35 | : never 36 | ) extends infer D ? Extract : never; 37 | 38 | /** 39 | * Extracts a value from an AbstractControl 40 | */ 41 | export type ValueOf = 42 | T extends FormControl 43 | ? U 44 | : T extends FormArray 45 | ? ValueOf[] 46 | : T extends FormGroup 47 | ? { [k in keyof U]: ValueOf } 48 | : never; 49 | 50 | /** 51 | * Extracts a value from an AbstractControl, but makes FormControls undefineable 52 | */ 53 | export type EnabledValueOf = 54 | T extends FormControl 55 | ? U | undefined 56 | : T extends FormArray 57 | ? EnabledValueOf[] 58 | : T extends FormGroup 59 | ? ( 60 | // FormGroups and FormArrays are required to be in the final value 61 | Required<{ [k in keyof U as U[k] extends FormControl ? never : k]: EnabledValueOf }> & 62 | // FormControls may be missing if disabled 63 | Partial<{ [k in keyof U]: EnabledValueOf }> 64 | ) 65 | : never; -------------------------------------------------------------------------------- /src/validation/models.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl } from "../abstract-control"; 2 | 3 | export type ValidationError = string; 4 | export type ValidationStatus = 'VALID' | 'INVALID' | 'PENDING'; 5 | 6 | /** 7 | * A Validator takes an AbstractControl and returns either an error or null. 8 | */ 9 | export interface Validator { 10 | (control: T): ValidationError | null; 11 | } 12 | 13 | /** 14 | * Same as Validator, but returns a Promise. 15 | */ 16 | export interface AsyncValidator { 17 | (control: T): Promise; 18 | } 19 | 20 | /** 21 | * A Validator can have 2 effects (functions), which are called when the 22 | * validator is added or removed to a control. 23 | * 24 | * You can use these 2 functions to set/remove a11y attributes on the element. 25 | * `ValidatorsWithEffects` are validators with built-in effects. You can write your 26 | * own validators with your own effects, for example to check a custom element's 27 | * local name and decide which attribute to set (eg. some elements want `minLength`, not `minlength`). 28 | */ 29 | type ValidatorEffects = { 30 | connected: (element: HTMLElement, control: AbstractControl) => void; 31 | disconnected: (element: HTMLElement, control: AbstractControl) => void; 32 | } 33 | 34 | export type ValidatorWithEffects = Validator & ValidatorEffects; 35 | export type AsyncValidatorWithEffects = AsyncValidator & ValidatorEffects; 36 | 37 | -------------------------------------------------------------------------------- /src/validation/pure-validators.ts: -------------------------------------------------------------------------------- 1 | import { FormControl } from "../controllers/form-control"; 2 | import { Validator } from "./models"; 3 | 4 | const required: Validator = (control) => { 5 | if (!(control instanceof FormControl)) return null; 6 | if (control.value == null || control.value.length === 0) return 'required'; 7 | return null; 8 | } 9 | 10 | const requiredTrue: Validator = (control) => { 11 | if (!(control instanceof FormControl)) return null; 12 | if (control.value === true) return null; 13 | return 'requiredTrue'; 14 | } 15 | 16 | function minLength(n: number) { 17 | const f: Validator = (control) => { 18 | if (!(control instanceof FormControl)) return null; 19 | return ('' + control.value).length >= n ? null : 'minLength'; 20 | } 21 | return f; 22 | } 23 | 24 | function maxLength(n: number) { 25 | const f: Validator = (control) => { 26 | if (!(control instanceof FormControl)) return null; 27 | return ('' + control.value).length <= n ? null : 'maxLength'; 28 | } 29 | return f; 30 | } 31 | 32 | function min(n: number) { 33 | const f: Validator = (control) => { 34 | if (!(control instanceof FormControl)) return null; 35 | return +(control.value) >= n ? null : 'min'; 36 | } 37 | return f; 38 | } 39 | 40 | function max(n: number) { 41 | const f: Validator = (control) => { 42 | if (!(control instanceof FormControl)) return null; 43 | return +(control.value) <= n ? null : 'max'; 44 | } 45 | return f; 46 | } 47 | 48 | export const EMAIL_REGEXP = 49 | /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; 50 | 51 | const email: Validator = (control) => { 52 | if (!(control instanceof FormControl)) return null; 53 | return EMAIL_REGEXP.test(control.value) ? null : 'email'; 54 | } 55 | 56 | function pattern(stringOrRegexp: string | RegExp): Validator { 57 | if (!stringOrRegexp) return () => null; 58 | let regex: RegExp; 59 | let regexStr: string; 60 | if (typeof stringOrRegexp === 'string') { 61 | regexStr = ''; 62 | 63 | if (stringOrRegexp.charAt(0) !== '^') regexStr += '^'; 64 | 65 | regexStr += stringOrRegexp; 66 | 67 | if (stringOrRegexp.charAt(stringOrRegexp.length - 1) !== '$') regexStr += '$'; 68 | 69 | regex = new RegExp(regexStr); 70 | } else { 71 | regexStr = stringOrRegexp.toString(); 72 | regex = stringOrRegexp; 73 | } 74 | const f: Validator = (control) => { 75 | if (!(control instanceof FormControl)) return null; 76 | return regex.test(control.value) ? null : 'pattern'; 77 | }; 78 | return f; 79 | } 80 | 81 | export const PureValidators = { 82 | required, 83 | requiredTrue, 84 | minLength, 85 | maxLength, 86 | min, 87 | max, 88 | email, 89 | pattern, 90 | }; 91 | 92 | -------------------------------------------------------------------------------- /src/validation/validators-with-effects.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl } from "../abstract-control"; 2 | import { Validator, ValidatorWithEffects } from "./models"; 3 | import { PureValidators } from "./pure-validators"; 4 | 5 | /** 6 | * Take an existing validator and add effects to it. 7 | */ 8 | export function addEffectsToValidator( 9 | validator: Validator, 10 | connected: (element: HTMLElement, control: AbstractControl) => void, 11 | disconnected: (element: HTMLElement, control: AbstractControl) => void, 12 | ): ValidatorWithEffects { 13 | const f: ValidatorWithEffects = (control: AbstractControl) => validator(control); 14 | f.connected = connected; 15 | f.disconnected = disconnected; 16 | return f; 17 | } 18 | 19 | const required = addEffectsToValidator(PureValidators.required, 20 | (el) => { el.setAttribute('required', '') }, 21 | (el) => { el.removeAttribute('required') } 22 | ); 23 | 24 | const requiredTrue = addEffectsToValidator(PureValidators.requiredTrue, 25 | (el) => { el.setAttribute('required', '') }, 26 | (el) => { el.removeAttribute('required') } 27 | ); 28 | 29 | const email = PureValidators.email; 30 | 31 | function minLength(n: number) { 32 | return addEffectsToValidator(PureValidators.minLength(n), 33 | (el) => { el.setAttribute('minlength', '' + n) }, 34 | (el) => { el.removeAttribute('minlength') } 35 | ); 36 | } 37 | 38 | function maxLength(n: number) { 39 | return addEffectsToValidator(PureValidators.maxLength(n), 40 | (el) => { el.setAttribute('maxlength', '' + n) }, 41 | (el) => { el.removeAttribute('maxlength') } 42 | ); 43 | } 44 | 45 | function min(n: number) { 46 | return addEffectsToValidator(PureValidators.min(n), 47 | (el) => { el.setAttribute('min', '' + n) }, 48 | (el) => { el.removeAttribute('min') } 49 | ); 50 | } 51 | 52 | function max(n: number) { 53 | return addEffectsToValidator(PureValidators.max(n), 54 | (el) => { el.setAttribute('max', '' + n) }, 55 | (el) => { el.removeAttribute('max') } 56 | ); 57 | } 58 | 59 | function pattern(stringOrRegexp: string | RegExp) { 60 | return addEffectsToValidator(PureValidators.pattern(stringOrRegexp), 61 | (el) => { el.setAttribute('pattern', stringOrRegexp.toString()) }, 62 | (el) => { el.removeAttribute('pattern') } 63 | ); 64 | } 65 | 66 | /** 67 | * These Validators are setup to make the `bind` Directive 68 | * set a11y attributes on the elements (required, minlength, maxlength, pattern...). 69 | * 70 | * You can create your own Validators With Effects, check for the element's name and 71 | * set different attributes depending on it. 72 | */ 73 | export const ValidatorsWithEffects = { 74 | required, 75 | requiredTrue, 76 | minLength, 77 | maxLength, 78 | min, 79 | max, 80 | email, 81 | pattern, 82 | }; 83 | -------------------------------------------------------------------------------- /test/form-array.test.ts: -------------------------------------------------------------------------------- 1 | import type { IWindow } from 'happy-dom' 2 | import { beforeEach, describe, it, vi, expect, afterEach } from 'vitest' 3 | import { FormArray, FormControl, PureValidators } from '../src'; 4 | import { LitElement, html } from 'lit'; 5 | 6 | declare global { 7 | interface Window extends IWindow {} 8 | } 9 | 10 | export class MyElement extends LitElement { 11 | 12 | formArray = new FormArray(this, { 13 | initialItems: [] 14 | }); 15 | 16 | render() { 17 | return html` 18 | ${this.formArray.controls.map(c => html` 19 | 20 | `)} 21 | ` 22 | } 23 | } 24 | customElements.define('my-element', MyElement); 25 | 26 | describe('FormArray', async () => { 27 | 28 | let element: MyElement; 29 | 30 | beforeEach(async () => { 31 | document.body.innerHTML = '' 32 | await window.happyDOM.whenAsyncComplete() 33 | await new Promise(resolve => setTimeout(resolve, 0)); 34 | 35 | element = document.querySelector('my-element')!; 36 | }) 37 | 38 | afterEach(() => { 39 | vi.restoreAllMocks() 40 | }) 41 | 42 | describe('Items manipulation', () => { 43 | 44 | it('should append', () => { 45 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 46 | expect(element.formArray.controls.length).toBe(1); 47 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 48 | element.formArray.append(new FormControl(element, { defaultValue: 'test 3' })); 49 | expect(element.formArray.controls.length).toBe(3); 50 | expect(element.formArray.controls.map(c => c.value)).toEqual(['test 1', 'test 2', 'test 3']) 51 | }) 52 | 53 | it('should clear', () => { 54 | element.formArray.append(new FormControl(element, { defaultValue: 'test' })); 55 | element.formArray.append(new FormControl(element, { defaultValue: 'test' })); 56 | expect(element.formArray.controls.length).toBe(2); 57 | element.formArray.clear(); 58 | expect(element.formArray.controls.length).toBe(0); 59 | }) 60 | 61 | it('should insertAt', () => { 62 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 63 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 64 | element.formArray.append(new FormControl(element, { defaultValue: 'test 3' })); 65 | element.formArray.insertAt(new FormControl(element, { defaultValue: 'test 1.5' }), 1); 66 | expect(element.formArray.controls.length).toBe(4); 67 | expect(element.formArray.get(0)!.value).toBe('test 1'); 68 | expect(element.formArray.get(1)!.value).toBe('test 1.5'); 69 | expect(element.formArray.get(2)!.value).toBe('test 2'); 70 | expect(element.formArray.get(3)!.value).toBe('test 3'); 71 | }) 72 | 73 | it('should prepend', () => { 74 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 75 | expect(element.formArray.controls.length).toBe(1); 76 | element.formArray.prepend(new FormControl(element, { defaultValue: 'test 0' })); 77 | expect(element.formArray.controls.length).toBe(2); 78 | expect(element.formArray.controls.map(c => c.value)).toEqual(['test 0', 'test 1']) 79 | }) 80 | 81 | it('should removeAt', () => { 82 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 83 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 84 | element.formArray.append(new FormControl(element, { defaultValue: 'test 3' })); 85 | element.formArray.removeAt(1); 86 | expect(element.formArray.controls.length).toBe(2); 87 | expect(element.formArray.get(0)!.value).toBe('test 1'); 88 | expect(element.formArray.get(1)!.value).toBe('test 3'); 89 | }) 90 | 91 | it('should pop', () => { 92 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 93 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 94 | element.formArray.append(new FormControl(element, { defaultValue: 'test 3' })); 95 | element.formArray.pop(); 96 | expect(element.formArray.controls.length).toBe(2); 97 | expect(element.formArray.get(0)!.value).toBe('test 1'); 98 | expect(element.formArray.get(1)!.value).toBe('test 2'); 99 | }) 100 | 101 | it('should swap', () => { 102 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 103 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 104 | element.formArray.append(new FormControl(element, { defaultValue: 'test 3' })); 105 | element.formArray.swap(0, 2); 106 | expect(element.formArray.controls.length).toBe(3); 107 | expect(element.formArray.get(0)!.value).toBe('test 3'); 108 | expect(element.formArray.get(1)!.value).toBe('test 2'); 109 | expect(element.formArray.get(2)!.value).toBe('test 1'); 110 | }) 111 | 112 | it('should move', () => { 113 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 114 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 115 | element.formArray.append(new FormControl(element, { defaultValue: 'test 3' })); 116 | element.formArray.move(0, 2); 117 | expect(element.formArray.controls.length).toBe(3); 118 | expect(element.formArray.get(0)!.value).toBe('test 2'); 119 | expect(element.formArray.get(1)!.value).toBe('test 3'); 120 | expect(element.formArray.get(2)!.value).toBe('test 1'); 121 | }) 122 | 123 | }) 124 | 125 | it('should reset all children', () => { 126 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 127 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 128 | element.formArray.get(0)!.set('new 1'); 129 | element.formArray.get(1)!.set('new 2'); 130 | expect(element.formArray.get(0)!.value).toBe('new 1'); 131 | expect(element.formArray.get(1)!.value).toBe('new 2'); 132 | element.formArray.reset(); 133 | expect(element.formArray.get(0)!.value).toBe('test 1'); 134 | expect(element.formArray.get(1)!.value).toBe('test 2'); 135 | }) 136 | 137 | describe('Dirty, Touched, Blurred', () => { 138 | 139 | it('should be dirty if at least 1 child is dirty', () => { 140 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 141 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 142 | expect(element.formArray.isDirty).toBe(false); 143 | (element.formArray.get(0)! as FormControl).setDirty(true); 144 | expect(element.formArray.isDirty).toBe(true); 145 | (element.formArray.get(0)! as FormControl).setDirty(false); 146 | expect(element.formArray.isDirty).toBe(false); 147 | }) 148 | 149 | it('should be touched if at least 1 child is touched', () => { 150 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 151 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 152 | expect(element.formArray.isTouched).toBe(false); 153 | (element.formArray.get(0)! as FormControl).setTouched(true); 154 | expect(element.formArray.isTouched).toBe(true); 155 | (element.formArray.get(0)! as FormControl).setTouched(false); 156 | expect(element.formArray.isTouched).toBe(false); 157 | }) 158 | 159 | it('should be blurred if at least 1 child is blurred', () => { 160 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 161 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 162 | expect(element.formArray.isBlurred).toBe(false); 163 | (element.formArray.get(0)! as FormControl).setBlurred(true); 164 | expect(element.formArray.isBlurred).toBe(true); 165 | (element.formArray.get(0)! as FormControl).setBlurred(false); 166 | expect(element.formArray.isBlurred).toBe(false); 167 | }) 168 | }) 169 | 170 | describe('Validation', () => { 171 | 172 | it('should be VALID if all children are VALID', () => { 173 | expect(element.formArray.status).toBe('VALID'); 174 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 175 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 176 | expect(element.formArray.status).toBe('VALID'); 177 | }) 178 | 179 | it('should be INVALID if at least 1 child is INVALID', () => { 180 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 181 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 182 | element.formArray.get(0)!.setFixedErrors(['a', 'b']); 183 | expect(element.formArray.status).toBe('INVALID'); 184 | expect(element.formArray.errors).toEqual([]); 185 | }) 186 | 187 | it('should be PENDING if at least 1 child is PENDING and others are VALID', async () => { 188 | element.formArray.append(new FormControl(element, { defaultValue: 'test 1' })); 189 | element.formArray.append(new FormControl(element, { defaultValue: 'test 2' })); 190 | (element.formArray.get(0)! as FormControl).setAsyncValidators([ 191 | () => Promise.resolve('error') 192 | ]) 193 | expect(element.formArray.status).toBe('PENDING'); 194 | await new Promise(resolve => setTimeout(resolve, 0)); 195 | expect(element.formArray.status).toBe('INVALID'); 196 | expect(element.formArray.errors).toEqual([]); 197 | // Even if the second item is PENDING, the first one is INVALID so the whole array is INVALID 198 | (element.formArray.get(1)! as FormControl).setAsyncValidators([ 199 | () => Promise.resolve('error') 200 | ]) 201 | expect(element.formArray.get(1)!.status).toBe('PENDING'); 202 | expect(element.formArray.status).toBe('INVALID'); 203 | expect(element.formArray.errors).toEqual([]); 204 | }) 205 | 206 | it('should set fixed errors', () => { 207 | element.formArray.setFixedErrors(['a', 'b']); 208 | expect(element.formArray.status).toBe('INVALID'); 209 | expect(element.formArray.errors).toEqual(['a', 'b']); 210 | }) 211 | 212 | it('should have its own validators', () => { 213 | element.formArray.append(new FormControl(element, { defaultValue: '', validators: [PureValidators.required] })); 214 | expect(element.formArray.status).toBe('INVALID'); 215 | expect(element.formArray.errors).toEqual([]); 216 | element.formArray.setValidators([ 217 | c => c.controls.length < 2 ? 'minLength' : null 218 | ]); 219 | expect(element.formArray.status).toBe('INVALID'); 220 | expect(element.formArray.errors).toEqual(['minLength']); 221 | element.formArray.append(new FormControl(element, { defaultValue: '', validators: [PureValidators.required] })); 222 | expect(element.formArray.status).toBe('INVALID'); 223 | expect(element.formArray.errors).toEqual([]); 224 | element.formArray.set(['test', 'test']); 225 | expect(element.formArray.status).toBe('VALID'); 226 | }) 227 | 228 | it('should have its own asynchronous validators', async () => { 229 | element.formArray.append(new FormControl(element, { defaultValue: '', asyncValidators: [ c => Promise.resolve(c.value ? null : 'required') ] })); 230 | expect(element.formArray.status).toBe('PENDING'); 231 | await new Promise(resolve => setTimeout(resolve, 0)); 232 | expect(element.formArray.status).toBe('INVALID'); 233 | expect(element.formArray.errors).toEqual([]); 234 | element.formArray.setAsyncValidators([ 235 | c => Promise.resolve(c.controls.length < 2 ? 'minLength' : null) 236 | ]); 237 | // Invalid because of the child, but in reality it's pending 238 | expect(element.formArray.status).toBe('INVALID'); 239 | await new Promise(resolve => setTimeout(resolve, 0)); 240 | expect(element.formArray.errors).toEqual(['minLength']); 241 | element.formArray.append(new FormControl(element, { defaultValue: '', asyncValidators: [ c => Promise.resolve(c.value ? null : 'required') ] })); 242 | await new Promise(resolve => setTimeout(resolve, 0)); 243 | expect(element.formArray.status).toBe('INVALID'); 244 | expect(element.formArray.errors).toEqual([]); 245 | element.formArray.set(['test', 'test']); 246 | expect(element.formArray.status).toBe('PENDING'); 247 | await new Promise(resolve => setTimeout(resolve, 0)); 248 | expect(element.formArray.errors).toEqual([]); 249 | expect(element.formArray.status).toBe('VALID'); 250 | }) 251 | }) 252 | }) 253 | -------------------------------------------------------------------------------- /test/form-control.test.ts: -------------------------------------------------------------------------------- 1 | import type { IWindow } from 'happy-dom' 2 | import { beforeEach, describe, it, vi, expect, afterEach, SpyInstance } from 'vitest' 3 | import { FormControl, PureValidators } from '../src'; 4 | import { LitElement, html } from 'lit'; 5 | 6 | declare global { 7 | interface Window extends IWindow {} 8 | } 9 | 10 | export class MyElement extends LitElement { 11 | 12 | formControl = new FormControl(this, { 13 | defaultValue: 'test value', 14 | updateOn: 'input', 15 | }); 16 | 17 | render() { 18 | return html` 19 | 20 | ` 21 | } 22 | } 23 | customElements.define('my-element', MyElement); 24 | 25 | describe('FormControl', async () => { 26 | 27 | let element: MyElement; 28 | let requestUpdate: SpyInstance; 29 | 30 | beforeEach(async () => { 31 | document.body.innerHTML = '' 32 | await window.happyDOM.whenAsyncComplete() 33 | await new Promise(resolve => setTimeout(resolve, 0)); 34 | 35 | element = document.querySelector('my-element')!; 36 | requestUpdate = vi.spyOn(element as any, 'requestUpdate'); 37 | }) 38 | 39 | afterEach(() => { 40 | vi.restoreAllMocks() 41 | }) 42 | 43 | it('should have default value', () => { 44 | expect(element.formControl.value).toBe('test value'); 45 | }) 46 | 47 | it('should change value imperatively', () => { 48 | element.formControl.set('new value'); 49 | expect(element.formControl.value).toBe('new value'); 50 | expect(requestUpdate).toHaveBeenCalled(); 51 | }) 52 | 53 | it('should reset correctly', () => { 54 | element.formControl.set('new value'); 55 | element.formControl.setDirty(true); 56 | element.formControl.setTouched(true); 57 | element.formControl.setBlurred(true); 58 | element.formControl.reset(); 59 | 60 | expect(element.formControl.value).toBe('test value'); 61 | expect(element.formControl.isTouched).toBe(false); 62 | expect(element.formControl.isBlurred).toBe(false); 63 | expect(element.formControl.isDirty).toBe(false); 64 | element.formControl.reset(false); 65 | 66 | expect(element.formControl.value).toBe('test value'); 67 | element.formControl.setDirty(true); 68 | element.formControl.setTouched(true); 69 | element.formControl.setBlurred(true); 70 | expect(element.formControl.isTouched).toBe(true); 71 | expect(element.formControl.isBlurred).toBe(true); 72 | expect(element.formControl.isDirty).toBe(true); 73 | }) 74 | 75 | describe('Model to View & viceversa', () => { 76 | it('should update the DOM', () => { 77 | const input = element.shadowRoot!.querySelector('input')!; 78 | expect(input.value).toBe('test value'); 79 | element.formControl.set('new value'); 80 | expect(input.value).toBe('new value'); 81 | }) 82 | 83 | it('should reflect the DOM to the model', () => { 84 | const input = element.shadowRoot!.querySelector('input')!; 85 | expect(input.value).toBe('test value'); 86 | input.value = 'new value'; 87 | input.dispatchEvent(new Event('input')); 88 | expect(element.formControl.value).toBe('new value'); 89 | }) 90 | }) 91 | 92 | describe('Dirty, Touched, Blurred', () => { 93 | it('should initially be pristine, untouched, unblurred', () => { 94 | expect(element.formControl.isBlurred).toBe(false); 95 | expect(element.formControl.isDirty).toBe(false); 96 | expect(element.formControl.isTouched).toBe(false); 97 | }) 98 | 99 | it('should become touched on focus', () => { 100 | const input = element.shadowRoot!.querySelector('input')!; 101 | input.focus(); 102 | expect(element.formControl.isTouched).toBe(true); 103 | }) 104 | 105 | it('should become blurred on blur', () => { 106 | const input = element.shadowRoot!.querySelector('input')!; 107 | input.focus(); 108 | input.blur(); 109 | expect(element.formControl.isBlurred).toBe(true); 110 | }) 111 | 112 | it('should become dirty on input', () => { 113 | const input = element.shadowRoot!.querySelector('input')!; 114 | input.dispatchEvent(new Event('input')); 115 | expect(element.formControl.isDirty).toBe(true); 116 | }) 117 | 118 | it('should imperatively set dirty, touched, blurred', () => { 119 | element.formControl.setDirty(false); 120 | expect(element.formControl.isDirty).toBe(false); 121 | element.formControl.setDirty(true); 122 | expect(element.formControl.isDirty).toBe(true); 123 | 124 | element.formControl.setTouched(false); 125 | expect(element.formControl.isTouched).toBe(false); 126 | element.formControl.setTouched(true); 127 | expect(element.formControl.isTouched).toBe(true); 128 | 129 | element.formControl.setBlurred(false); 130 | expect(element.formControl.isBlurred).toBe(false); 131 | element.formControl.setBlurred(true); 132 | expect(element.formControl.isBlurred).toBe(true); 133 | }) 134 | }) 135 | 136 | describe('UI State', () => { 137 | it('should be ENABLED by default', () => { 138 | expect(element.formControl.uiState).toBe('ENABLED'); 139 | }) 140 | 141 | it('should set UI state', () => { 142 | const input = element.shadowRoot!.querySelector('input')!; 143 | 144 | element.formControl.setUIState('ENABLED'); 145 | expect(element.formControl.uiState).toBe('ENABLED'); 146 | expect(input.disabled).toBe(false); 147 | expect(input.readOnly).toBe(false); 148 | 149 | element.formControl.setUIState('DISABLED'); 150 | expect(element.formControl.uiState).toBe('DISABLED'); 151 | expect(input.disabled).toBe(true); 152 | expect(input.readOnly).toBe(false); 153 | 154 | element.formControl.setUIState('READONLY'); 155 | expect(element.formControl.uiState).toBe('READONLY'); 156 | expect(input.disabled).toBe(false); 157 | expect(input.readOnly).toBe(true); 158 | }) 159 | }) 160 | 161 | describe('Validators', () => { 162 | it('should set validators, valid and invalid states', () => { 163 | expect(element.formControl.status).toBe('VALID'); 164 | element.formControl.setValidators([PureValidators.required]); 165 | expect(element.formControl.status).toBe('VALID'); 166 | element.formControl.set(''); 167 | expect(element.formControl.status).toBe('INVALID'); 168 | expect(element.formControl.errors).toContain('required'); 169 | expect(element.formControl.errors.length).toBe(1); 170 | expect(element.formControl.hasError('required')).toBe(true); 171 | element.formControl.setValidators([]); 172 | expect(element.formControl.status).toBe('VALID'); 173 | expect(element.formControl.errors).not.toContain('required'); 174 | expect(element.formControl.errors.length).toBe(0); 175 | expect(element.formControl.hasError('required')).toBe(false); 176 | }) 177 | 178 | it('should set fixed errors', () => { 179 | expect(element.formControl.status).toBe('VALID'); 180 | element.formControl.setFixedErrors(['a', 'b', 'c']); 181 | expect(element.formControl.status).toBe('INVALID'); 182 | expect(element.formControl.errors).toContain('a'); 183 | expect(element.formControl.errors).toContain('b'); 184 | expect(element.formControl.errors).toContain('c'); 185 | expect(element.formControl.errors.length).toBe(3); 186 | expect(element.formControl.hasError('a')).toBe(true); 187 | expect(element.formControl.hasError('b')).toBe(true); 188 | expect(element.formControl.hasError('c')).toBe(true); 189 | element.formControl.setFixedErrors([]); 190 | expect(element.formControl.status).toBe('VALID'); 191 | expect(element.formControl.errors.length).toBe(0); 192 | }) 193 | }) 194 | 195 | describe('Asynchronous validators', () => { 196 | 197 | it('should set validators, pending and invalid states', async () => { 198 | element.formControl.set(''); 199 | expect(element.formControl.status).toBe('VALID'); 200 | element.formControl.setAsyncValidators([ 201 | c => Promise.resolve(!!c.value ? null : 'required'), 202 | c => Promise.resolve(c.value.length > 2 ? null : 'minLength'), 203 | ]); 204 | expect(element.formControl.status).toBe('PENDING'); 205 | expect(element.formControl.errors.length).toBe(0); 206 | await new Promise(resolve => setTimeout(resolve, 0)); 207 | expect(element.formControl.status).toBe('INVALID'); 208 | expect(element.formControl.hasError('required')).toBe(true); 209 | expect(element.formControl.hasError('minLength')).toBe(true); 210 | element.formControl.set('ne'); 211 | await new Promise(resolve => setTimeout(resolve, 0)); 212 | expect(element.formControl.hasError('required')).toBe(false); 213 | expect(element.formControl.hasError('minLength')).toBe(true); 214 | element.formControl.set('new value'); 215 | await new Promise(resolve => setTimeout(resolve, 0)); 216 | expect(element.formControl.hasError('required')).toBe(false); 217 | expect(element.formControl.hasError('minLength')).toBe(false); 218 | expect(element.formControl.status).toBe('VALID'); 219 | element.formControl.set(''); 220 | await new Promise(resolve => setTimeout(resolve, 0)); 221 | expect(element.formControl.hasError('required')).toBe(true); 222 | expect(element.formControl.hasError('minLength')).toBe(true); 223 | element.formControl.setAsyncValidators([ 224 | c => Promise.resolve(!!c.value ? null : 'required'), 225 | ]); 226 | expect(element.formControl.status).toBe('PENDING'); 227 | await new Promise(resolve => setTimeout(resolve, 0)); 228 | expect(element.formControl.status).toBe('INVALID'); 229 | expect(element.formControl.errors.length).toBe(1); 230 | element.formControl.setAsyncValidators([]); 231 | expect(element.formControl.status).toBe('VALID'); 232 | }) 233 | }) 234 | }) 235 | -------------------------------------------------------------------------------- /test/form-group.test.ts: -------------------------------------------------------------------------------- 1 | import type { IWindow } from 'happy-dom' 2 | import { beforeEach, describe, it, vi, expect, afterEach } from 'vitest' 3 | import { FormControl, FormGroup, PureValidators } from '../src'; 4 | import { LitElement, html, nothing } from 'lit'; 5 | 6 | declare global { 7 | interface Window extends IWindow {} 8 | } 9 | 10 | export class MyElement extends LitElement { 11 | 12 | formGroup = new FormGroup(this, { 13 | name: new FormControl(this, { defaultValue: 'name' }), 14 | surname: new FormControl(this, { defaultValue: 'surname' }), 15 | }); 16 | 17 | render() { 18 | return html` 19 | 20 | 21 | ` 22 | } 23 | } 24 | customElements.define('my-element', MyElement); 25 | 26 | describe('FormGroup', async () => { 27 | 28 | let element: MyElement; 29 | 30 | beforeEach(async () => { 31 | document.body.innerHTML = '' 32 | await window.happyDOM.whenAsyncComplete() 33 | await new Promise(resolve => setTimeout(resolve, 0)); 34 | 35 | element = document.querySelector('my-element')!; 36 | }) 37 | 38 | afterEach(() => { 39 | vi.restoreAllMocks() 40 | }) 41 | 42 | it('should get an existing child', () => { 43 | expect(element.formGroup.get('name')).toBeDefined(); 44 | expect(element.formGroup.get('surname')).toBeDefined(); 45 | expect(element.formGroup.get('test' as any)).toBeUndefined(); 46 | }) 47 | 48 | it('should bind to an existing child', () => { 49 | expect(element.formGroup.bind('name')).toBeDefined() 50 | expect(element.formGroup.bind('surname')).toBeDefined() 51 | expect(element.formGroup.bind('test')).toBe(nothing); 52 | }) 53 | 54 | it('should have the whole value', () => { 55 | expect(element.formGroup.value).toEqual({ 56 | name: 'name', 57 | surname: 'surname' 58 | }); 59 | element.formGroup.get('name').set('test'); 60 | expect(element.formGroup.value).toEqual({ 61 | name: 'test', 62 | surname: 'surname' 63 | }); 64 | }) 65 | 66 | it('should skip disabled values in enabledValue', () => { 67 | expect(element.formGroup.enabledValue).toEqual({ 68 | name: 'name', 69 | surname: 'surname' 70 | }); 71 | element.formGroup.get('name').setUIState('DISABLED'); 72 | expect(element.formGroup.enabledValue).toEqual({ 73 | surname: 'surname' 74 | }); 75 | element.formGroup.get('surname').setUIState('DISABLED'); 76 | expect(element.formGroup.enabledValue).toEqual({}); 77 | element.formGroup.get('name').setUIState('ENABLED'); 78 | element.formGroup.get('surname').setUIState('ENABLED'); 79 | expect(element.formGroup.enabledValue).toEqual({ 80 | name: 'name', 81 | surname: 'surname' 82 | }); 83 | }) 84 | 85 | it('should set and patch children', () => { 86 | element.formGroup.set({ name: 'a', surname: 'b' }); 87 | expect(element.formGroup.get('name').value).toBe('a'); 88 | expect(element.formGroup.get('surname').value).toBe('b'); 89 | element.formGroup.patch({ name: 'c' }); 90 | expect(element.formGroup.get('name').value).toBe('c'); 91 | expect(element.formGroup.get('surname').value).toBe('b'); 92 | }) 93 | 94 | it('should reset all children', () => { 95 | element.formGroup.set({ name: 'a', surname: 'b' }); 96 | element.formGroup.reset(); 97 | expect(element.formGroup.value).toEqual({ name: 'name', surname: 'surname' }); 98 | element.formGroup.set({ name: 'a', surname: 'b' }); 99 | element.formGroup.get('name').setDirty(true); 100 | element.formGroup.get('name').setTouched(true); 101 | element.formGroup.get('name').setBlurred(true); 102 | element.formGroup.reset(false); 103 | expect(element.formGroup.value).toEqual({ name: 'name', surname: 'surname' }); 104 | expect(element.formGroup.get('name').isDirty).toBe(true); 105 | expect(element.formGroup.get('name').isTouched).toBe(true); 106 | expect(element.formGroup.get('name').isBlurred).toBe(true); 107 | }) 108 | 109 | describe('Dirty, Touched, Blurred', () => { 110 | 111 | it('should be dirty if at least 1 child is dirty', () => { 112 | element.formGroup.get('name').setDirty(false); 113 | element.formGroup.get('surname').setDirty(false); 114 | expect(element.formGroup.isDirty).toBe(false); 115 | element.formGroup.get('name').setDirty(true); 116 | expect(element.formGroup.isDirty).toBe(true); 117 | }) 118 | 119 | it('should be touched if at least 1 child is touched', () => { 120 | element.formGroup.get('name').setTouched(false); 121 | element.formGroup.get('surname').setTouched(false); 122 | expect(element.formGroup.isTouched).toBe(false); 123 | element.formGroup.get('name').setTouched(true); 124 | expect(element.formGroup.isTouched).toBe(true); 125 | }) 126 | 127 | it('should be blurred if at least 1 child is blurred', () => { 128 | element.formGroup.get('name').setBlurred(false); 129 | element.formGroup.get('surname').setBlurred(false); 130 | expect(element.formGroup.isBlurred).toBe(false); 131 | element.formGroup.get('name').setBlurred(true); 132 | expect(element.formGroup.isBlurred).toBe(true); 133 | }) 134 | }) 135 | 136 | describe('Validation', () => { 137 | 138 | it('should be VALID if all children are VALID, INVALID if at least 1 is INVALID', () => { 139 | element.formGroup.get('name').setFixedErrors(['a']); 140 | element.formGroup.get('surname').setFixedErrors(['b']); 141 | expect(element.formGroup.status).toBe('INVALID'); 142 | expect(element.formGroup.errors).toEqual([]); 143 | element.formGroup.get('name').setFixedErrors([]); 144 | expect(element.formGroup.status).toBe('INVALID'); 145 | element.formGroup.get('surname').setFixedErrors([]); 146 | expect(element.formGroup.status).toBe('VALID'); 147 | }) 148 | 149 | it('should be PENDING if at least 1 child is PENDING and others are VALID', async () => { 150 | element.formGroup.get('name').setAsyncValidators([ 151 | c => Promise.resolve(c.value ? null : 'required') 152 | ]); 153 | expect(element.formGroup.status).toBe('PENDING'); 154 | await new Promise(resolve => setTimeout(resolve, 0)); 155 | expect(element.formGroup.status).toBe('VALID'); 156 | // The second field is now INVALID, we don't expect a PENDING state 157 | element.formGroup.get('surname').setValidators([ 158 | () => 'error' 159 | ]); 160 | element.formGroup.patch({ name: 'new name' }); 161 | expect(element.formGroup.status).toBe('INVALID'); 162 | }) 163 | 164 | it('should set fixed errors', () => { 165 | element.formGroup.setFixedErrors(['a', 'b']); 166 | expect(element.formGroup.errors).toEqual(['a', 'b']); 167 | }) 168 | 169 | it('should have its own validators', () => { 170 | element.formGroup.set({ name: '', surname: '' }); 171 | element.formGroup.get('name').setValidators([PureValidators.required]); 172 | expect(element.formGroup.status).toBe('INVALID'); 173 | expect(element.formGroup.errors).toEqual([]); 174 | element.formGroup.setValidators([ 175 | c => c.get('name').value.length < 2 ? 'minLength' : null 176 | ]); 177 | expect(element.formGroup.status).toBe('INVALID'); 178 | expect(element.formGroup.errors).toEqual(['minLength']); 179 | element.formGroup.get('surname').setValidators([PureValidators.required]); 180 | expect(element.formGroup.status).toBe('INVALID'); 181 | expect(element.formGroup.errors).toEqual(['minLength']); 182 | element.formGroup.set({ name: 'test', surname: 'test' }); 183 | expect(element.formGroup.status).toBe('VALID'); 184 | }) 185 | 186 | it('should have its own asynchronous validators', async () => { 187 | element.formGroup.set({ name: '', surname: '' }); 188 | element.formGroup.get('name').setAsyncValidators([ c => Promise.resolve(c.value ? null : 'required') ]); 189 | expect(element.formGroup.status).toBe('PENDING'); 190 | await new Promise(resolve => setTimeout(resolve, 0)); 191 | expect(element.formGroup.status).toBe('INVALID'); 192 | expect(element.formGroup.errors).toEqual([]); 193 | element.formGroup.setAsyncValidators([ 194 | c => Promise.resolve(c.get('name').value.length < 2 ? 'minLength' : null) 195 | ]); 196 | // Invalid because of the child, but in reality it's pending 197 | expect(element.formGroup.status).toBe('INVALID'); 198 | await new Promise(resolve => setTimeout(resolve, 0)); 199 | expect(element.formGroup.errors).toEqual(['minLength']); 200 | element.formGroup.get('surname').setAsyncValidators([ c => Promise.resolve(c.value ? null : 'required') ]); 201 | await new Promise(resolve => setTimeout(resolve, 0)); 202 | expect(element.formGroup.status).toBe('INVALID'); 203 | expect(element.formGroup.errors).toEqual(['minLength']); 204 | element.formGroup.set({ name: 'test', surname: 'test' }); 205 | expect(element.formGroup.get('name').status).toBe('PENDING'); 206 | expect(element.formGroup.get('surname').status).toBe('PENDING'); 207 | await new Promise(resolve => setTimeout(resolve, 0)); 208 | expect(element.formGroup.status).toBe('VALID'); 209 | expect(element.formGroup.errors).toEqual([]); 210 | }) 211 | }) 212 | }) 213 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "lib": ["es2017", "dom", "dom.iterable"], 5 | "declaration": true, 6 | "emitDeclarationOnly": true, 7 | "outDir": "./types", 8 | "strict": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "moduleResolution": "node", 14 | "allowSyntheticDefaultImports": true, 15 | "experimentalDecorators": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "useDefineForClassFields": false, 18 | "target": "es5" 19 | }, 20 | "include": ["src/**/*.ts"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | esbuild: { 7 | minify: true 8 | }, 9 | build: { 10 | lib: { 11 | entry: 'src/index.ts', 12 | formats: ['es'], 13 | name: 'LitReactiveForms' 14 | }, 15 | rollupOptions: { 16 | external: ['lit', 'lit-html', 'rxjs'] 17 | } 18 | }, 19 | test: { 20 | globals: true, 21 | environment: 'happy-dom', 22 | }, 23 | }) 24 | --------------------------------------------------------------------------------