UNPKG

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