@relax.js/core 1.0.3 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -188
- package/dist/DependencyInjection.d.ts +42 -24
- package/dist/di/index.js +1 -1
- package/dist/di/index.js.map +3 -3
- package/dist/di/index.mjs +1 -1
- package/dist/di/index.mjs.map +3 -3
- package/dist/errors.d.ts +20 -0
- package/dist/forms/FormValidator.d.ts +1 -20
- package/dist/forms/ValidationRules.d.ts +2 -0
- package/dist/forms/index.js +1 -1
- package/dist/forms/index.js.map +4 -4
- package/dist/forms/index.mjs +1 -1
- package/dist/forms/index.mjs.map +4 -4
- package/dist/html/TableRenderer.d.ts +1 -0
- package/dist/html/index.js.map +2 -2
- package/dist/html/index.mjs.map +2 -2
- package/dist/html/template.d.ts +4 -0
- package/dist/http/http.d.ts +1 -0
- package/dist/http/index.js.map +2 -2
- package/dist/http/index.mjs.map +2 -2
- package/dist/index.js +3 -3
- package/dist/index.js.map +3 -3
- package/dist/index.mjs +3 -3
- package/dist/index.mjs.map +3 -3
- package/dist/routing/index.js +3 -3
- package/dist/routing/index.js.map +3 -3
- package/dist/routing/index.mjs +3 -3
- package/dist/routing/index.mjs.map +3 -3
- package/dist/routing/routeTargetRegistry.d.ts +1 -0
- package/dist/routing/types.d.ts +2 -1
- package/dist/templates/NodeTemplate.d.ts +2 -0
- package/dist/utils/index.js +1 -1
- package/dist/utils/index.js.map +2 -2
- package/dist/utils/index.mjs +1 -1
- package/dist/utils/index.mjs.map +2 -2
- package/docs/Architecture.md +333 -333
- package/docs/DependencyInjection.md +277 -237
- package/docs/Errors.md +87 -87
- package/docs/GettingStarted.md +231 -231
- package/docs/Pipes.md +5 -5
- package/docs/Translations.md +167 -312
- package/docs/WhyRelaxjs.md +336 -336
- package/docs/api/.nojekyll +1 -0
- package/docs/api/assets/hierarchy.js +1 -0
- package/docs/api/assets/highlight.css +120 -0
- package/docs/api/assets/icons.js +18 -0
- package/docs/api/assets/icons.svg +1 -0
- package/docs/api/assets/main.js +60 -0
- package/docs/api/assets/navigation.js +1 -0
- package/docs/api/assets/search.js +1 -0
- package/docs/api/assets/style.css +1633 -0
- package/docs/api/classes/http.WebSocketClient.html +26 -0
- package/docs/api/classes/i18n.LocaleChangeEvent.html +66 -0
- package/docs/api/classes/index.Blueprint.html +3 -0
- package/docs/api/classes/index.BoundNode.html +3 -0
- package/docs/api/classes/index.DigitsValidation.html +10 -0
- package/docs/api/classes/index.FormValidator.html +32 -0
- package/docs/api/classes/index.HttpError.html +13 -0
- package/docs/api/classes/index.LinkedList.html +26 -0
- package/docs/api/classes/index.NavigateRouteEvent.html +76 -0
- package/docs/api/classes/index.Node.html +15 -0
- package/docs/api/classes/index.PageSelectedEvent.html +61 -0
- package/docs/api/classes/index.Pager.html +4 -0
- package/docs/api/classes/index.RangeValidation.html +15 -0
- package/docs/api/classes/index.RelaxError.html +17 -0
- package/docs/api/classes/index.RequiredValidation.html +10 -0
- package/docs/api/classes/index.RouteError.html +11 -0
- package/docs/api/classes/index.RouteGuardError.html +12 -0
- package/docs/api/classes/index.RouteLink.html +779 -0
- package/docs/api/classes/index.RouteTarget.html +788 -0
- package/docs/api/classes/index.SSEClient.html +13 -0
- package/docs/api/classes/index.SSEDataEvent.html +63 -0
- package/docs/api/classes/index.ServiceCollection.html +28 -0
- package/docs/api/classes/index.ServiceContainer.html +24 -0
- package/docs/api/classes/index.SortChangeEvent.html +61 -0
- package/docs/api/classes/index.TableRenderer.html +5 -0
- package/docs/api/classes/index.TableSorter.html +4 -0
- package/docs/api/enums/index.GuardResult.html +9 -0
- package/docs/api/functions/elements.formError.html +6 -0
- package/docs/api/functions/elements.selectOne.html +6 -0
- package/docs/api/functions/i18n.getCurrentLocale.html +3 -0
- package/docs/api/functions/i18n.loadNamespace.html +7 -0
- package/docs/api/functions/i18n.loadNamespaces.html +6 -0
- package/docs/api/functions/i18n.onMissingTranslation.html +7 -0
- package/docs/api/functions/i18n.setLocale.html +7 -0
- package/docs/api/functions/i18n.setMessageFormatter.html +7 -0
- package/docs/api/functions/i18n.t.html +9 -0
- package/docs/api/functions/index.BooleanConverter.html +6 -0
- package/docs/api/functions/index.ContainerService.html +13 -0
- package/docs/api/functions/index.DateConverter.html +11 -0
- package/docs/api/functions/index.Inject.html +16 -0
- package/docs/api/functions/index.NumberConverter.html +5 -0
- package/docs/api/functions/index.RegisterValidator.html +7 -0
- package/docs/api/functions/index.applyPipes.html +17 -0
- package/docs/api/functions/index.asyncHandler.html +11 -0
- package/docs/api/functions/index.capitalizePipe.html +4 -0
- package/docs/api/functions/index.clearPendingNavigations.html +1 -0
- package/docs/api/functions/index.compileTemplate.html +26 -0
- package/docs/api/functions/index.configure.html +5 -0
- package/docs/api/functions/index.createBluePrint.html +1 -0
- package/docs/api/functions/index.createConverterFromDataType.html +4 -0
- package/docs/api/functions/index.createConverterFromInputType.html +5 -0
- package/docs/api/functions/index.createPipeRegistry.html +12 -0
- package/docs/api/functions/index.currencyPipe.html +9 -0
- package/docs/api/functions/index.datePipe.html +9 -0
- package/docs/api/functions/index.daysAgoPipe.html +8 -0
- package/docs/api/functions/index.defaultPipe.html +5 -0
- package/docs/api/functions/index.defineRoutes.html +8 -0
- package/docs/api/functions/index.del.html +8 -0
- package/docs/api/functions/index.findRouteByName.html +5 -0
- package/docs/api/functions/index.findRouteByUrl.html +4 -0
- package/docs/api/functions/index.firstPipe.html +4 -0
- package/docs/api/functions/index.generateSequentialId.html +21 -0
- package/docs/api/functions/index.get.html +9 -0
- package/docs/api/functions/index.getDataConverter.html +11 -0
- package/docs/api/functions/index.getParentComponent.html +18 -0
- package/docs/api/functions/index.getValidator.html +4 -0
- package/docs/api/functions/index.html.html +19 -0
- package/docs/api/functions/index.joinPipe.html +5 -0
- package/docs/api/functions/index.keysPipe.html +4 -0
- package/docs/api/functions/index.lastPipe.html +4 -0
- package/docs/api/functions/index.lowercasePipe.html +4 -0
- package/docs/api/functions/index.mapFormToClass.html +17 -0
- package/docs/api/functions/index.matchRoute.html +5 -0
- package/docs/api/functions/index.navigate.html +8 -0
- package/docs/api/functions/index.onError.html +8 -0
- package/docs/api/functions/index.piecesPipe.html +8 -0
- package/docs/api/functions/index.post.html +9 -0
- package/docs/api/functions/index.printRoutes.html +2 -0
- package/docs/api/functions/index.put.html +9 -0
- package/docs/api/functions/index.readData.html +17 -0
- package/docs/api/functions/index.registerRouteTarget.html +9 -0
- package/docs/api/functions/index.reportError.html +10 -0
- package/docs/api/functions/index.request.html +8 -0
- package/docs/api/functions/index.resolveValue.html +18 -0
- package/docs/api/functions/index.setFetch.html +6 -0
- package/docs/api/functions/index.setFormData.html +17 -0
- package/docs/api/functions/index.shortenPipe.html +5 -0
- package/docs/api/functions/index.startRouting.html +6 -0
- package/docs/api/functions/index.ternaryPipe.html +6 -0
- package/docs/api/functions/index.trimPipe.html +4 -0
- package/docs/api/functions/index.unregisterRouteTarget.html +3 -0
- package/docs/api/functions/index.uppercasePipe.html +4 -0
- package/docs/api/hierarchy.html +1 -0
- package/docs/api/index.html +323 -0
- package/docs/api/interfaces/http.SimpleDataEvent.html +3 -0
- package/docs/api/interfaces/http.WebSocketAbstraction.html +9 -0
- package/docs/api/interfaces/http.WebSocketCodec.html +4 -0
- package/docs/api/interfaces/http.WebSocketOptions.html +20 -0
- package/docs/api/interfaces/index.CompiledTemplate.html +10 -0
- package/docs/api/interfaces/index.DataLoader.html +19 -0
- package/docs/api/interfaces/index.EngineConfig.html +11 -0
- package/docs/api/interfaces/index.ErrorContext.html +4 -0
- package/docs/api/interfaces/index.FormReaderOptions.html +8 -0
- package/docs/api/interfaces/index.HttpOptions.html +16 -0
- package/docs/api/interfaces/index.HttpResponse.html +17 -0
- package/docs/api/interfaces/index.LoadRoute.html +7 -0
- package/docs/api/interfaces/index.NavigateOptions.html +7 -0
- package/docs/api/interfaces/index.PipeRegistry.html +12 -0
- package/docs/api/interfaces/index.RegistrationOptions.html +22 -0
- package/docs/api/interfaces/index.RenderTemplate.html +7 -0
- package/docs/api/interfaces/index.RequestOptions.html +11 -0
- package/docs/api/interfaces/index.Routable.html +10 -0
- package/docs/api/interfaces/index.Route.html +13 -0
- package/docs/api/interfaces/index.RouteGuard.html +2 -0
- package/docs/api/interfaces/index.RouteValue.html +6 -0
- package/docs/api/interfaces/index.SSEOptions.html +24 -0
- package/docs/api/interfaces/index.ValidationContext.html +8 -0
- package/docs/api/interfaces/index.ValidatorOptions.html +14 -0
- package/docs/api/media/Architecture.md +333 -0
- package/docs/api/media/DependencyInjection.md +277 -0
- package/docs/api/media/GettingStarted.md +231 -0
- package/docs/api/media/HttpClient.md +459 -0
- package/docs/api/media/Pipes.md +211 -0
- package/docs/api/media/Routing.md +332 -0
- package/docs/api/media/WhyRelaxjs.md +336 -0
- package/docs/api/media/forms.md +99 -0
- package/docs/api/media/html.md +175 -0
- package/docs/api/media/i18n.md +354 -0
- package/docs/api/media/utilities.md +143 -0
- package/docs/api/media/validation.md +351 -0
- package/docs/api/modules/collections_Index.html +1 -0
- package/docs/api/modules/di.html +1 -0
- package/docs/api/modules/elements.html +1 -0
- package/docs/api/modules/forms.html +1 -0
- package/docs/api/modules/html.html +1 -0
- package/docs/api/modules/http.html +1 -0
- package/docs/api/modules/i18n.html +1 -0
- package/docs/api/modules/index.html +1 -0
- package/docs/api/modules/routing.html +1 -0
- package/docs/api/modules/utils.html +1 -0
- package/docs/api/modules.html +1 -0
- package/docs/api/types/http.WebSocketFactory.html +2 -0
- package/docs/api/types/i18n.MessageFormatter.html +3 -0
- package/docs/api/types/i18n.MissingTranslationHandler.html +1 -0
- package/docs/api/types/index.Constructor.html +7 -0
- package/docs/api/types/index.ConverterFunc.html +2 -0
- package/docs/api/types/index.DataType.html +2 -0
- package/docs/api/types/index.InputType.html +2 -0
- package/docs/api/types/index.PipeFunction.html +6 -0
- package/docs/api/types/index.RouteData.html +1 -0
- package/docs/api/types/index.RouteMatchResult.html +9 -0
- package/docs/api/types/index.RouteParamType.html +1 -0
- package/docs/api/types/index.RouteSegmentType.html +2 -0
- package/docs/api/types/index.SSEEventFactory.html +5 -0
- package/docs/api/types/index.ServiceScope.html +10 -0
- package/docs/api/types/index.SortColumn.html +3 -0
- package/docs/api/variables/i18n.formatICU.html +3 -0
- package/docs/api/variables/index.container.html +6 -0
- package/docs/api/variables/index.defaultPipes.html +6 -0
- package/docs/api/variables/index.internalRoutes.html +1 -0
- package/docs/api/variables/index.serviceCollection.html +6 -0
- package/docs/api.json +93171 -0
- package/docs/elements/dom.md +102 -102
- package/docs/forms/creating-form-components.md +924 -924
- package/docs/forms/form-api.md +94 -94
- package/docs/forms/forms.md +99 -99
- package/docs/forms/patterns.md +311 -311
- package/docs/forms/reading-writing.md +365 -365
- package/docs/forms/validation.md +351 -351
- package/docs/html/TableRenderer.md +291 -291
- package/docs/html/html.md +175 -175
- package/docs/html/index.md +54 -54
- package/docs/html/template.md +422 -422
- package/docs/http/HttpClient.md +459 -459
- package/docs/http/ServerSentEvents.md +184 -184
- package/docs/http/index.md +109 -109
- package/docs/i18n/i18n.md +49 -4
- package/docs/i18n/intl-standard.md +178 -178
- package/docs/routing/RouteLink.md +98 -98
- package/docs/routing/Routing.md +332 -332
- package/docs/routing/layouts.md +207 -207
- package/docs/utilities.md +143 -143
- package/package.json +4 -3
|
@@ -1,924 +1,924 @@
|
|
|
1
|
-
# Creating Custom Form Components
|
|
2
|
-
|
|
3
|
-
This guide explains how to create custom web components that integrate with the RelaxJS form system using the HTML Form API (form-associated custom elements).
|
|
4
|
-
|
|
5
|
-
## Overview
|
|
6
|
-
|
|
7
|
-
Form-associated custom elements allow your web components to:
|
|
8
|
-
|
|
9
|
-
- Participate in form submission via `FormData`
|
|
10
|
-
- Work with `readData()`, `setFormData()`, and `mapFormToClass()`
|
|
11
|
-
- Support native form validation with `FormValidator`
|
|
12
|
-
- Be accessed via `form.elements`
|
|
13
|
-
- Reset with the form
|
|
14
|
-
|
|
15
|
-
## Basic Structure
|
|
16
|
-
|
|
17
|
-
```typescript
|
|
18
|
-
class RInput extends HTMLElement {
|
|
19
|
-
static formAssociated = true;
|
|
20
|
-
|
|
21
|
-
private internals: ElementInternals;
|
|
22
|
-
private input: HTMLInputElement;
|
|
23
|
-
|
|
24
|
-
constructor() {
|
|
25
|
-
super();
|
|
26
|
-
this.internals = this.attachInternals();
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
connectedCallback() {
|
|
30
|
-
this.render();
|
|
31
|
-
this.setupEventListeners();
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
private render() {
|
|
35
|
-
this.innerHTML = `<input type="text">`;
|
|
36
|
-
this.input = this.querySelector('input');
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
private setupEventListeners() {
|
|
40
|
-
this.input.addEventListener('input', () => {
|
|
41
|
-
this.internals.setFormValue(this.input.value);
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
get value(): string {
|
|
46
|
-
return this.input?.value ?? '';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
set value(val: string) {
|
|
50
|
-
if (this.input) {
|
|
51
|
-
this.input.value = val;
|
|
52
|
-
this.internals.setFormValue(val);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
get name(): string {
|
|
57
|
-
return this.getAttribute('name') ?? '';
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
set name(val: string) {
|
|
61
|
-
this.setAttribute('name', val);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
customElements.define('r-input', RInput);
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
## Required Properties
|
|
69
|
-
|
|
70
|
-
### formAssociated
|
|
71
|
-
|
|
72
|
-
Static property that enables form association:
|
|
73
|
-
|
|
74
|
-
```typescript
|
|
75
|
-
class MyComponent extends HTMLElement {
|
|
76
|
-
static formAssociated = true;
|
|
77
|
-
// ...
|
|
78
|
-
}
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
### ElementInternals
|
|
82
|
-
|
|
83
|
-
Provides the bridge to the form:
|
|
84
|
-
|
|
85
|
-
```typescript
|
|
86
|
-
private internals: ElementInternals;
|
|
87
|
-
|
|
88
|
-
constructor() {
|
|
89
|
-
super();
|
|
90
|
-
this.internals = this.attachInternals();
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
## Core Form Properties
|
|
95
|
-
|
|
96
|
-
Implement these properties for full form integration:
|
|
97
|
-
|
|
98
|
-
### name
|
|
99
|
-
|
|
100
|
-
The field name used in form data:
|
|
101
|
-
|
|
102
|
-
```typescript
|
|
103
|
-
get name(): string {
|
|
104
|
-
return this.getAttribute('name') ?? '';
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
set name(val: string) {
|
|
108
|
-
this.setAttribute('name', val);
|
|
109
|
-
}
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
### value
|
|
113
|
-
|
|
114
|
-
The current value:
|
|
115
|
-
|
|
116
|
-
```typescript
|
|
117
|
-
get value(): string {
|
|
118
|
-
return this._value;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
set value(val: string) {
|
|
122
|
-
this._value = val;
|
|
123
|
-
this.internals.setFormValue(val);
|
|
124
|
-
this.updateDisplay();
|
|
125
|
-
}
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### disabled
|
|
129
|
-
|
|
130
|
-
Prevents interaction and excludes from form data:
|
|
131
|
-
|
|
132
|
-
```typescript
|
|
133
|
-
get disabled(): boolean {
|
|
134
|
-
return this.hasAttribute('disabled');
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
set disabled(val: boolean) {
|
|
138
|
-
if (val) {
|
|
139
|
-
this.setAttribute('disabled', '');
|
|
140
|
-
} else {
|
|
141
|
-
this.removeAttribute('disabled');
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
### required
|
|
147
|
-
|
|
148
|
-
Marks field as required for validation:
|
|
149
|
-
|
|
150
|
-
```typescript
|
|
151
|
-
get required(): boolean {
|
|
152
|
-
return this.hasAttribute('required');
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
set required(val: boolean) {
|
|
156
|
-
if (val) {
|
|
157
|
-
this.setAttribute('required', '');
|
|
158
|
-
} else {
|
|
159
|
-
this.removeAttribute('required');
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
```
|
|
163
|
-
|
|
164
|
-
## Type Conversion with dataType
|
|
165
|
-
|
|
166
|
-
The `dataType` property allows custom type conversion when using `readData()`:
|
|
167
|
-
|
|
168
|
-
```typescript
|
|
169
|
-
get dataType(): string | ((value: string) => unknown) {
|
|
170
|
-
return this.getAttribute('data-type') ?? 'string';
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
set dataType(val: string | ((value: string) => unknown)) {
|
|
174
|
-
if (typeof val === 'function') {
|
|
175
|
-
(this as any)._dataTypeConverter = val;
|
|
176
|
-
} else {
|
|
177
|
-
this.setAttribute('data-type', val);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
```
|
|
181
|
-
|
|
182
|
-
Built-in data types: `'string'`, `'number'`, `'boolean'`, `'Date'`
|
|
183
|
-
|
|
184
|
-
## Validation
|
|
185
|
-
|
|
186
|
-
### Native Validation
|
|
187
|
-
|
|
188
|
-
Use `setValidity()` for HTML5 validation integration:
|
|
189
|
-
|
|
190
|
-
```typescript
|
|
191
|
-
private validate(): void {
|
|
192
|
-
if (this.required && !this.value) {
|
|
193
|
-
this.internals.setValidity(
|
|
194
|
-
{ valueMissing: true },
|
|
195
|
-
'This field is required',
|
|
196
|
-
this.input
|
|
197
|
-
);
|
|
198
|
-
} else if (this.pattern && !new RegExp(this.pattern).test(this.value)) {
|
|
199
|
-
this.internals.setValidity(
|
|
200
|
-
{ patternMismatch: true },
|
|
201
|
-
'Please match the requested format',
|
|
202
|
-
this.input
|
|
203
|
-
);
|
|
204
|
-
} else {
|
|
205
|
-
this.internals.setValidity({});
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
### Validation Flags
|
|
211
|
-
|
|
212
|
-
Available `ValidityStateFlags`:
|
|
213
|
-
|
|
214
|
-
| Flag | Description |
|
|
215
|
-
|------|-------------|
|
|
216
|
-
| `valueMissing` | Required field is empty |
|
|
217
|
-
| `typeMismatch` | Value doesn't match input type |
|
|
218
|
-
| `patternMismatch` | Value doesn't match pattern |
|
|
219
|
-
| `tooLong` | Value exceeds maxlength |
|
|
220
|
-
| `tooShort` | Value is shorter than minlength |
|
|
221
|
-
| `rangeUnderflow` | Value is below min |
|
|
222
|
-
| `rangeOverflow` | Value exceeds max |
|
|
223
|
-
| `stepMismatch` | Value doesn't match step |
|
|
224
|
-
| `badInput` | Browser can't convert input |
|
|
225
|
-
| `customError` | Custom validation failed |
|
|
226
|
-
|
|
227
|
-
### Custom Validation Messages
|
|
228
|
-
|
|
229
|
-
```typescript
|
|
230
|
-
private validate(): void {
|
|
231
|
-
const value = this.value;
|
|
232
|
-
|
|
233
|
-
if (this.required && !value) {
|
|
234
|
-
this.internals.setValidity(
|
|
235
|
-
{ valueMissing: true },
|
|
236
|
-
'Please fill out this field',
|
|
237
|
-
this.input
|
|
238
|
-
);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
if (this.minLength && value.length < this.minLength) {
|
|
243
|
-
this.internals.setValidity(
|
|
244
|
-
{ tooShort: true },
|
|
245
|
-
`Please enter at least ${this.minLength} characters`,
|
|
246
|
-
this.input
|
|
247
|
-
);
|
|
248
|
-
return;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Custom business logic
|
|
252
|
-
if (this.getAttribute('data-type') === 'email' && !this.isValidEmail(value)) {
|
|
253
|
-
this.internals.setValidity(
|
|
254
|
-
{ typeMismatch: true },
|
|
255
|
-
'Please enter a valid email address',
|
|
256
|
-
this.input
|
|
257
|
-
);
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
this.internals.setValidity({});
|
|
262
|
-
}
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
## Form Callbacks
|
|
266
|
-
|
|
267
|
-
### formAssociatedCallback
|
|
268
|
-
|
|
269
|
-
Called when the element is associated with a form:
|
|
270
|
-
|
|
271
|
-
```typescript
|
|
272
|
-
formAssociatedCallback(form: HTMLFormElement | null): void {
|
|
273
|
-
if (form) {
|
|
274
|
-
console.log('Associated with form:', form.id);
|
|
275
|
-
} else {
|
|
276
|
-
console.log('Disassociated from form');
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### formResetCallback
|
|
282
|
-
|
|
283
|
-
Called when the form is reset:
|
|
284
|
-
|
|
285
|
-
```typescript
|
|
286
|
-
formResetCallback(): void {
|
|
287
|
-
this.value = this.getAttribute('value') ?? '';
|
|
288
|
-
}
|
|
289
|
-
```
|
|
290
|
-
|
|
291
|
-
### formDisabledCallback
|
|
292
|
-
|
|
293
|
-
Called when the element's disabled state changes due to the form:
|
|
294
|
-
|
|
295
|
-
```typescript
|
|
296
|
-
formDisabledCallback(disabled: boolean): void {
|
|
297
|
-
this.input.disabled = disabled;
|
|
298
|
-
}
|
|
299
|
-
```
|
|
300
|
-
|
|
301
|
-
### formStateRestoreCallback
|
|
302
|
-
|
|
303
|
-
Called during form restoration (browser back/forward):
|
|
304
|
-
|
|
305
|
-
```typescript
|
|
306
|
-
formStateRestoreCallback(state: string, mode: 'restore' | 'autocomplete'): void {
|
|
307
|
-
this.value = state;
|
|
308
|
-
}
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
## Complete Example: Text Input
|
|
312
|
-
|
|
313
|
-
```typescript
|
|
314
|
-
class RInput extends HTMLElement {
|
|
315
|
-
static formAssociated = true;
|
|
316
|
-
static observedAttributes = ['value', 'disabled', 'required', 'placeholder', 'type'];
|
|
317
|
-
|
|
318
|
-
private internals: ElementInternals;
|
|
319
|
-
private input: HTMLInputElement;
|
|
320
|
-
|
|
321
|
-
constructor() {
|
|
322
|
-
super();
|
|
323
|
-
this.internals = this.attachInternals();
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
connectedCallback(): void {
|
|
327
|
-
this.render();
|
|
328
|
-
this.setupEventListeners();
|
|
329
|
-
this.validate();
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
private render(): void {
|
|
333
|
-
const type = this.getAttribute('type') ?? 'text';
|
|
334
|
-
const placeholder = this.getAttribute('placeholder') ?? '';
|
|
335
|
-
const value = this.getAttribute('value') ?? '';
|
|
336
|
-
|
|
337
|
-
this.innerHTML = `
|
|
338
|
-
<input
|
|
339
|
-
type="${type}"
|
|
340
|
-
placeholder="${placeholder}"
|
|
341
|
-
value="${value}"
|
|
342
|
-
${this.disabled ? 'disabled' : ''}
|
|
343
|
-
>
|
|
344
|
-
`;
|
|
345
|
-
this.input = this.querySelector('input');
|
|
346
|
-
this.internals.setFormValue(value);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
private setupEventListeners(): void {
|
|
350
|
-
this.input.addEventListener('input', () => {
|
|
351
|
-
this.internals.setFormValue(this.input.value);
|
|
352
|
-
this.validate();
|
|
353
|
-
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
this.input.addEventListener('change', () => {
|
|
357
|
-
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
private validate(): void {
|
|
362
|
-
if (this.required && !this.value) {
|
|
363
|
-
this.internals.setValidity(
|
|
364
|
-
{ valueMissing: true },
|
|
365
|
-
'This field is required',
|
|
366
|
-
this.input
|
|
367
|
-
);
|
|
368
|
-
} else {
|
|
369
|
-
this.internals.setValidity({});
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
attributeChangedCallback(name: string, oldValue: string, newValue: string): void {
|
|
374
|
-
if (!this.input) return;
|
|
375
|
-
|
|
376
|
-
switch (name) {
|
|
377
|
-
case 'value':
|
|
378
|
-
this.input.value = newValue ?? '';
|
|
379
|
-
this.internals.setFormValue(newValue ?? '');
|
|
380
|
-
break;
|
|
381
|
-
case 'disabled':
|
|
382
|
-
this.input.disabled = newValue !== null;
|
|
383
|
-
break;
|
|
384
|
-
case 'required':
|
|
385
|
-
this.input.required = newValue !== null;
|
|
386
|
-
this.validate();
|
|
387
|
-
break;
|
|
388
|
-
case 'placeholder':
|
|
389
|
-
this.input.placeholder = newValue ?? '';
|
|
390
|
-
break;
|
|
391
|
-
case 'type':
|
|
392
|
-
this.input.type = newValue ?? 'text';
|
|
393
|
-
break;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
// Form callbacks
|
|
398
|
-
formResetCallback(): void {
|
|
399
|
-
this.value = this.getAttribute('value') ?? '';
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
formDisabledCallback(disabled: boolean): void {
|
|
403
|
-
this.input.disabled = disabled;
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
formStateRestoreCallback(state: string): void {
|
|
407
|
-
this.value = state;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Properties
|
|
411
|
-
get value(): string {
|
|
412
|
-
return this.input?.value ?? '';
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
set value(val: string) {
|
|
416
|
-
if (this.input) {
|
|
417
|
-
this.input.value = val;
|
|
418
|
-
this.internals.setFormValue(val);
|
|
419
|
-
this.validate();
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
get name(): string {
|
|
424
|
-
return this.getAttribute('name') ?? '';
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
set name(val: string) {
|
|
428
|
-
this.setAttribute('name', val);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
get disabled(): boolean {
|
|
432
|
-
return this.hasAttribute('disabled');
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
set disabled(val: boolean) {
|
|
436
|
-
if (val) {
|
|
437
|
-
this.setAttribute('disabled', '');
|
|
438
|
-
} else {
|
|
439
|
-
this.removeAttribute('disabled');
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
get required(): boolean {
|
|
444
|
-
return this.hasAttribute('required');
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
set required(val: boolean) {
|
|
448
|
-
if (val) {
|
|
449
|
-
this.setAttribute('required', '');
|
|
450
|
-
} else {
|
|
451
|
-
this.removeAttribute('required');
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
get form(): HTMLFormElement | null {
|
|
456
|
-
return this.internals.form;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
get validity(): ValidityState {
|
|
460
|
-
return this.internals.validity;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
get validationMessage(): string {
|
|
464
|
-
return this.internals.validationMessage;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
get willValidate(): boolean {
|
|
468
|
-
return this.internals.willValidate;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
checkValidity(): boolean {
|
|
472
|
-
return this.internals.checkValidity();
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
reportValidity(): boolean {
|
|
476
|
-
return this.internals.reportValidity();
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
customElements.define('r-input', RInput);
|
|
481
|
-
```
|
|
482
|
-
|
|
483
|
-
## Complete Example: Checkbox
|
|
484
|
-
|
|
485
|
-
```typescript
|
|
486
|
-
class RCheckbox extends HTMLElement {
|
|
487
|
-
static formAssociated = true;
|
|
488
|
-
static observedAttributes = ['checked', 'disabled', 'required', 'value'];
|
|
489
|
-
|
|
490
|
-
private internals: ElementInternals;
|
|
491
|
-
private checkbox: HTMLInputElement;
|
|
492
|
-
|
|
493
|
-
constructor() {
|
|
494
|
-
super();
|
|
495
|
-
this.internals = this.attachInternals();
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
connectedCallback(): void {
|
|
499
|
-
this.render();
|
|
500
|
-
this.setupEventListeners();
|
|
501
|
-
this.updateFormValue();
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
private render(): void {
|
|
505
|
-
const label = this.textContent;
|
|
506
|
-
this.innerHTML = `
|
|
507
|
-
<label>
|
|
508
|
-
<input type="checkbox" ${this.checked ? 'checked' : ''}>
|
|
509
|
-
<span>${label}</span>
|
|
510
|
-
</label>
|
|
511
|
-
`;
|
|
512
|
-
this.checkbox = this.querySelector('input');
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
private setupEventListeners(): void {
|
|
516
|
-
this.checkbox.addEventListener('change', () => {
|
|
517
|
-
this.updateFormValue();
|
|
518
|
-
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
519
|
-
});
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
private updateFormValue(): void {
|
|
523
|
-
if (this.checked) {
|
|
524
|
-
this.internals.setFormValue(this.value);
|
|
525
|
-
} else {
|
|
526
|
-
this.internals.setFormValue(null);
|
|
527
|
-
}
|
|
528
|
-
this.validate();
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
private validate(): void {
|
|
532
|
-
if (this.required && !this.checked) {
|
|
533
|
-
this.internals.setValidity(
|
|
534
|
-
{ valueMissing: true },
|
|
535
|
-
'Please check this box',
|
|
536
|
-
this.checkbox
|
|
537
|
-
);
|
|
538
|
-
} else {
|
|
539
|
-
this.internals.setValidity({});
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
formResetCallback(): void {
|
|
544
|
-
this.checked = this.hasAttribute('checked');
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
get checked(): boolean {
|
|
548
|
-
return this.checkbox?.checked ?? this.hasAttribute('checked');
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
set checked(val: boolean) {
|
|
552
|
-
if (this.checkbox) {
|
|
553
|
-
this.checkbox.checked = val;
|
|
554
|
-
this.updateFormValue();
|
|
555
|
-
}
|
|
556
|
-
if (val) {
|
|
557
|
-
this.setAttribute('checked', '');
|
|
558
|
-
} else {
|
|
559
|
-
this.removeAttribute('checked');
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
get value(): string {
|
|
564
|
-
return this.getAttribute('value') ?? 'on';
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
set value(val: string) {
|
|
568
|
-
this.setAttribute('value', val);
|
|
569
|
-
this.updateFormValue();
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
get name(): string {
|
|
573
|
-
return this.getAttribute('name') ?? '';
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
set name(val: string) {
|
|
577
|
-
this.setAttribute('name', val);
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
get disabled(): boolean {
|
|
581
|
-
return this.hasAttribute('disabled');
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
set disabled(val: boolean) {
|
|
585
|
-
if (val) {
|
|
586
|
-
this.setAttribute('disabled', '');
|
|
587
|
-
} else {
|
|
588
|
-
this.removeAttribute('disabled');
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
get required(): boolean {
|
|
593
|
-
return this.hasAttribute('required');
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
set required(val: boolean) {
|
|
597
|
-
if (val) {
|
|
598
|
-
this.setAttribute('required', '');
|
|
599
|
-
} else {
|
|
600
|
-
this.removeAttribute('required');
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
checkValidity(): boolean {
|
|
605
|
-
return this.internals.checkValidity();
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
reportValidity(): boolean {
|
|
609
|
-
return this.internals.reportValidity();
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
customElements.define('r-checkbox', RCheckbox);
|
|
614
|
-
```
|
|
615
|
-
|
|
616
|
-
## Complete Example: Select
|
|
617
|
-
|
|
618
|
-
```typescript
|
|
619
|
-
class RSelect extends HTMLElement {
|
|
620
|
-
static formAssociated = true;
|
|
621
|
-
static observedAttributes = ['value', 'disabled', 'required'];
|
|
622
|
-
|
|
623
|
-
private internals: ElementInternals;
|
|
624
|
-
private select: HTMLSelectElement;
|
|
625
|
-
|
|
626
|
-
constructor() {
|
|
627
|
-
super();
|
|
628
|
-
this.internals = this.attachInternals();
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
connectedCallback(): void {
|
|
632
|
-
this.render();
|
|
633
|
-
this.setupEventListeners();
|
|
634
|
-
this.validate();
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
private render(): void {
|
|
638
|
-
const options = Array.from(this.querySelectorAll('option'))
|
|
639
|
-
.map(opt => opt.outerHTML)
|
|
640
|
-
.join('');
|
|
641
|
-
|
|
642
|
-
this.innerHTML = `
|
|
643
|
-
<select ${this.disabled ? 'disabled' : ''}>
|
|
644
|
-
${options}
|
|
645
|
-
</select>
|
|
646
|
-
`;
|
|
647
|
-
this.select = this.querySelector('select');
|
|
648
|
-
|
|
649
|
-
if (this.hasAttribute('value')) {
|
|
650
|
-
this.select.value = this.getAttribute('value');
|
|
651
|
-
}
|
|
652
|
-
this.internals.setFormValue(this.select.value);
|
|
653
|
-
}
|
|
654
|
-
|
|
655
|
-
private setupEventListeners(): void {
|
|
656
|
-
this.select.addEventListener('change', () => {
|
|
657
|
-
this.internals.setFormValue(this.select.value);
|
|
658
|
-
this.validate();
|
|
659
|
-
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
private validate(): void {
|
|
664
|
-
if (this.required && !this.value) {
|
|
665
|
-
this.internals.setValidity(
|
|
666
|
-
{ valueMissing: true },
|
|
667
|
-
'Please select an option',
|
|
668
|
-
this.select
|
|
669
|
-
);
|
|
670
|
-
} else {
|
|
671
|
-
this.internals.setValidity({});
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
formResetCallback(): void {
|
|
676
|
-
this.select.selectedIndex = 0;
|
|
677
|
-
this.internals.setFormValue(this.select.value);
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
get value(): string {
|
|
681
|
-
return this.select?.value ?? '';
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
set value(val: string) {
|
|
685
|
-
if (this.select) {
|
|
686
|
-
this.select.value = val;
|
|
687
|
-
this.internals.setFormValue(val);
|
|
688
|
-
this.validate();
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
get name(): string {
|
|
693
|
-
return this.getAttribute('name') ?? '';
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
set name(val: string) {
|
|
697
|
-
this.setAttribute('name', val);
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
get disabled(): boolean {
|
|
701
|
-
return this.hasAttribute('disabled');
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
set disabled(val: boolean) {
|
|
705
|
-
if (val) {
|
|
706
|
-
this.setAttribute('disabled', '');
|
|
707
|
-
} else {
|
|
708
|
-
this.removeAttribute('disabled');
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
get required(): boolean {
|
|
713
|
-
return this.hasAttribute('required');
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
set required(val: boolean) {
|
|
717
|
-
if (val) {
|
|
718
|
-
this.setAttribute('required', '');
|
|
719
|
-
} else {
|
|
720
|
-
this.removeAttribute('required');
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
checkValidity(): boolean {
|
|
725
|
-
return this.internals.checkValidity();
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
reportValidity(): boolean {
|
|
729
|
-
return this.internals.reportValidity();
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
customElements.define('r-select', RSelect);
|
|
734
|
-
```
|
|
735
|
-
|
|
736
|
-
## getData and setData Methods
|
|
737
|
-
|
|
738
|
-
For complex components, implement `getData()` and `setData()` methods:
|
|
739
|
-
|
|
740
|
-
```typescript
|
|
741
|
-
class RRating extends HTMLElement {
|
|
742
|
-
static formAssociated = true;
|
|
743
|
-
|
|
744
|
-
private internals: ElementInternals;
|
|
745
|
-
private _value: number = 0;
|
|
746
|
-
|
|
747
|
-
constructor() {
|
|
748
|
-
super();
|
|
749
|
-
this.internals = this.attachInternals();
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
connectedCallback(): void {
|
|
753
|
-
this.render();
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
private render(): void {
|
|
757
|
-
this.innerHTML = `
|
|
758
|
-
<div class="stars">
|
|
759
|
-
${[1, 2, 3, 4, 5].map(i => `
|
|
760
|
-
<span class="star" data-value="${i}">★</span>
|
|
761
|
-
`).join('')}
|
|
762
|
-
</div>
|
|
763
|
-
`;
|
|
764
|
-
|
|
765
|
-
this.querySelectorAll('.star').forEach(star => {
|
|
766
|
-
star.addEventListener('click', () => {
|
|
767
|
-
this.setData(Number(star.getAttribute('data-value')));
|
|
768
|
-
});
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
getData(): number {
|
|
773
|
-
return this._value;
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
setData(value: number): void {
|
|
777
|
-
this._value = Math.max(0, Math.min(5, value));
|
|
778
|
-
this.internals.setFormValue(String(this._value));
|
|
779
|
-
this.updateDisplay();
|
|
780
|
-
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
private updateDisplay(): void {
|
|
784
|
-
this.querySelectorAll('.star').forEach((star, index) => {
|
|
785
|
-
star.classList.toggle('selected', index < this._value);
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
get value(): string {
|
|
790
|
-
return String(this._value);
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
set value(val: string) {
|
|
794
|
-
this.setData(Number(val));
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
get name(): string {
|
|
798
|
-
return this.getAttribute('name') ?? '';
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
set name(val: string) {
|
|
802
|
-
this.setAttribute('name', val);
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
formResetCallback(): void {
|
|
806
|
-
this.setData(0);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
customElements.define('r-rating', RRating);
|
|
811
|
-
```
|
|
812
|
-
|
|
813
|
-
## Usage with RelaxJS Forms
|
|
814
|
-
|
|
815
|
-
```html
|
|
816
|
-
<form id="profile-form">
|
|
817
|
-
<r-input name="username" required placeholder="Username"></r-input>
|
|
818
|
-
<r-input name="email" type="email" required placeholder="Email"></r-input>
|
|
819
|
-
<r-checkbox name="newsletter">Subscribe to newsletter</r-checkbox>
|
|
820
|
-
<r-select name="country" required>
|
|
821
|
-
<option value="">Select country</option>
|
|
822
|
-
<option value="us">United States</option>
|
|
823
|
-
<option value="uk">United Kingdom</option>
|
|
824
|
-
</r-select>
|
|
825
|
-
<r-rating name="satisfaction"></r-rating>
|
|
826
|
-
<button type="submit">Submit</button>
|
|
827
|
-
</form>
|
|
828
|
-
```
|
|
829
|
-
|
|
830
|
-
```typescript
|
|
831
|
-
const form = document.querySelector('#profile-form') as HTMLFormElement;
|
|
832
|
-
|
|
833
|
-
const validator = new FormValidator(form, {
|
|
834
|
-
useSummary: true,
|
|
835
|
-
autoValidate: true,
|
|
836
|
-
submitCallback: () => {
|
|
837
|
-
const data = readData(form);
|
|
838
|
-
console.log('Form data:', data);
|
|
839
|
-
// { username: 'john', email: 'john@example.com', newsletter: true, country: 'us', satisfaction: '4' }
|
|
840
|
-
}
|
|
841
|
-
});
|
|
842
|
-
|
|
843
|
-
// Pre-fill form
|
|
844
|
-
setFormData(form, {
|
|
845
|
-
username: 'john',
|
|
846
|
-
email: 'john@example.com',
|
|
847
|
-
newsletter: true,
|
|
848
|
-
country: 'us',
|
|
849
|
-
satisfaction: '4'
|
|
850
|
-
});
|
|
851
|
-
```
|
|
852
|
-
|
|
853
|
-
## Custom States with CustomStateSet
|
|
854
|
-
|
|
855
|
-
Use `ElementInternals.states` to expose component states as CSS pseudo-classes. This replaces data attributes or class toggling for states like invalid, loading, or disabled.
|
|
856
|
-
|
|
857
|
-
```typescript
|
|
858
|
-
class RInput extends HTMLElement {
|
|
859
|
-
static formAssociated = true;
|
|
860
|
-
private internals: ElementInternals;
|
|
861
|
-
|
|
862
|
-
constructor() {
|
|
863
|
-
super();
|
|
864
|
-
this.internals = this.attachInternals();
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
private validate(): void {
|
|
868
|
-
if (this.required && !this.value) {
|
|
869
|
-
this.internals.states.add('invalid');
|
|
870
|
-
this.internals.setValidity(
|
|
871
|
-
{ valueMissing: true },
|
|
872
|
-
'This field is required',
|
|
873
|
-
this.input
|
|
874
|
-
);
|
|
875
|
-
} else {
|
|
876
|
-
this.internals.states.delete('invalid');
|
|
877
|
-
this.internals.setValidity({});
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
set loading(v: boolean) {
|
|
882
|
-
if (v) {
|
|
883
|
-
this.internals.states.add('loading');
|
|
884
|
-
} else {
|
|
885
|
-
this.internals.states.delete('loading');
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
}
|
|
889
|
-
```
|
|
890
|
-
|
|
891
|
-
Consumers style these states with the `:state()` pseudo-class:
|
|
892
|
-
|
|
893
|
-
```css
|
|
894
|
-
r-input:state(invalid) {
|
|
895
|
-
border-color: var(--error-color, red);
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
r-input:state(loading) {
|
|
899
|
-
opacity: 0.6;
|
|
900
|
-
pointer-events: none;
|
|
901
|
-
}
|
|
902
|
-
```
|
|
903
|
-
|
|
904
|
-
This is preferred over data attributes because:
|
|
905
|
-
|
|
906
|
-
- States are encapsulated
|
|
907
|
-
- They work with CSS pseudo-class syntax, consistent with `:disabled`, `:invalid`
|
|
908
|
-
- No DOM attribute pollution
|
|
909
|
-
|
|
910
|
-
## Checklist
|
|
911
|
-
|
|
912
|
-
When creating a form component, ensure you have:
|
|
913
|
-
|
|
914
|
-
- [ ] `static formAssociated = true`
|
|
915
|
-
- [ ] `ElementInternals` via `attachInternals()`
|
|
916
|
-
- [ ] `name` property (get/set)
|
|
917
|
-
- [ ] `value` property (get/set)
|
|
918
|
-
- [ ] `disabled` property (get/set)
|
|
919
|
-
- [ ] `required` property (get/set)
|
|
920
|
-
- [ ] Call `setFormValue()` when value changes
|
|
921
|
-
- [ ] Call `setValidity()` for validation
|
|
922
|
-
- [ ] Implement `formResetCallback()`
|
|
923
|
-
- [ ] Implement `checkValidity()` and `reportValidity()`
|
|
924
|
-
- [ ] Dispatch `input` and/or `change` events
|
|
1
|
+
# Creating Custom Form Components
|
|
2
|
+
|
|
3
|
+
This guide explains how to create custom web components that integrate with the RelaxJS form system using the HTML Form API (form-associated custom elements).
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
Form-associated custom elements allow your web components to:
|
|
8
|
+
|
|
9
|
+
- Participate in form submission via `FormData`
|
|
10
|
+
- Work with `readData()`, `setFormData()`, and `mapFormToClass()`
|
|
11
|
+
- Support native form validation with `FormValidator`
|
|
12
|
+
- Be accessed via `form.elements`
|
|
13
|
+
- Reset with the form
|
|
14
|
+
|
|
15
|
+
## Basic Structure
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
class RInput extends HTMLElement {
|
|
19
|
+
static formAssociated = true;
|
|
20
|
+
|
|
21
|
+
private internals: ElementInternals;
|
|
22
|
+
private input: HTMLInputElement;
|
|
23
|
+
|
|
24
|
+
constructor() {
|
|
25
|
+
super();
|
|
26
|
+
this.internals = this.attachInternals();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
connectedCallback() {
|
|
30
|
+
this.render();
|
|
31
|
+
this.setupEventListeners();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private render() {
|
|
35
|
+
this.innerHTML = `<input type="text">`;
|
|
36
|
+
this.input = this.querySelector('input');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private setupEventListeners() {
|
|
40
|
+
this.input.addEventListener('input', () => {
|
|
41
|
+
this.internals.setFormValue(this.input.value);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get value(): string {
|
|
46
|
+
return this.input?.value ?? '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
set value(val: string) {
|
|
50
|
+
if (this.input) {
|
|
51
|
+
this.input.value = val;
|
|
52
|
+
this.internals.setFormValue(val);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get name(): string {
|
|
57
|
+
return this.getAttribute('name') ?? '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
set name(val: string) {
|
|
61
|
+
this.setAttribute('name', val);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
customElements.define('r-input', RInput);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Required Properties
|
|
69
|
+
|
|
70
|
+
### formAssociated
|
|
71
|
+
|
|
72
|
+
Static property that enables form association:
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
class MyComponent extends HTMLElement {
|
|
76
|
+
static formAssociated = true;
|
|
77
|
+
// ...
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### ElementInternals
|
|
82
|
+
|
|
83
|
+
Provides the bridge to the form:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
private internals: ElementInternals;
|
|
87
|
+
|
|
88
|
+
constructor() {
|
|
89
|
+
super();
|
|
90
|
+
this.internals = this.attachInternals();
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Core Form Properties
|
|
95
|
+
|
|
96
|
+
Implement these properties for full form integration:
|
|
97
|
+
|
|
98
|
+
### name
|
|
99
|
+
|
|
100
|
+
The field name used in form data:
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
get name(): string {
|
|
104
|
+
return this.getAttribute('name') ?? '';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
set name(val: string) {
|
|
108
|
+
this.setAttribute('name', val);
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### value
|
|
113
|
+
|
|
114
|
+
The current value:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
get value(): string {
|
|
118
|
+
return this._value;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
set value(val: string) {
|
|
122
|
+
this._value = val;
|
|
123
|
+
this.internals.setFormValue(val);
|
|
124
|
+
this.updateDisplay();
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### disabled
|
|
129
|
+
|
|
130
|
+
Prevents interaction and excludes from form data:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
get disabled(): boolean {
|
|
134
|
+
return this.hasAttribute('disabled');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
set disabled(val: boolean) {
|
|
138
|
+
if (val) {
|
|
139
|
+
this.setAttribute('disabled', '');
|
|
140
|
+
} else {
|
|
141
|
+
this.removeAttribute('disabled');
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### required
|
|
147
|
+
|
|
148
|
+
Marks field as required for validation:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
get required(): boolean {
|
|
152
|
+
return this.hasAttribute('required');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
set required(val: boolean) {
|
|
156
|
+
if (val) {
|
|
157
|
+
this.setAttribute('required', '');
|
|
158
|
+
} else {
|
|
159
|
+
this.removeAttribute('required');
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Type Conversion with dataType
|
|
165
|
+
|
|
166
|
+
The `dataType` property allows custom type conversion when using `readData()`:
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
get dataType(): string | ((value: string) => unknown) {
|
|
170
|
+
return this.getAttribute('data-type') ?? 'string';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
set dataType(val: string | ((value: string) => unknown)) {
|
|
174
|
+
if (typeof val === 'function') {
|
|
175
|
+
(this as any)._dataTypeConverter = val;
|
|
176
|
+
} else {
|
|
177
|
+
this.setAttribute('data-type', val);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Built-in data types: `'string'`, `'number'`, `'boolean'`, `'Date'`
|
|
183
|
+
|
|
184
|
+
## Validation
|
|
185
|
+
|
|
186
|
+
### Native Validation
|
|
187
|
+
|
|
188
|
+
Use `setValidity()` for HTML5 validation integration:
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
private validate(): void {
|
|
192
|
+
if (this.required && !this.value) {
|
|
193
|
+
this.internals.setValidity(
|
|
194
|
+
{ valueMissing: true },
|
|
195
|
+
'This field is required',
|
|
196
|
+
this.input
|
|
197
|
+
);
|
|
198
|
+
} else if (this.pattern && !new RegExp(this.pattern).test(this.value)) {
|
|
199
|
+
this.internals.setValidity(
|
|
200
|
+
{ patternMismatch: true },
|
|
201
|
+
'Please match the requested format',
|
|
202
|
+
this.input
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
this.internals.setValidity({});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Validation Flags
|
|
211
|
+
|
|
212
|
+
Available `ValidityStateFlags`:
|
|
213
|
+
|
|
214
|
+
| Flag | Description |
|
|
215
|
+
|------|-------------|
|
|
216
|
+
| `valueMissing` | Required field is empty |
|
|
217
|
+
| `typeMismatch` | Value doesn't match input type |
|
|
218
|
+
| `patternMismatch` | Value doesn't match pattern |
|
|
219
|
+
| `tooLong` | Value exceeds maxlength |
|
|
220
|
+
| `tooShort` | Value is shorter than minlength |
|
|
221
|
+
| `rangeUnderflow` | Value is below min |
|
|
222
|
+
| `rangeOverflow` | Value exceeds max |
|
|
223
|
+
| `stepMismatch` | Value doesn't match step |
|
|
224
|
+
| `badInput` | Browser can't convert input |
|
|
225
|
+
| `customError` | Custom validation failed |
|
|
226
|
+
|
|
227
|
+
### Custom Validation Messages
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
private validate(): void {
|
|
231
|
+
const value = this.value;
|
|
232
|
+
|
|
233
|
+
if (this.required && !value) {
|
|
234
|
+
this.internals.setValidity(
|
|
235
|
+
{ valueMissing: true },
|
|
236
|
+
'Please fill out this field',
|
|
237
|
+
this.input
|
|
238
|
+
);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (this.minLength && value.length < this.minLength) {
|
|
243
|
+
this.internals.setValidity(
|
|
244
|
+
{ tooShort: true },
|
|
245
|
+
`Please enter at least ${this.minLength} characters`,
|
|
246
|
+
this.input
|
|
247
|
+
);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Custom business logic
|
|
252
|
+
if (this.getAttribute('data-type') === 'email' && !this.isValidEmail(value)) {
|
|
253
|
+
this.internals.setValidity(
|
|
254
|
+
{ typeMismatch: true },
|
|
255
|
+
'Please enter a valid email address',
|
|
256
|
+
this.input
|
|
257
|
+
);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
this.internals.setValidity({});
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Form Callbacks
|
|
266
|
+
|
|
267
|
+
### formAssociatedCallback
|
|
268
|
+
|
|
269
|
+
Called when the element is associated with a form:
|
|
270
|
+
|
|
271
|
+
```typescript
|
|
272
|
+
formAssociatedCallback(form: HTMLFormElement | null): void {
|
|
273
|
+
if (form) {
|
|
274
|
+
console.log('Associated with form:', form.id);
|
|
275
|
+
} else {
|
|
276
|
+
console.log('Disassociated from form');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
### formResetCallback
|
|
282
|
+
|
|
283
|
+
Called when the form is reset:
|
|
284
|
+
|
|
285
|
+
```typescript
|
|
286
|
+
formResetCallback(): void {
|
|
287
|
+
this.value = this.getAttribute('value') ?? '';
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
### formDisabledCallback
|
|
292
|
+
|
|
293
|
+
Called when the element's disabled state changes due to the form:
|
|
294
|
+
|
|
295
|
+
```typescript
|
|
296
|
+
formDisabledCallback(disabled: boolean): void {
|
|
297
|
+
this.input.disabled = disabled;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### formStateRestoreCallback
|
|
302
|
+
|
|
303
|
+
Called during form restoration (browser back/forward):
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
formStateRestoreCallback(state: string, mode: 'restore' | 'autocomplete'): void {
|
|
307
|
+
this.value = state;
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
## Complete Example: Text Input
|
|
312
|
+
|
|
313
|
+
```typescript
|
|
314
|
+
class RInput extends HTMLElement {
|
|
315
|
+
static formAssociated = true;
|
|
316
|
+
static observedAttributes = ['value', 'disabled', 'required', 'placeholder', 'type'];
|
|
317
|
+
|
|
318
|
+
private internals: ElementInternals;
|
|
319
|
+
private input: HTMLInputElement;
|
|
320
|
+
|
|
321
|
+
constructor() {
|
|
322
|
+
super();
|
|
323
|
+
this.internals = this.attachInternals();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
connectedCallback(): void {
|
|
327
|
+
this.render();
|
|
328
|
+
this.setupEventListeners();
|
|
329
|
+
this.validate();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private render(): void {
|
|
333
|
+
const type = this.getAttribute('type') ?? 'text';
|
|
334
|
+
const placeholder = this.getAttribute('placeholder') ?? '';
|
|
335
|
+
const value = this.getAttribute('value') ?? '';
|
|
336
|
+
|
|
337
|
+
this.innerHTML = `
|
|
338
|
+
<input
|
|
339
|
+
type="${type}"
|
|
340
|
+
placeholder="${placeholder}"
|
|
341
|
+
value="${value}"
|
|
342
|
+
${this.disabled ? 'disabled' : ''}
|
|
343
|
+
>
|
|
344
|
+
`;
|
|
345
|
+
this.input = this.querySelector('input');
|
|
346
|
+
this.internals.setFormValue(value);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private setupEventListeners(): void {
|
|
350
|
+
this.input.addEventListener('input', () => {
|
|
351
|
+
this.internals.setFormValue(this.input.value);
|
|
352
|
+
this.validate();
|
|
353
|
+
this.dispatchEvent(new Event('input', { bubbles: true }));
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
this.input.addEventListener('change', () => {
|
|
357
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
private validate(): void {
|
|
362
|
+
if (this.required && !this.value) {
|
|
363
|
+
this.internals.setValidity(
|
|
364
|
+
{ valueMissing: true },
|
|
365
|
+
'This field is required',
|
|
366
|
+
this.input
|
|
367
|
+
);
|
|
368
|
+
} else {
|
|
369
|
+
this.internals.setValidity({});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
attributeChangedCallback(name: string, oldValue: string, newValue: string): void {
|
|
374
|
+
if (!this.input) return;
|
|
375
|
+
|
|
376
|
+
switch (name) {
|
|
377
|
+
case 'value':
|
|
378
|
+
this.input.value = newValue ?? '';
|
|
379
|
+
this.internals.setFormValue(newValue ?? '');
|
|
380
|
+
break;
|
|
381
|
+
case 'disabled':
|
|
382
|
+
this.input.disabled = newValue !== null;
|
|
383
|
+
break;
|
|
384
|
+
case 'required':
|
|
385
|
+
this.input.required = newValue !== null;
|
|
386
|
+
this.validate();
|
|
387
|
+
break;
|
|
388
|
+
case 'placeholder':
|
|
389
|
+
this.input.placeholder = newValue ?? '';
|
|
390
|
+
break;
|
|
391
|
+
case 'type':
|
|
392
|
+
this.input.type = newValue ?? 'text';
|
|
393
|
+
break;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Form callbacks
|
|
398
|
+
formResetCallback(): void {
|
|
399
|
+
this.value = this.getAttribute('value') ?? '';
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
formDisabledCallback(disabled: boolean): void {
|
|
403
|
+
this.input.disabled = disabled;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
formStateRestoreCallback(state: string): void {
|
|
407
|
+
this.value = state;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Properties
|
|
411
|
+
get value(): string {
|
|
412
|
+
return this.input?.value ?? '';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
set value(val: string) {
|
|
416
|
+
if (this.input) {
|
|
417
|
+
this.input.value = val;
|
|
418
|
+
this.internals.setFormValue(val);
|
|
419
|
+
this.validate();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
get name(): string {
|
|
424
|
+
return this.getAttribute('name') ?? '';
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
set name(val: string) {
|
|
428
|
+
this.setAttribute('name', val);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
get disabled(): boolean {
|
|
432
|
+
return this.hasAttribute('disabled');
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
set disabled(val: boolean) {
|
|
436
|
+
if (val) {
|
|
437
|
+
this.setAttribute('disabled', '');
|
|
438
|
+
} else {
|
|
439
|
+
this.removeAttribute('disabled');
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
get required(): boolean {
|
|
444
|
+
return this.hasAttribute('required');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
set required(val: boolean) {
|
|
448
|
+
if (val) {
|
|
449
|
+
this.setAttribute('required', '');
|
|
450
|
+
} else {
|
|
451
|
+
this.removeAttribute('required');
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
get form(): HTMLFormElement | null {
|
|
456
|
+
return this.internals.form;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
get validity(): ValidityState {
|
|
460
|
+
return this.internals.validity;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
get validationMessage(): string {
|
|
464
|
+
return this.internals.validationMessage;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
get willValidate(): boolean {
|
|
468
|
+
return this.internals.willValidate;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
checkValidity(): boolean {
|
|
472
|
+
return this.internals.checkValidity();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
reportValidity(): boolean {
|
|
476
|
+
return this.internals.reportValidity();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
customElements.define('r-input', RInput);
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
## Complete Example: Checkbox
|
|
484
|
+
|
|
485
|
+
```typescript
|
|
486
|
+
class RCheckbox extends HTMLElement {
|
|
487
|
+
static formAssociated = true;
|
|
488
|
+
static observedAttributes = ['checked', 'disabled', 'required', 'value'];
|
|
489
|
+
|
|
490
|
+
private internals: ElementInternals;
|
|
491
|
+
private checkbox: HTMLInputElement;
|
|
492
|
+
|
|
493
|
+
constructor() {
|
|
494
|
+
super();
|
|
495
|
+
this.internals = this.attachInternals();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
connectedCallback(): void {
|
|
499
|
+
this.render();
|
|
500
|
+
this.setupEventListeners();
|
|
501
|
+
this.updateFormValue();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private render(): void {
|
|
505
|
+
const label = this.textContent;
|
|
506
|
+
this.innerHTML = `
|
|
507
|
+
<label>
|
|
508
|
+
<input type="checkbox" ${this.checked ? 'checked' : ''}>
|
|
509
|
+
<span>${label}</span>
|
|
510
|
+
</label>
|
|
511
|
+
`;
|
|
512
|
+
this.checkbox = this.querySelector('input');
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
private setupEventListeners(): void {
|
|
516
|
+
this.checkbox.addEventListener('change', () => {
|
|
517
|
+
this.updateFormValue();
|
|
518
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private updateFormValue(): void {
|
|
523
|
+
if (this.checked) {
|
|
524
|
+
this.internals.setFormValue(this.value);
|
|
525
|
+
} else {
|
|
526
|
+
this.internals.setFormValue(null);
|
|
527
|
+
}
|
|
528
|
+
this.validate();
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
private validate(): void {
|
|
532
|
+
if (this.required && !this.checked) {
|
|
533
|
+
this.internals.setValidity(
|
|
534
|
+
{ valueMissing: true },
|
|
535
|
+
'Please check this box',
|
|
536
|
+
this.checkbox
|
|
537
|
+
);
|
|
538
|
+
} else {
|
|
539
|
+
this.internals.setValidity({});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
formResetCallback(): void {
|
|
544
|
+
this.checked = this.hasAttribute('checked');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
get checked(): boolean {
|
|
548
|
+
return this.checkbox?.checked ?? this.hasAttribute('checked');
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
set checked(val: boolean) {
|
|
552
|
+
if (this.checkbox) {
|
|
553
|
+
this.checkbox.checked = val;
|
|
554
|
+
this.updateFormValue();
|
|
555
|
+
}
|
|
556
|
+
if (val) {
|
|
557
|
+
this.setAttribute('checked', '');
|
|
558
|
+
} else {
|
|
559
|
+
this.removeAttribute('checked');
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
get value(): string {
|
|
564
|
+
return this.getAttribute('value') ?? 'on';
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
set value(val: string) {
|
|
568
|
+
this.setAttribute('value', val);
|
|
569
|
+
this.updateFormValue();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
get name(): string {
|
|
573
|
+
return this.getAttribute('name') ?? '';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
set name(val: string) {
|
|
577
|
+
this.setAttribute('name', val);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
get disabled(): boolean {
|
|
581
|
+
return this.hasAttribute('disabled');
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
set disabled(val: boolean) {
|
|
585
|
+
if (val) {
|
|
586
|
+
this.setAttribute('disabled', '');
|
|
587
|
+
} else {
|
|
588
|
+
this.removeAttribute('disabled');
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
get required(): boolean {
|
|
593
|
+
return this.hasAttribute('required');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
set required(val: boolean) {
|
|
597
|
+
if (val) {
|
|
598
|
+
this.setAttribute('required', '');
|
|
599
|
+
} else {
|
|
600
|
+
this.removeAttribute('required');
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
checkValidity(): boolean {
|
|
605
|
+
return this.internals.checkValidity();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
reportValidity(): boolean {
|
|
609
|
+
return this.internals.reportValidity();
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
customElements.define('r-checkbox', RCheckbox);
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
## Complete Example: Select
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
class RSelect extends HTMLElement {
|
|
620
|
+
static formAssociated = true;
|
|
621
|
+
static observedAttributes = ['value', 'disabled', 'required'];
|
|
622
|
+
|
|
623
|
+
private internals: ElementInternals;
|
|
624
|
+
private select: HTMLSelectElement;
|
|
625
|
+
|
|
626
|
+
constructor() {
|
|
627
|
+
super();
|
|
628
|
+
this.internals = this.attachInternals();
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
connectedCallback(): void {
|
|
632
|
+
this.render();
|
|
633
|
+
this.setupEventListeners();
|
|
634
|
+
this.validate();
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private render(): void {
|
|
638
|
+
const options = Array.from(this.querySelectorAll('option'))
|
|
639
|
+
.map(opt => opt.outerHTML)
|
|
640
|
+
.join('');
|
|
641
|
+
|
|
642
|
+
this.innerHTML = `
|
|
643
|
+
<select ${this.disabled ? 'disabled' : ''}>
|
|
644
|
+
${options}
|
|
645
|
+
</select>
|
|
646
|
+
`;
|
|
647
|
+
this.select = this.querySelector('select');
|
|
648
|
+
|
|
649
|
+
if (this.hasAttribute('value')) {
|
|
650
|
+
this.select.value = this.getAttribute('value');
|
|
651
|
+
}
|
|
652
|
+
this.internals.setFormValue(this.select.value);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private setupEventListeners(): void {
|
|
656
|
+
this.select.addEventListener('change', () => {
|
|
657
|
+
this.internals.setFormValue(this.select.value);
|
|
658
|
+
this.validate();
|
|
659
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
private validate(): void {
|
|
664
|
+
if (this.required && !this.value) {
|
|
665
|
+
this.internals.setValidity(
|
|
666
|
+
{ valueMissing: true },
|
|
667
|
+
'Please select an option',
|
|
668
|
+
this.select
|
|
669
|
+
);
|
|
670
|
+
} else {
|
|
671
|
+
this.internals.setValidity({});
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
formResetCallback(): void {
|
|
676
|
+
this.select.selectedIndex = 0;
|
|
677
|
+
this.internals.setFormValue(this.select.value);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
get value(): string {
|
|
681
|
+
return this.select?.value ?? '';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
set value(val: string) {
|
|
685
|
+
if (this.select) {
|
|
686
|
+
this.select.value = val;
|
|
687
|
+
this.internals.setFormValue(val);
|
|
688
|
+
this.validate();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
get name(): string {
|
|
693
|
+
return this.getAttribute('name') ?? '';
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
set name(val: string) {
|
|
697
|
+
this.setAttribute('name', val);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
get disabled(): boolean {
|
|
701
|
+
return this.hasAttribute('disabled');
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
set disabled(val: boolean) {
|
|
705
|
+
if (val) {
|
|
706
|
+
this.setAttribute('disabled', '');
|
|
707
|
+
} else {
|
|
708
|
+
this.removeAttribute('disabled');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
get required(): boolean {
|
|
713
|
+
return this.hasAttribute('required');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
set required(val: boolean) {
|
|
717
|
+
if (val) {
|
|
718
|
+
this.setAttribute('required', '');
|
|
719
|
+
} else {
|
|
720
|
+
this.removeAttribute('required');
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
checkValidity(): boolean {
|
|
725
|
+
return this.internals.checkValidity();
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
reportValidity(): boolean {
|
|
729
|
+
return this.internals.reportValidity();
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
customElements.define('r-select', RSelect);
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
## getData and setData Methods
|
|
737
|
+
|
|
738
|
+
For complex components, implement `getData()` and `setData()` methods:
|
|
739
|
+
|
|
740
|
+
```typescript
|
|
741
|
+
class RRating extends HTMLElement {
|
|
742
|
+
static formAssociated = true;
|
|
743
|
+
|
|
744
|
+
private internals: ElementInternals;
|
|
745
|
+
private _value: number = 0;
|
|
746
|
+
|
|
747
|
+
constructor() {
|
|
748
|
+
super();
|
|
749
|
+
this.internals = this.attachInternals();
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
connectedCallback(): void {
|
|
753
|
+
this.render();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private render(): void {
|
|
757
|
+
this.innerHTML = `
|
|
758
|
+
<div class="stars">
|
|
759
|
+
${[1, 2, 3, 4, 5].map(i => `
|
|
760
|
+
<span class="star" data-value="${i}">★</span>
|
|
761
|
+
`).join('')}
|
|
762
|
+
</div>
|
|
763
|
+
`;
|
|
764
|
+
|
|
765
|
+
this.querySelectorAll('.star').forEach(star => {
|
|
766
|
+
star.addEventListener('click', () => {
|
|
767
|
+
this.setData(Number(star.getAttribute('data-value')));
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
getData(): number {
|
|
773
|
+
return this._value;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
setData(value: number): void {
|
|
777
|
+
this._value = Math.max(0, Math.min(5, value));
|
|
778
|
+
this.internals.setFormValue(String(this._value));
|
|
779
|
+
this.updateDisplay();
|
|
780
|
+
this.dispatchEvent(new Event('change', { bubbles: true }));
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
private updateDisplay(): void {
|
|
784
|
+
this.querySelectorAll('.star').forEach((star, index) => {
|
|
785
|
+
star.classList.toggle('selected', index < this._value);
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
get value(): string {
|
|
790
|
+
return String(this._value);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
set value(val: string) {
|
|
794
|
+
this.setData(Number(val));
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
get name(): string {
|
|
798
|
+
return this.getAttribute('name') ?? '';
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
set name(val: string) {
|
|
802
|
+
this.setAttribute('name', val);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
formResetCallback(): void {
|
|
806
|
+
this.setData(0);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
customElements.define('r-rating', RRating);
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
## Usage with RelaxJS Forms
|
|
814
|
+
|
|
815
|
+
```html
|
|
816
|
+
<form id="profile-form">
|
|
817
|
+
<r-input name="username" required placeholder="Username"></r-input>
|
|
818
|
+
<r-input name="email" type="email" required placeholder="Email"></r-input>
|
|
819
|
+
<r-checkbox name="newsletter">Subscribe to newsletter</r-checkbox>
|
|
820
|
+
<r-select name="country" required>
|
|
821
|
+
<option value="">Select country</option>
|
|
822
|
+
<option value="us">United States</option>
|
|
823
|
+
<option value="uk">United Kingdom</option>
|
|
824
|
+
</r-select>
|
|
825
|
+
<r-rating name="satisfaction"></r-rating>
|
|
826
|
+
<button type="submit">Submit</button>
|
|
827
|
+
</form>
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
```typescript
|
|
831
|
+
const form = document.querySelector('#profile-form') as HTMLFormElement;
|
|
832
|
+
|
|
833
|
+
const validator = new FormValidator(form, {
|
|
834
|
+
useSummary: true,
|
|
835
|
+
autoValidate: true,
|
|
836
|
+
submitCallback: () => {
|
|
837
|
+
const data = readData(form);
|
|
838
|
+
console.log('Form data:', data);
|
|
839
|
+
// { username: 'john', email: 'john@example.com', newsletter: true, country: 'us', satisfaction: '4' }
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// Pre-fill form
|
|
844
|
+
setFormData(form, {
|
|
845
|
+
username: 'john',
|
|
846
|
+
email: 'john@example.com',
|
|
847
|
+
newsletter: true,
|
|
848
|
+
country: 'us',
|
|
849
|
+
satisfaction: '4'
|
|
850
|
+
});
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
## Custom States with CustomStateSet
|
|
854
|
+
|
|
855
|
+
Use `ElementInternals.states` to expose component states as CSS pseudo-classes. This replaces data attributes or class toggling for states like invalid, loading, or disabled.
|
|
856
|
+
|
|
857
|
+
```typescript
|
|
858
|
+
class RInput extends HTMLElement {
|
|
859
|
+
static formAssociated = true;
|
|
860
|
+
private internals: ElementInternals;
|
|
861
|
+
|
|
862
|
+
constructor() {
|
|
863
|
+
super();
|
|
864
|
+
this.internals = this.attachInternals();
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private validate(): void {
|
|
868
|
+
if (this.required && !this.value) {
|
|
869
|
+
this.internals.states.add('invalid');
|
|
870
|
+
this.internals.setValidity(
|
|
871
|
+
{ valueMissing: true },
|
|
872
|
+
'This field is required',
|
|
873
|
+
this.input
|
|
874
|
+
);
|
|
875
|
+
} else {
|
|
876
|
+
this.internals.states.delete('invalid');
|
|
877
|
+
this.internals.setValidity({});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
set loading(v: boolean) {
|
|
882
|
+
if (v) {
|
|
883
|
+
this.internals.states.add('loading');
|
|
884
|
+
} else {
|
|
885
|
+
this.internals.states.delete('loading');
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
```
|
|
890
|
+
|
|
891
|
+
Consumers style these states with the `:state()` pseudo-class:
|
|
892
|
+
|
|
893
|
+
```css
|
|
894
|
+
r-input:state(invalid) {
|
|
895
|
+
border-color: var(--error-color, red);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
r-input:state(loading) {
|
|
899
|
+
opacity: 0.6;
|
|
900
|
+
pointer-events: none;
|
|
901
|
+
}
|
|
902
|
+
```
|
|
903
|
+
|
|
904
|
+
This is preferred over data attributes because:
|
|
905
|
+
|
|
906
|
+
- States are encapsulated and can't be set externally via `setAttribute()`
|
|
907
|
+
- They work with CSS pseudo-class syntax, consistent with `:disabled`, `:invalid`
|
|
908
|
+
- No DOM attribute pollution
|
|
909
|
+
|
|
910
|
+
## Checklist
|
|
911
|
+
|
|
912
|
+
When creating a form component, ensure you have:
|
|
913
|
+
|
|
914
|
+
- [ ] `static formAssociated = true`
|
|
915
|
+
- [ ] `ElementInternals` via `attachInternals()`
|
|
916
|
+
- [ ] `name` property (get/set)
|
|
917
|
+
- [ ] `value` property (get/set)
|
|
918
|
+
- [ ] `disabled` property (get/set)
|
|
919
|
+
- [ ] `required` property (get/set)
|
|
920
|
+
- [ ] Call `setFormValue()` when value changes
|
|
921
|
+
- [ ] Call `setValidity()` for validation
|
|
922
|
+
- [ ] Implement `formResetCallback()`
|
|
923
|
+
- [ ] Implement `checkValidity()` and `reportValidity()`
|
|
924
|
+
- [ ] Dispatch `input` and/or `change` events
|