@jsenv/navi 0.9.2 → 0.10.0

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.
@@ -27,6 +27,20 @@
27
27
  * - Validation messages that follow the input element and adapt to viewport
28
28
  */
29
29
 
30
+ /**
31
+ * To enable this API one have to call installCustomConstraintValidation(element)
32
+ * on the <form> and every element within the <form> (<input>, <button>, etc.)
33
+ * (In practice this is done automatically by jsx components in navi package)
34
+ *
35
+ * Once installed code must now listen to specific action events on the <form>
36
+ * (not "submit" but "actionrequested" most notably)
37
+ *
38
+ * There is one way to fully bypass validation which is to call form.submit()
39
+ * just like you could do with the native validation API to bypass validation.
40
+ * We keep this behavior on purpose but in practice you always want to go through the form validation process
41
+ */
42
+
43
+ import { createPubSub } from "@jsenv/dom";
30
44
  import { openCallout } from "../components/callout/callout.js";
31
45
  import {
32
46
  DISABLED_CONSTRAINT,
@@ -40,6 +54,8 @@ import {
40
54
  TYPE_NUMBER_CONSTRAINT,
41
55
  } from "./constraints/native_constraints.js";
42
56
  import { READONLY_CONSTRAINT } from "./constraints/readonly_constraint.js";
57
+ import { SAME_AS_CONSTRAINT } from "./constraints/same_as_constraint.js";
58
+ import { listenInputChange } from "./input_change_effect.js";
43
59
 
44
60
  let debug = false;
45
61
 
@@ -49,9 +65,9 @@ export const requestAction = (
49
65
  target,
50
66
  action,
51
67
  {
68
+ actionOrigin,
52
69
  event,
53
70
  requester = target,
54
- actionOrigin,
55
71
  method = "rerun",
56
72
  meta = {},
57
73
  confirmMessage,
@@ -132,12 +148,8 @@ export const requestAction = (
132
148
  // Single element validation case
133
149
  isValid = validationInterface.checkValidity({ fromRequestAction: true });
134
150
  if (!isValid) {
135
- if (event) {
136
- event.preventDefault();
137
- }
138
151
  validationInterface.reportValidity();
139
152
  }
140
-
141
153
  elementForConfirmation = target;
142
154
  elementForDispatch = target;
143
155
  }
@@ -180,6 +192,15 @@ export const requestAction = (
180
192
  return true;
181
193
  };
182
194
 
195
+ export const forwardActionRequested = (e, action, target = e.target) => {
196
+ requestAction(target, action, {
197
+ actionOrigin: e.detail?.actionOrigin,
198
+ event: e.detail?.event || e,
199
+ requester: e.detail?.requester,
200
+ meta: e.detail?.meta,
201
+ });
202
+ };
203
+
183
204
  export const closeValidationMessage = (element, reason) => {
184
205
  const validationInterface = element.__validationInterface__;
185
206
  if (!validationInterface) {
@@ -200,6 +221,7 @@ export const checkValidity = (element) => {
200
221
  return validationInterface.checkValidity();
201
222
  };
202
223
 
224
+ const formInstrumentedWeakSet = new WeakSet();
203
225
  export const installCustomConstraintValidation = (
204
226
  element,
205
227
  elementReceivingValidationMessage = element,
@@ -218,20 +240,25 @@ export const installCustomConstraintValidation = (
218
240
  validationMessage: null,
219
241
  };
220
242
 
221
- const cleanupCallbackSet = new Set();
243
+ const [teardown, addTeardown] = createPubSub();
222
244
  cleanup: {
223
245
  const uninstall = () => {
224
- for (const cleanupCallback of cleanupCallbackSet) {
225
- cleanupCallback();
226
- }
227
- cleanupCallbackSet.clear();
246
+ teardown();
228
247
  };
229
248
  validationInterface.uninstall = uninstall;
230
249
  }
231
250
 
251
+ const isForm = element.tagName === "FORM";
252
+ if (isForm) {
253
+ formInstrumentedWeakSet.add(element);
254
+ addTeardown(() => {
255
+ formInstrumentedWeakSet.delete(element);
256
+ });
257
+ }
258
+
232
259
  expose_as_node_property: {
233
260
  element.__validationInterface__ = validationInterface;
234
- cleanupCallbackSet.add(() => {
261
+ addTeardown(() => {
235
262
  delete element.__validationInterface__;
236
263
  });
237
264
  }
@@ -240,7 +267,6 @@ export const installCustomConstraintValidation = (
240
267
  const cancelEvent = new CustomEvent("cancel", options);
241
268
  element.dispatchEvent(cancelEvent);
242
269
  };
243
-
244
270
  const closeElementValidationMessage = (reason) => {
245
271
  if (validationInterface.validationMessage) {
246
272
  validationInterface.validationMessage.close(reason);
@@ -260,6 +286,7 @@ export const installCustomConstraintValidation = (
260
286
  constraintSet.add(MIN_CONSTRAINT);
261
287
  constraintSet.add(MAX_CONSTRAINT);
262
288
  constraintSet.add(READONLY_CONSTRAINT);
289
+ constraintSet.add(SAME_AS_CONSTRAINT);
263
290
  register_constraint: {
264
291
  validationInterface.registerConstraint = (constraint) => {
265
292
  if (typeof constraint === "function") {
@@ -278,6 +305,8 @@ export const installCustomConstraintValidation = (
278
305
  let failedConstraintInfo = null;
279
306
  const validityInfoMap = new Map();
280
307
 
308
+ const hasTitleAttribute = element.hasAttribute("title");
309
+
281
310
  const resetValidity = ({ fromRequestAction } = {}) => {
282
311
  if (fromRequestAction && failedConstraintInfo) {
283
312
  for (const [key, customMessage] of customMessageMap) {
@@ -295,7 +324,7 @@ export const installCustomConstraintValidation = (
295
324
  validityInfoMap.clear();
296
325
  failedConstraintInfo = null;
297
326
  };
298
- cleanupCallbackSet.add(resetValidity);
327
+ addTeardown(resetValidity);
299
328
 
300
329
  const checkValidity = ({ fromRequestAction, skipReadonly } = {}) => {
301
330
  resetValidity({ fromRequestAction });
@@ -340,7 +369,16 @@ export const installCustomConstraintValidation = (
340
369
  validityInfoMap.set(constraint, failedConstraintInfo);
341
370
  }
342
371
 
343
- if (!failedConstraintInfo) {
372
+ if (failedConstraintInfo) {
373
+ if (!hasTitleAttribute) {
374
+ // when a constraint is failing browser displays that constraint message if the element has no title attribute.
375
+ // We want to do the same with our message (overriding the browser in the process to get better messages)
376
+ element.setAttribute("title", failedConstraintInfo.message);
377
+ }
378
+ } else {
379
+ if (!hasTitleAttribute) {
380
+ element.removeAttribute("title");
381
+ }
344
382
  closeElementValidationMessage("becomes_valid");
345
383
  }
346
384
 
@@ -366,9 +404,9 @@ export const installCustomConstraintValidation = (
366
404
  if (!skipFocus) {
367
405
  element.focus();
368
406
  }
369
- const closeOnCleanup = () => {
407
+ const removeCloseOnCleanup = addTeardown(() => {
370
408
  closeElementValidationMessage("cleanup");
371
- };
409
+ });
372
410
 
373
411
  const anchorElement =
374
412
  failedConstraintInfo.target || elementReceivingValidationMessage;
@@ -379,7 +417,7 @@ export const installCustomConstraintValidation = (
379
417
  level: failedConstraintInfo.level,
380
418
  closeOnClickOutside: failedConstraintInfo.closeOnClickOutside,
381
419
  onClose: () => {
382
- cleanupCallbackSet.delete(closeOnCleanup);
420
+ removeCloseOnCleanup();
383
421
  validationInterface.validationMessage = null;
384
422
  if (failedConstraintInfo) {
385
423
  failedConstraintInfo.reportStatus = "closed";
@@ -391,7 +429,6 @@ export const installCustomConstraintValidation = (
391
429
  },
392
430
  );
393
431
  failedConstraintInfo.reportStatus = "reported";
394
- cleanupCallbackSet.add(closeOnCleanup);
395
432
  };
396
433
  validationInterface.checkValidity = checkValidity;
397
434
  validationInterface.reportValidity = reportValidity;
@@ -426,7 +463,7 @@ export const installCustomConstraintValidation = (
426
463
  reportValidity();
427
464
  }
428
465
  };
429
- cleanupCallbackSet.add(() => {
466
+ addTeardown(() => {
430
467
  customMessageMap.clear();
431
468
  });
432
469
  Object.assign(validationInterface, {
@@ -435,6 +472,7 @@ export const installCustomConstraintValidation = (
435
472
  });
436
473
  }
437
474
 
475
+ checkValidity();
438
476
  close_and_check_on_input: {
439
477
  const oninput = () => {
440
478
  customMessageMap.clear();
@@ -442,7 +480,7 @@ export const installCustomConstraintValidation = (
442
480
  checkValidity();
443
481
  };
444
482
  element.addEventListener("input", oninput);
445
- cleanupCallbackSet.add(() => {
483
+ addTeardown(() => {
446
484
  element.removeEventListener("input", oninput);
447
485
  });
448
486
  }
@@ -454,13 +492,7 @@ export const installCustomConstraintValidation = (
454
492
  checkValidity();
455
493
  };
456
494
  element.addEventListener("actionend", onactionend);
457
- if (element.form) {
458
- element.form.addEventListener("actionend", onactionend);
459
- cleanupCallbackSet.add(() => {
460
- element.form.removeEventListener("actionend", onactionend);
461
- });
462
- }
463
- cleanupCallbackSet.add(() => {
495
+ addTeardown(() => {
464
496
  element.removeEventListener("actionend", onactionend);
465
497
  });
466
498
  }
@@ -470,37 +502,119 @@ export const installCustomConstraintValidation = (
470
502
  element.reportValidity = () => {
471
503
  reportValidity();
472
504
  };
473
- cleanupCallbackSet.add(() => {
505
+ addTeardown(() => {
474
506
  element.reportValidity = nativeReportValidity;
475
507
  });
476
508
  }
477
509
 
478
- dispatch_request_submit_call_on_form: {
479
- const onRequestSubmit = (form, e) => {
480
- if (form !== element.form && form !== element) {
510
+ request_on_enter: {
511
+ if (element.tagName !== "INPUT") {
512
+ // maybe we want it too for checkboxes etc, we'll see
513
+ break request_on_enter;
514
+ }
515
+ const onkeydown = (keydownEvent) => {
516
+ if (keydownEvent.defaultPrevented) {
481
517
  return;
482
518
  }
483
-
484
- const requestSubmitCustomEvent = new CustomEvent("requestsubmit", {
485
- cancelable: true,
486
- detail: { cause: e },
519
+ if (keydownEvent.key !== "Enter") {
520
+ return;
521
+ }
522
+ if (element.hasAttribute("data-action")) {
523
+ if (wouldKeydownSubmitForm(keydownEvent)) {
524
+ keydownEvent.preventDefault();
525
+ }
526
+ dispatchActionRequestedCustomEvent(element, {
527
+ event: keydownEvent,
528
+ requester: element,
529
+ });
530
+ return;
531
+ }
532
+ const { form } = element;
533
+ if (!form) {
534
+ return;
535
+ }
536
+ keydownEvent.preventDefault();
537
+ dispatchActionRequestedCustomEvent(form, {
538
+ event: keydownEvent,
539
+ requester: getFirstButtonSubmittingForm(form) || element,
487
540
  });
488
- form.dispatchEvent(requestSubmitCustomEvent);
489
- if (requestSubmitCustomEvent.defaultPrevented) {
490
- e.preventDefault();
541
+ };
542
+ element.addEventListener("keydown", onkeydown);
543
+ addTeardown(() => {
544
+ element.removeEventListener("keydown", onkeydown);
545
+ });
546
+ }
547
+
548
+ request_on_button_click: {
549
+ const onclick = (clickEvent) => {
550
+ if (clickEvent.defaultPrevented) {
551
+ return;
552
+ }
553
+ if (element.tagName !== "BUTTON") {
554
+ return;
491
555
  }
556
+ if (element.hasAttribute("data-action")) {
557
+ if (wouldClickSubmitForm(clickEvent)) {
558
+ clickEvent.preventDefault();
559
+ }
560
+ dispatchActionRequestedCustomEvent(element, {
561
+ event: clickEvent,
562
+ requester: element,
563
+ });
564
+ return;
565
+ }
566
+ const { form } = element;
567
+ if (!form) {
568
+ return;
569
+ }
570
+ if (wouldClickSubmitForm(clickEvent)) {
571
+ clickEvent.preventDefault();
572
+ }
573
+ dispatchActionRequestedCustomEvent(form, {
574
+ event: clickEvent,
575
+ requester: element,
576
+ });
492
577
  };
493
- requestSubmitCallbackSet.add(onRequestSubmit);
494
- cleanupCallbackSet.add(() => {
495
- requestSubmitCallbackSet.delete(onRequestSubmit);
578
+ element.addEventListener("click", onclick);
579
+ addTeardown(() => {
580
+ element.removeEventListener("click", onclick);
581
+ });
582
+ }
583
+
584
+ request_on_input_change: {
585
+ const isInput =
586
+ element.tagName === "INPUT" || element.tagName === "TEXTAREA";
587
+ if (!isInput) {
588
+ break request_on_input_change;
589
+ }
590
+ const stop = listenInputChange(element, (e) => {
591
+ if (element.hasAttribute("data-action")) {
592
+ dispatchActionRequestedCustomEvent(element, {
593
+ event: e,
594
+ requester: element,
595
+ });
596
+ return;
597
+ }
598
+ const { form } = element;
599
+ if (!form) {
600
+ return;
601
+ }
602
+ dispatchActionRequestedCustomEvent(form, {
603
+ event: e,
604
+ requester: element,
605
+ });
606
+ });
607
+ addTeardown(() => {
608
+ stop();
496
609
  });
497
610
  }
498
611
 
499
612
  execute_on_form_submit: {
500
- const form = element.form || element.tagName === "FORM" ? element : null;
501
- if (!form) {
613
+ if (!isForm) {
502
614
  break execute_on_form_submit;
503
615
  }
616
+ // We will dispatch "action" when "submit" occurs (code called from.submit() to bypass validation)
617
+ const form = element;
504
618
  const removeListener = addEventListener(form, "submit", (e) => {
505
619
  e.preventDefault();
506
620
  if (debug) {
@@ -512,12 +626,14 @@ export const installCustomConstraintValidation = (
512
626
  event: e,
513
627
  method: "rerun",
514
628
  requester: form,
515
- meta: {},
629
+ meta: {
630
+ isSubmit: true,
631
+ },
516
632
  },
517
633
  });
518
634
  form.dispatchEvent(actionCustomEvent);
519
635
  });
520
- cleanupCallbackSet.add(() => {
636
+ addTeardown(() => {
521
637
  removeListener();
522
638
  });
523
639
  }
@@ -531,7 +647,7 @@ export const installCustomConstraintValidation = (
531
647
  }
532
648
  };
533
649
  element.addEventListener("keydown", onkeydown);
534
- cleanupCallbackSet.add(() => {
650
+ addTeardown(() => {
535
651
  element.removeEventListener("keydown", onkeydown);
536
652
  });
537
653
  }
@@ -539,7 +655,11 @@ export const installCustomConstraintValidation = (
539
655
  cancel_on_blur: {
540
656
  const onblur = () => {
541
657
  if (element.value === "") {
542
- dispatchCancelCustomEvent({ detail: { reason: "blur_empty" } });
658
+ dispatchCancelCustomEvent({
659
+ detail: {
660
+ reason: "blur_empty",
661
+ },
662
+ });
543
663
  return;
544
664
  }
545
665
  // if we have failed constraint, we cancel too
@@ -554,7 +674,7 @@ export const installCustomConstraintValidation = (
554
674
  }
555
675
  };
556
676
  element.addEventListener("blur", onblur);
557
- cleanupCallbackSet.add(() => {
677
+ addTeardown(() => {
558
678
  element.removeEventListener("blur", onblur);
559
679
  });
560
680
  }
@@ -562,22 +682,105 @@ export const installCustomConstraintValidation = (
562
682
  return validationInterface;
563
683
  };
564
684
 
565
- // https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation
685
+ const wouldClickSubmitForm = (clickEvent) => {
686
+ if (clickEvent.defaultPrevented) {
687
+ return false;
688
+ }
689
+ const clickTarget = clickEvent.target;
690
+ const { form } = clickTarget;
691
+ if (!form) {
692
+ return false;
693
+ }
694
+ const button = clickTarget.closest("button");
695
+ if (!button) {
696
+ return false;
697
+ }
698
+ const wouldSubmitFormByType =
699
+ button.type === "submit" || button.type === "image";
700
+ if (wouldSubmitFormByType) {
701
+ return true;
702
+ }
703
+ if (button.type) {
704
+ return false;
705
+ }
706
+ if (getFirstButtonSubmittingForm(form)) {
707
+ // an other button is explicitly submitting the form, this one would not submit it
708
+ return false;
709
+ }
710
+ // this is the only button inside the form without type attribute, so it defaults to type="submit"
711
+ return true;
712
+ };
713
+ const getFirstButtonSubmittingForm = (form) => {
714
+ return form.querySelector(
715
+ `button[type="submit"], input[type="submit"], input[type="image"]`,
716
+ );
717
+ };
718
+
719
+ const wouldKeydownSubmitForm = (keydownEvent) => {
720
+ if (keydownEvent.defaultPrevented) {
721
+ return false;
722
+ }
723
+ const keydownTarget = keydownEvent.target;
724
+ const { form } = keydownTarget;
725
+ if (!form) {
726
+ return false;
727
+ }
728
+ if (keydownEvent.key !== "Enter") {
729
+ return false;
730
+ }
731
+ const isTextInput =
732
+ keydownTarget.tagName === "INPUT" || keydownTarget.tagName === "TEXTAREA";
733
+ if (!isTextInput) {
734
+ return false;
735
+ }
736
+ return true;
737
+ };
566
738
 
567
- const requestSubmitCallbackSet = new Set();
739
+ const dispatchActionRequestedCustomEvent = (
740
+ fieldOrForm,
741
+ { actionOrigin = "action_prop", event, requester },
742
+ ) => {
743
+ const actionRequestedCustomEvent = new CustomEvent("actionrequested", {
744
+ cancelable: true,
745
+ detail: {
746
+ actionOrigin,
747
+ event,
748
+ requester,
749
+ },
750
+ });
751
+ fieldOrForm.dispatchEvent(actionRequestedCustomEvent);
752
+ };
753
+ // https://developer.mozilla.org/en-US/docs/Web/HTML/Guides/Constraint_validation
568
754
  const requestSubmit = HTMLFormElement.prototype.requestSubmit;
569
755
  HTMLFormElement.prototype.requestSubmit = function (submitter) {
570
- let prevented = false;
571
- const preventDefault = () => {
572
- prevented = true;
573
- };
574
- for (const requestSubmitCallback of requestSubmitCallbackSet) {
575
- requestSubmitCallback(this, { submitter, preventDefault });
576
- }
577
- if (prevented) {
756
+ const form = this;
757
+ const isInstrumented = formInstrumentedWeakSet.has(form);
758
+ if (!isInstrumented) {
759
+ requestSubmit.call(form, submitter);
578
760
  return;
579
761
  }
580
- requestSubmit.call(this, submitter);
762
+ const programmaticEvent = new CustomEvent("programmatic_requestsubmit", {
763
+ cancelable: true,
764
+ detail: {
765
+ submitter,
766
+ },
767
+ });
768
+ dispatchActionRequestedCustomEvent(form, {
769
+ event: programmaticEvent,
770
+ requester: submitter,
771
+ });
772
+
773
+ // When all fields are valid calling the native requestSubmit would let browser go through the
774
+ // standard form validation steps leading to form submission.
775
+ // We don't want that because we have our own action system to handle forms
776
+ // If we did that the form submission would happen in parallel of our action system
777
+ // and because we listen to "submit" event to dispatch "action" event
778
+ // we would end up with two actions being executed.
779
+ //
780
+ // In case we have discrepencies in our implementation compared to the browser standard
781
+ // this also prevent the native validation message to show up.
782
+
783
+ // requestSubmit.call(this, submitter);
581
784
  };
582
785
 
583
786
  // const submit = HTMLFormElement.prototype.submit;