@optionfactory/fml 9.0.0-rc19 → 9.0.0-rc20

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/dist/fml.iife.js CHANGED
@@ -6501,6 +6501,31 @@ var fml = (function (exports) {
6501
6501
  }
6502
6502
  }
6503
6503
 
6504
+ /**
6505
+ * Asks before the page is left, through the browser's own `beforeunload`
6506
+ * prompt.
6507
+ */
6508
+ class PageLeave {
6509
+ /**
6510
+ * Asks before the page is left while `isDirty()` answers true, until
6511
+ * `signal` aborts. The browser words the prompt, and asks only once the
6512
+ * user has interacted with the page.
6513
+ * @param {() => boolean} isDirty
6514
+ * @param {AbortSignal} [signal]
6515
+ */
6516
+ static guard(isDirty, signal) {
6517
+ window.addEventListener(
6518
+ 'beforeunload',
6519
+ (evt) => {
6520
+ if (isDirty()) {
6521
+ evt.preventDefault();
6522
+ }
6523
+ },
6524
+ { signal },
6525
+ );
6526
+ }
6527
+ }
6528
+
6504
6529
  /**
6505
6530
  * A hold on one generation of a `Claims`: `stale` turns true once a later
6506
6531
  * `take()` or `invalidate()` on it starts a newer generation.
@@ -7588,6 +7613,10 @@ var fml = (function (exports) {
7588
7613
  *
7589
7614
  * A native submit, by a button or by Enter in a field, is stopped and runs
7590
7615
  * `submit` instead.
7616
+ *
7617
+ * `track-changes` keeps track of the edits not saved yet (see `dirty`), and
7618
+ * `guard-leave`, which implies it, asks before the page is left while there
7619
+ * are any.
7591
7620
  */
7592
7621
  class Form extends ParsedElement {
7593
7622
  /** Read once at the upgrade: changing one afterwards has no effect. */
@@ -7600,6 +7629,8 @@ var fml = (function (exports) {
7600
7629
  'clear-invalid-on-change:presence',
7601
7630
  'scroll-on-error:presence',
7602
7631
  'autocomplete',
7632
+ 'track-changes:presence',
7633
+ 'guard-leave:presence',
7603
7634
  ];
7604
7635
  /** The native form holding the fields, created by the render. */
7605
7636
  form;
@@ -7637,6 +7668,27 @@ var fml = (function (exports) {
7637
7668
  });
7638
7669
  }
7639
7670
  this.replaceChildren(form);
7671
+ this.#tracking = this.declared('track-changes') || this.declared('guard-leave');
7672
+ if (!this.#tracking) {
7673
+ return;
7674
+ }
7675
+ this.addEventListener('input', (evt) => this.#touch(evt.target));
7676
+ this.addEventListener('change', (evt) => this.#touch(evt.target));
7677
+ this.addEventListener('focusin', (evt) => this.#focus(evt.target));
7678
+ Rendering.waitForChildren(this)
7679
+ .catch(() => undefined)
7680
+ .then(() => this.commit());
7681
+ this.#guard();
7682
+ }
7683
+ connectedCallback() {
7684
+ super.connectedCallback();
7685
+ if (this.#tracking) {
7686
+ this.#guard();
7687
+ }
7688
+ }
7689
+ disconnectedCallback() {
7690
+ this.#leave?.abort();
7691
+ this.#leave = null;
7640
7692
  }
7641
7693
  #submitting = false;
7642
7694
  /**
@@ -7655,7 +7707,9 @@ var fml = (function (exports) {
7655
7707
  * loader's `submit` as its third argument. A listener may replace
7656
7708
  * `request` on the detail
7657
7709
  * 6. has the loader `submit` the request and `transform` the response
7658
- * 7. fires `submit:success` with `{ submitter, values, request, response }`,
7710
+ * 7. under `track-changes`, commits the values extracted at step 2 (see
7711
+ * `commit`): an edit made while the submit was in flight stays unsaved
7712
+ * 8. fires `submit:success` with `{ submitter, values, request, response }`,
7659
7713
  * `response` being the transformed one
7660
7714
  *
7661
7715
  * Anything thrown from step 1 on, a missing loader or a throwing mapper
@@ -7681,6 +7735,7 @@ var fml = (function (exports) {
7681
7735
  try {
7682
7736
  const loader = this.component(this.declared('loader') ?? 'loaders:form').create(this);
7683
7737
  values = Bindings.extractFrom(this.form, submitter);
7738
+ const submitted = this.#tracking ? { saved: this.#snapshot(), upTo: this.#sequence } : null;
7684
7739
  request = await loader.prepare(values, this);
7685
7740
  const se = new CustomEvent('submit', {
7686
7741
  bubbles: true,
@@ -7701,6 +7756,9 @@ var fml = (function (exports) {
7701
7756
 
7702
7757
  response = await loader.submit(request, this, response);
7703
7758
  const mapped = await loader.transform(response, this);
7759
+ if (submitted) {
7760
+ this.#commitFrom(submitted.saved, submitted.upTo);
7761
+ }
7704
7762
  this.dispatchEvent(
7705
7763
  new CustomEvent('submit:success', {
7706
7764
  bubbles: true,
@@ -7726,9 +7784,208 @@ var fml = (function (exports) {
7726
7784
  this.spinner(false);
7727
7785
  }
7728
7786
  }
7729
- /** Resets the native form, each field returning to its default value. */
7787
+ /**
7788
+ * Resets the native form, each field returning to its default value. Under
7789
+ * `track-changes` every control counts as edited: the form is dirty if the
7790
+ * defaults differ from what was last committed.
7791
+ */
7730
7792
  reset() {
7731
7793
  this.form.reset();
7794
+ if (!this.#tracking) {
7795
+ return;
7796
+ }
7797
+ for (const name of new Set([...this.#saved.keys(), ...this.#snapshot().keys()])) {
7798
+ this.#edits.set(name, ++this.#sequence);
7799
+ }
7800
+ this.#refresh();
7801
+ }
7802
+ /** name → the serialized values the last commit recorded */
7803
+ #saved = new Map();
7804
+ /** name → the serialized values when first focused, for a name the last commit did not see */
7805
+ #focused = new Map();
7806
+ /** name → the sequence number of its latest edit since the last commit */
7807
+ #edits = new Map();
7808
+ #sequence = 0;
7809
+ #wasDirty = false;
7810
+ #frame = 0;
7811
+ /** @type {AbortController|null} */
7812
+ #leave = null;
7813
+ #tracking = false;
7814
+ /**
7815
+ * Whether, under `track-changes`, a control the user edited since the last
7816
+ * commit holds a value other than the one committed. An edit is what the
7817
+ * controls report as one: an `input` or a `change` bubbling from a control
7818
+ * of this form (0019), so assigning `value`, a select's vocabulary dropping
7819
+ * a key or a section delivering its fields is not one, and an edit undone
7820
+ * is no longer dirty. A control the last commit did not see compares with
7821
+ * its value when it was first focused. Always false without
7822
+ * `track-changes`.
7823
+ *
7824
+ * While dirty, the host matches `:state(dirty)` and the submit buttons of
7825
+ * this form carry `data-ful-dirty`; a change of the state dispatches
7826
+ * `dirty:change` with `{ dirty }`. Both are refreshed at the next frame
7827
+ * after an edit, while the property is exact at any time.
7828
+ * @returns {boolean}
7829
+ */
7830
+ get dirty() {
7831
+ if (!this.#tracking) {
7832
+ return false;
7833
+ }
7834
+ for (const name of this.#edits.keys()) {
7835
+ const now = this.#valuesOf(name);
7836
+ if (!this.#saved.has(name) && now === '[]') {
7837
+ continue;
7838
+ }
7839
+ const was = this.#saved.get(name) ?? this.#focused.get(name);
7840
+ if (was !== now) {
7841
+ return true;
7842
+ }
7843
+ }
7844
+ return false;
7845
+ }
7846
+ /**
7847
+ * Records the values of the controls as saved, so that the form is no
7848
+ * longer dirty. A submit commits by itself on success, and the form
7849
+ * commits once its fields have rendered: a page calls this for a save of
7850
+ * its own, or after writing the values it loaded. Does nothing without
7851
+ * `track-changes`.
7852
+ */
7853
+ commit() {
7854
+ if (this.#tracking) {
7855
+ this.#commitFrom(this.#snapshot(), this.#sequence);
7856
+ }
7857
+ }
7858
+ /**
7859
+ * Counts a control as edited by the user, for a value the page writes on
7860
+ * the user's behalf: assigning `value` reports nothing, as 0019 wants.
7861
+ * @param {Element} el the control, or an element inside it
7862
+ */
7863
+ edited(el) {
7864
+ this.#touch(el);
7865
+ }
7866
+ /**
7867
+ * @param {Map<string, string>} saved
7868
+ * @param {number} upTo the sequence number of the latest edit the values include
7869
+ */
7870
+ #commitFrom(saved, upTo) {
7871
+ this.#saved = saved;
7872
+ for (const [name, sequence] of this.#edits) {
7873
+ if (sequence <= upTo) {
7874
+ this.#edits.delete(name);
7875
+ }
7876
+ }
7877
+ for (const name of this.#focused.keys()) {
7878
+ if (saved.has(name)) {
7879
+ this.#focused.delete(name);
7880
+ }
7881
+ }
7882
+ this.#refresh();
7883
+ }
7884
+ /**
7885
+ * The named control of this form `target` is or sits in, if any: a field's
7886
+ * inner control is unnamed or carries `form=""`, a control of a form nested
7887
+ * in this one, like a table's filters, belongs to that form, and a
7888
+ * form-associated element with no `form` property answers its owner
7889
+ * through `internals.form`.
7890
+ * @param {any} target
7891
+ * @returns {string|null} its name
7892
+ */
7893
+ #nameOf(target) {
7894
+ for (let el = target; el && el !== this; el = el.parentElement) {
7895
+ if ((el.form ?? el.internals?.form) === this.form && el.hasAttribute('name')) {
7896
+ return el.getAttribute('name');
7897
+ }
7898
+ }
7899
+ return null;
7900
+ }
7901
+ /** @param {EventTarget|null} target */
7902
+ #touch(target) {
7903
+ if (!this.#tracking) {
7904
+ return;
7905
+ }
7906
+ const name = this.#nameOf(target);
7907
+ if (name === null) {
7908
+ return;
7909
+ }
7910
+ this.#edits.set(name, ++this.#sequence);
7911
+ if (this.#frame === 0) {
7912
+ this.#frame = requestAnimationFrame(() => this.#refresh());
7913
+ }
7914
+ }
7915
+ /** @param {EventTarget|null} target */
7916
+ #focus(target) {
7917
+ const name = this.#nameOf(target);
7918
+ if (name !== null && !this.#saved.has(name) && !this.#focused.has(name)) {
7919
+ this.#focused.set(name, this.#valuesOf(name));
7920
+ }
7921
+ }
7922
+ /**
7923
+ * The controls that contribute to the values, as `Bindings.extractFrom`
7924
+ * reads them, with no submitter.
7925
+ * @returns {any[]}
7926
+ */
7927
+ #contributing() {
7928
+ return Array.from(this.form.elements).filter(
7929
+ (el) =>
7930
+ el.hasAttribute('name') &&
7931
+ !['submit', 'reset', 'button'].includes(/** @type {any} */ (el).type) &&
7932
+ !el.matches(':disabled'),
7933
+ );
7934
+ }
7935
+ /** @returns {Map<string, string>} each name to its serialized values */
7936
+ #snapshot() {
7937
+ /** @type {Map<string, any[]>} */
7938
+ const values = new Map();
7939
+ for (const el of this.#contributing()) {
7940
+ const name = el.getAttribute('name');
7941
+ values.set(name, [...(values.get(name) ?? []), Bindings.extract(el)]);
7942
+ }
7943
+ return new Map(Array.from(values, ([name, vs]) => [name, JSON.stringify(vs)]));
7944
+ }
7945
+ /**
7946
+ * @param {string} name
7947
+ * @returns {string} the serialized values of the controls carrying it
7948
+ */
7949
+ #valuesOf(name) {
7950
+ return JSON.stringify(
7951
+ this.#contributing()
7952
+ .filter((el) => el.getAttribute('name') === name)
7953
+ .map((el) => Bindings.extract(el)),
7954
+ );
7955
+ }
7956
+ #refresh() {
7957
+ cancelAnimationFrame(this.#frame);
7958
+ this.#frame = 0;
7959
+ const dirty = this.dirty;
7960
+ this.querySelectorAll('button').forEach((el) => {
7961
+ const button = /** @type HTMLButtonElement */ (el);
7962
+ if (button.form === this.form && button.type === 'submit') {
7963
+ button.toggleAttribute('data-ful-dirty', dirty);
7964
+ }
7965
+ });
7966
+ if (dirty === this.#wasDirty) {
7967
+ return;
7968
+ }
7969
+ this.#wasDirty = dirty;
7970
+ if (dirty) {
7971
+ this.internals.states.add('dirty');
7972
+ } else {
7973
+ this.internals.states.delete('dirty');
7974
+ }
7975
+ this.dispatchEvent(
7976
+ new CustomEvent('dirty:change', {
7977
+ bubbles: true,
7978
+ cancelable: false,
7979
+ detail: { dirty },
7980
+ }),
7981
+ );
7982
+ }
7983
+ #guard() {
7984
+ if (!this.declared('guard-leave') || this.#leave) {
7985
+ return;
7986
+ }
7987
+ this.#leave = new AbortController();
7988
+ PageLeave.guard(() => this.dirty, this.#leave.signal);
7732
7989
  }
7733
7990
  #spinning = 0;
7734
7991
  /**
@@ -7916,7 +8173,9 @@ var fml = (function (exports) {
7916
8173
  * datetime-local, month or week control fires its native `change` while its
7917
8174
  * segments are typed, as soon as they make a valid value; for those the field
7918
8175
  * reports when the control is left or Enter is pressed, at once for a value
7919
- * picked from the browser's picker, and never a half-typed value.
8176
+ * picked from the browser's picker, never with the dates a year passes
8177
+ * through while it is typed. A control left incomplete reads as `null`, as
8178
+ * the native one does.
7920
8179
  *
7921
8180
  * A subclass template may render more than one control, as the compare filters
7922
8181
  * render the two bounds of a range: the first is `_input`, the one the field is
@@ -14329,6 +14588,7 @@ var fml = (function (exports) {
14329
14588
  LocalStorage: LocalStorage,
14330
14589
  Menu: Menu,
14331
14590
  NumberFilter: NumberFilter,
14591
+ PageLeave: PageLeave,
14332
14592
  Pagination: Pagination,
14333
14593
  Plugin: Plugin,
14334
14594
  RadioGroup: RadioGroup,
@@ -14403,6 +14663,7 @@ var fml = (function (exports) {
14403
14663
  exports.Menu = Menu;
14404
14664
  exports.Nodes = Nodes;
14405
14665
  exports.NumberFilter = NumberFilter;
14666
+ exports.PageLeave = PageLeave;
14406
14667
  exports.Pagination = Pagination;
14407
14668
  exports.ParsedElement = ParsedElement;
14408
14669
  exports.Plugin = Plugin;