@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/custom-elements.json +21 -0
- package/dist/fml.css +2 -2
- package/dist/fml.css.map +1 -1
- package/dist/fml.iife.js +264 -3
- package/dist/fml.iife.js.map +1 -1
- package/dist/fml.iife.min.js +1 -1
- package/dist/fml.iife.min.js.map +1 -1
- package/dist/ful.css +2 -2
- package/dist/ful.css.map +1 -1
- package/dist/ful.d.mts +63 -4
- package/dist/ful.iife.js +263 -3
- package/dist/ful.iife.js.map +1 -1
- package/dist/ful.iife.min.js +1 -1
- package/dist/ful.iife.min.js.map +1 -1
- package/dist/ful.min.mjs +1 -1
- package/dist/ful.min.mjs.map +1 -1
- package/dist/ful.mjs +264 -5
- package/dist/ful.mjs.map +1 -1
- package/dist/vscode.html-custom-data.json +8 -0
- package/dist/web-types.json +21 -1
- package/package.json +1 -1
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.
|
|
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
|
-
/**
|
|
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,
|
|
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;
|