UNPKG

19.5 kBJavaScriptView Raw
1import WUPBaseElement from "./baseElement";
2import { nestedProperty, promiseWait, scrollIntoView } from "./indexHelpers";
3import WUPSpinElement from "./spinElement";
4import { WUPcssButton } from "./styles";
5export var SubmitActions;
6(function (SubmitActions) {
7 /** Disable any action */
8 SubmitActions[SubmitActions["none"] = 0] = "none";
9 /** Scroll to first error (if exists) and focus control */
10 SubmitActions[SubmitActions["goToError"] = 1] = "goToError";
11 /** Validate until first error is found (otherwise validate all) */
12 SubmitActions[SubmitActions["validateUntilFirst"] = 2] = "validateUntilFirst";
13 /** Collect to model only changed values */
14 SubmitActions[SubmitActions["collectChanged"] = 4] = "collectChanged";
15 /** Reset isDirty and assign $value to $initValue for controls (on success only) */
16 SubmitActions[SubmitActions["reset"] = 8] = "reset";
17 /** Lock the whole form during the pending state (set $isPending = true or provide `promise` to submitEvent.waitFor);
18 * Otherwise user can submit several times in a short time;
19 * If promise resolves during the short-time pending state won't be set, otherwise it takes at least 300ms via helper {@link promiseWait} */
20 SubmitActions[SubmitActions["lockOnPending"] = 16] = "lockOnPending";
21})(SubmitActions || (SubmitActions = {}));
22const tagName = "wup-form";
23const formStore = [];
24/** Wrapper of FormHTMLElement that collect values from controls
25 * @example
26 * // init form
27 * const form = document.createElement("wup-form");
28 * form.$options.autoComplete = false;
29 * form.$initModel = { email: "test-me@google.com" };
30 * form.addEventListener("$submit", (e) => console.warn(e.detail.model) );
31 * form.$onSubmit = async (e)=>{ await postHere(e.detail.model); } // equal to form.addEventListener
32 * // init control
33 * const el = document.createElement("wup-text");
34 * el.$options.name = "email";
35 * el.$options.validations = { required: true, email: true };
36 * form.appendChild(el);
37 * const btn = form.appendChild(document.createElement("button"));
38 * btn.textContent = "Submit";
39 * btn.type = "submit";
40 * document.body.appendChild(form);
41 * // or HTML
42 * <wup-form w-autocomplete w-autofocus>
43 * <wup-text w-name="email" />
44 * <button type="submit">Submit</submit>
45 * </wup-form>;
46 * @tutorial Troubleshooting/rules:
47 * * options like $initModel, $model overrides control.$initValue, control.$value (every control that matches by $options.name)
48 * * In React ref-parent called after ref-children. So if you want to set control.$initValue over form.$initModel use empty setTimeout on ref-control
49 * @example
50 * <wup-form
51 ref={(el) => {
52 if (el) {
53 el.$initModel = { email: "test-me@google.com" };
54 el.$onSubmit = async (ev)=>{
55 await postHere();
56 }
57 }
58 }}
59 >
60 <wup-text
61 ref={(el) => {
62 if (el) {
63 setTimeout(() => {
64 el.$options.name = "email";
65 el.$initValue = "";
66 });
67 }
68 }}
69 <button type="submit">Submit</button>
70 />
71 </wup-form>
72 */
73export default class WUPFormElement extends WUPBaseElement {
74 /** Returns this.constructor // watch-fix: https://github.com/Microsoft/TypeScript/issues/3841#issuecomment-337560146 */
75 #ctr = this.constructor;
76 static get $styleRoot() {
77 return `:root {
78 --btn-submit-text: var(--base-btn-text);
79 --btn-submit-bg: var(--base-btn-bg);
80 --btn-submit-focus: var(--base-btn-focus);
81 }
82 [wupdark] {
83 --btn-submit-text: var(--base-btn-text);
84 --btn-submit-bg: var(--base-btn-bg);
85 --btn-submit-focus: var(--base-btn-focus);
86 }`;
87 }
88 static get $style() {
89 return `${super.$style}
90 :host {
91 position: relative;
92 display: block;
93 max-width: 500px;
94 margin: auto;
95 }
96 ${WUPcssButton(":host button[type=submit]")}
97 :host button[type=submit] {
98 --base-btn-text: var(--btn-submit-text);
99 --base-btn-bg: var(--btn-submit-bg);
100 --base-btn-focus: var(--btn-submit-focus);
101 display: block;
102 }`;
103 }
104 static get mappedAttributes() {
105 const m = super.mappedAttributes;
106 m.autostore.type = 2 /* AttributeTypes.string */;
107 return m;
108 }
109 /** Find form related to control,register and apply initModel if initValue undefined */
110 static $tryConnect(control) {
111 const form = formStore.find((f) => f.contains(control));
112 form?.$controls.push(control);
113 return form;
114 }
115 /** Map model to control-values */
116 static $modelToControls(m, controls, prop) {
117 const out = { hasProp: undefined };
118 controls.forEach((c) => {
119 const key = c.$options.name;
120 if (key) {
121 const v = m && nestedProperty.get(m, key, out);
122 if (out.hasProp) {
123 c[prop] = v;
124 }
125 }
126 });
127 }
128 /** Collect model from control-values */
129 static $modelFromControls(prevModel, controls, prop, isOnlyChanged) {
130 if (isOnlyChanged) {
131 controls.forEach((c) => c.$options.name && c.$isChanged && nestedProperty.set(prevModel, c.$options.name, c[prop]));
132 }
133 else {
134 controls.forEach((c) => c.$options.name && nestedProperty.set(prevModel, c.$options.name, c[prop]));
135 }
136 return prevModel;
137 }
138 static get observedAttributes() {
139 const a = super.observedAttributes;
140 a.push("disabled", "readonly"); // support for `readonly` & `w-readonly`
141 return a;
142 }
143 /** Default options - applied to every element. Change it to configure default behavior */
144 static $defaults = {
145 submitActions: 1 /* SubmitActions.goToError */ | 2 /* SubmitActions.validateUntilFirst */ | 8 /* SubmitActions.reset */ | 16 /* SubmitActions.lockOnPending */,
146 autoComplete: false,
147 autoFocus: false,
148 autoStore: false,
149 disabled: false,
150 readOnly: false,
151 };
152 /** Fires before $submit is happened; can be prevented via `e.preventDefault()` */
153 $onWillSubmit;
154 /** Dispatched on submit. Return promise to lock form and show spinner on http-request */
155 $onSubmit;
156 /** Fires when submit is end (after http-response) */
157 $onSubmitEnd;
158 /** Dispatched on submit */
159 // It's not required but called: $onsubmit?: (ev: WUP.Form.SubmitEvent<Model>) => void;
160 /** All controls related to form */
161 $controls = [];
162 /** Returns related to form controls with $options.name != null */
163 get $controlsAttached() {
164 return this.$controls.filter((c) => c.$options.name != null);
165 }
166 _model;
167 /** Model related to every control inside (with $options.name);
168 * @see {@link BaseControl.prototype.$value} */
169 get $model() {
170 return Object.assign(this._model || {}, this.#ctr.$modelFromControls({}, this.$controls, "$value"));
171 }
172 set $model(m) {
173 if (m !== this._model) {
174 this._model = m;
175 this.#ctr.$modelToControls(m, this.$controls, "$value");
176 }
177 }
178 _initModel;
179 /** Default/init model related to every control inside;
180 * @see {@link BaseControl.prototype.$initValue} */
181 get $initModel() {
182 // it's required to avoid case when model has more props than controls
183 return this.#ctr.$modelFromControls(this._initModel || {}, this.$controls, "$initValue");
184 }
185 set $initModel(m) {
186 if (m !== this._initModel) {
187 this._initModel = m;
188 this.#ctr.$modelToControls(m, this.$controls, "$initValue");
189 }
190 }
191 /** Pending state (spinner + lock form if SubmitActions.lockOnPending enabled) */
192 get $isPending() {
193 return this.#stopPending !== undefined;
194 }
195 set $isPending(v) {
196 if (v !== this.$isPending) {
197 this.changePending(v);
198 }
199 }
200 /** Returns true if all nested controls (with name) are valid */
201 get $isValid() {
202 return this.$controlsAttached.every((c) => c.$isValid);
203 }
204 /** Returns true if some of controls value is changed by user */
205 get $isChanged() {
206 return this.$controls.some((c) => c.$options.name && c.$isChanged);
207 }
208 /** Call it to manually trigger submit or better to use `gotSubmit` for handling events properly */
209 $submit() {
210 this.gotSubmit(null, this);
211 }
212 /** Called on every spin-render */
213 renderSpin(target) {
214 WUPSpinElement.$use();
215 const spin = document.createElement("wup-spin");
216 spin.$options.fit = true;
217 spin.$options.overflowFade = false;
218 spin.$options.overflowTarget = target;
219 return spin;
220 }
221 #stopPending;
222 /** Change pending state */
223 changePending(v) {
224 if (v === !!this.#stopPending) {
225 return;
226 }
227 if (v) {
228 const wasDisabled = this._opts.disabled;
229 if (this._opts.submitActions & 16 /* SubmitActions.lockOnPending */) {
230 this.$options.disabled = true;
231 }
232 const btns = [];
233 const spins = [];
234 this.querySelectorAll("[type='submit']").forEach((b) => {
235 spins.push(this.appendChild(this.renderSpin(b)));
236 b._wupDisabled = b.disabled;
237 b.disabled = true;
238 btns.push(b);
239 });
240 this.#stopPending = () => {
241 this.#stopPending = undefined;
242 this.$options.disabled = wasDisabled;
243 btns.forEach((b) => (b.disabled = b._wupDisabled));
244 spins.forEach((s) => s.remove());
245 };
246 }
247 else {
248 this.#stopPending();
249 }
250 }
251 /** Called on submit before validation (to fire validation & $onSubmit if successful) */
252 gotSubmit(e, submitter) {
253 e?.preventDefault(); // prevent default keyboard or mouse event because it's handled in custom event
254 const willEv = this.fireEvent("$willSubmit", {
255 bubbles: true,
256 cancelable: true,
257 detail: {
258 relatedEvent: e,
259 relatedForm: this,
260 submitter,
261 },
262 });
263 if (willEv.defaultPrevented) {
264 return;
265 }
266 // validate
267 let errCtrl;
268 let arrCtrl = this.$controlsAttached;
269 if (this._opts.submitActions & 2 /* SubmitActions.validateUntilFirst */) {
270 errCtrl = arrCtrl.find((c) => c.validateBySubmit() && c.canShowError);
271 }
272 else {
273 arrCtrl.forEach((c) => {
274 const err = c.validateBySubmit();
275 if (err && !errCtrl && c.canShowError) {
276 errCtrl = c;
277 }
278 });
279 }
280 // analyze - go to error
281 if (errCtrl) {
282 if (this._opts.submitActions & 1 /* SubmitActions.goToError */) {
283 const el = errCtrl;
284 scrollIntoView(el, { offsetTop: -30, onlyIfNeeded: true }).then(() => el.focus());
285 }
286 return;
287 }
288 // collect changes only from valid controls: because some controls has canShowError: false
289 arrCtrl = arrCtrl.filter((c) => c.canShowError);
290 // collect values to model
291 const onlyChanged = this._opts.submitActions & 4 /* SubmitActions.collectChanged */;
292 const m = this.#ctr.$modelFromControls({}, arrCtrl, "$value", !!onlyChanged);
293 // fire events
294 const ev = new CustomEvent("$submit", {
295 cancelable: false,
296 bubbles: true,
297 detail: {
298 model: m,
299 relatedEvent: e,
300 relatedForm: this,
301 submitter,
302 },
303 });
304 const needReset = this._opts.submitActions & 8 /* SubmitActions.reset */;
305 setTimeout(() => {
306 const p1 = this.$onSubmit?.call(this, ev);
307 this.dispatchEvent(ev);
308 // SubmitEvent constructor doesn't exist on some browsers: https://developer.mozilla.org/en-US/docs/Web/API/SubmitEvent/SubmitEvent
309 const ev2 = new (window.SubmitEvent || Event)("submit", { submitter, cancelable: false, bubbles: true });
310 /* istanbul ignore else */
311 if (!window.SubmitEvent) {
312 ev2.submitter = submitter;
313 }
314 this.dispatchEvent(ev2);
315 let success = false;
316 promiseWait(Promise.all([p1, ev.detail.waitFor]), 300, (v) => this.changePending(v))
317 .then(() => {
318 this._opts.autoStore && this.storageSave(null); // clear storage after submit
319 if (needReset) {
320 arrCtrl.forEach((v) => (v.$isDirty = false));
321 this.$initModel = this.$model;
322 }
323 success = true;
324 })
325 .finally(() => {
326 this.fireEvent("$submitEnd", { detail: { success }, cancelable: false, bubbles: true });
327 });
328 });
329 }
330 /** Auto-safe debounce timeout */
331 #autoStoreT;
332 #autoStoreRemEv;
333 gotChanges(propsChanged) {
334 super.gotChanges(propsChanged);
335 this.setAttr("readonly", this._opts.readOnly, true);
336 this.setAttr("disabled", this._opts.disabled, true);
337 if (this._opts.autoStore) {
338 this.#autoStoreRemEv = this.appendEvent(this, "$change", () => {
339 if (this._preventStorageSave) {
340 return;
341 }
342 this.#autoStoreT && clearTimeout(this.#autoStoreT);
343 this.#autoStoreT = setTimeout(() => this._opts.autoStore && this.storageSave(), 700);
344 });
345 // works only on init
346 !propsChanged &&
347 setTimeout(() => {
348 const m = this.storageGet();
349 if (m) {
350 this._preventStorageSave = true;
351 this.$model = { ...this._initModel, ...m, ...this._model };
352 setTimeout(() => delete this._preventStorageSave);
353 }
354 }, 2); // timeout required to wait for init controls
355 }
356 else if (this.#autoStoreRemEv) {
357 this.#autoStoreRemEv.call(this);
358 this.#autoStoreRemEv = undefined;
359 }
360 const p = propsChanged;
361 if (p && (p.includes("disabled") || p.includes("autoComplete") || p.includes("readOnly"))) {
362 this.$controls.forEach((c) => c.gotFormChanges(propsChanged));
363 }
364 }
365 gotReady() {
366 super.gotReady();
367 // tie with heading if possible
368 if (["title", "aria-label", "aria-labelledby"].every((a) => !this.hasAttribute(a))) {
369 const meHeading = (el) => el && (/H[\d]/.test(el.tagName) || el.getAttribute("role") === "heading") ? el : null;
370 const h = meHeading(this.firstElementChild) ?? meHeading(this.previousElementSibling);
371 if (h) {
372 h.id ||= this.#ctr.$uniqueId;
373 this.setAttribute("aria-labelledby", h.id);
374 }
375 }
376 this.appendEvent(this, "keydown", (e) => e.key === "Enter" &&
377 !e.defaultPrevented &&
378 !e.submitPrevented && // textarea related
379 !e.shiftKey &&
380 !e.ctrlKey &&
381 this.gotSubmit(e, e.target instanceof HTMLElement ? e.target : this), { passive: false });
382 this.appendEvent(this, "click", (e) => {
383 if (!e.defaultPrevented) {
384 let t = e.target;
385 while (t instanceof HTMLElement && t !== this) {
386 if (t.type === "submit") {
387 this.gotSubmit(e, t);
388 return;
389 }
390 t = t.parentElement;
391 }
392 }
393 }, { passive: false });
394 }
395 connectedCallback() {
396 super.connectedCallback();
397 this.setAttribute("role", "form");
398 formStore.push(this);
399 }
400 /** Returns storage key based on url+control-names or `$options.autoStore` if `string` */
401 get storageKey() {
402 const sn = this._opts.autoStore;
403 return typeof sn === "string"
404 ? sn
405 : `${window.location.pathname}?${this.$controls
406 .map((c) => c.$options.name)
407 .filter((v) => v)
408 .sort()
409 .join(",")}`;
410 }
411 /** Get & parse value from storage according to option `autoStore`, $model */
412 storageGet() {
413 try {
414 const key = this.storageKey;
415 const sv = window.localStorage.getItem(key);
416 if (sv) {
417 const m = JSON.parse(sv);
418 let hasVals = false;
419 this.$controls.forEach((c) => {
420 const n = c.$options.name;
421 if (!n) {
422 return;
423 }
424 const v = m[n];
425 if (v === undefined) {
426 return;
427 }
428 const parsed = typeof v === "string" ? c.parse(v) : v;
429 nestedProperty.set(m, n, parsed);
430 if (n.includes(".")) {
431 delete m[n];
432 }
433 hasVals = true;
434 });
435 if (!hasVals) {
436 return null;
437 }
438 return m;
439 }
440 }
441 catch (err) {
442 this.throwError(err); // re-throw error when storage is full
443 }
444 return null;
445 }
446 _preventStorageSave;
447 /** Save/remove model (only changes) to storage according to option `autoStore`, $model
448 * @returns model saved to localStorage or Null if removed */
449 storageSave(model = {}) {
450 try {
451 const storage = window.localStorage;
452 if (model == null) {
453 storage.removeItem(this.storageKey);
454 return null;
455 }
456 const m = {}; // plain model without nested objects
457 let hasChanges = false;
458 this.$controls.forEach((c) => {
459 // ignore password controls and controls without names
460 if (c.$options.name && c.tagName !== "WUP-PWD") {
461 const v = c.$value;
462 if (c.$isChanged && v !== undefined) {
463 if (typeof v === "object" && !v.toJSON && !Array.isArray(v)) {
464 return; // skip complex objects that not serializable: otherwise it can throw err on parse
465 }
466 m[c.$options.name] = v; // get only changed values
467 hasChanges = true;
468 }
469 }
470 });
471 if (hasChanges) {
472 storage.setItem(this.storageKey, JSON.stringify(m));
473 return m;
474 }
475 storage.removeItem(this.storageKey);
476 }
477 catch (err) {
478 this.throwError(err); // re-throw error when storage is full
479 }
480 return null;
481 }
482 disconnectedCallback() {
483 super.disconnectedCallback();
484 formStore.splice(formStore.indexOf(this), 1);
485 }
486}
487customElements.define(tagName, WUPFormElement);
488// todo show success/error result in <wup-alert> at the left/right angle + add autoSubmit option