@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/ful.d.mts CHANGED
@@ -123,6 +123,20 @@ declare class AsyncEvents {
123
123
  */
124
124
  static mixInto(...classes: Function[]): void;
125
125
  }
126
+ /**
127
+ * Asks before the page is left, through the browser's own `beforeunload`
128
+ * prompt.
129
+ */
130
+ declare class PageLeave {
131
+ /**
132
+ * Asks before the page is left while `isDirty()` answers true, until
133
+ * `signal` aborts. The browser words the prompt, and asks only once the
134
+ * user has interacted with the page.
135
+ * @param {() => boolean} isDirty
136
+ * @param {AbortSignal} [signal]
137
+ */
138
+ static guard(isDirty: () => boolean, signal?: AbortSignal): void;
139
+ }
126
140
  export type Claim = {
127
141
  readonly stale: boolean;
128
142
  };
@@ -722,6 +736,10 @@ declare class FormLoader {
722
736
  *
723
737
  * A native submit, by a button or by Enter in a field, is stopped and runs
724
738
  * `submit` instead.
739
+ *
740
+ * `track-changes` keeps track of the edits not saved yet (see `dirty`), and
741
+ * `guard-leave`, which implies it, asks before the page is left while there
742
+ * are any.
725
743
  */
726
744
  declare class Form extends ParsedElement {
727
745
  #private;
@@ -731,6 +749,8 @@ declare class Form extends ParsedElement {
731
749
  form: any;
732
750
  /** Builds the native form and moves the element's children into it. */
733
751
  render(): void;
752
+ connectedCallback(): void;
753
+ disconnectedCallback(): void;
734
754
  /**
735
755
  * Runs the submit pipeline, with the spinner up for its whole length:
736
756
  *
@@ -747,7 +767,9 @@ declare class Form extends ParsedElement {
747
767
  * loader's `submit` as its third argument. A listener may replace
748
768
  * `request` on the detail
749
769
  * 6. has the loader `submit` the request and `transform` the response
750
- * 7. fires `submit:success` with `{ submitter, values, request, response }`,
770
+ * 7. under `track-changes`, commits the values extracted at step 2 (see
771
+ * `commit`): an edit made while the submit was in flight stays unsaved
772
+ * 8. fires `submit:success` with `{ submitter, values, request, response }`,
751
773
  * `response` being the transformed one
752
774
  *
753
775
  * Anything thrown from step 1 on, a missing loader or a throwing mapper
@@ -763,8 +785,43 @@ declare class Form extends ParsedElement {
763
785
  * @returns {Promise<void>} settling once the submit has finished, whatever its outcome
764
786
  */
765
787
  submit(submitter?: HTMLElement): Promise<void>;
766
- /** Resets the native form, each field returning to its default value. */
788
+ /**
789
+ * Resets the native form, each field returning to its default value. Under
790
+ * `track-changes` every control counts as edited: the form is dirty if the
791
+ * defaults differ from what was last committed.
792
+ */
767
793
  reset(): void;
794
+ /**
795
+ * Whether, under `track-changes`, a control the user edited since the last
796
+ * commit holds a value other than the one committed. An edit is what the
797
+ * controls report as one: an `input` or a `change` bubbling from a control
798
+ * of this form (0019), so assigning `value`, a select's vocabulary dropping
799
+ * a key or a section delivering its fields is not one, and an edit undone
800
+ * is no longer dirty. A control the last commit did not see compares with
801
+ * its value when it was first focused. Always false without
802
+ * `track-changes`.
803
+ *
804
+ * While dirty, the host matches `:state(dirty)` and the submit buttons of
805
+ * this form carry `data-ful-dirty`; a change of the state dispatches
806
+ * `dirty:change` with `{ dirty }`. Both are refreshed at the next frame
807
+ * after an edit, while the property is exact at any time.
808
+ * @returns {boolean}
809
+ */
810
+ get dirty(): boolean;
811
+ /**
812
+ * Records the values of the controls as saved, so that the form is no
813
+ * longer dirty. A submit commits by itself on success, and the form
814
+ * commits once its fields have rendered: a page calls this for a save of
815
+ * its own, or after writing the values it loaded. Does nothing without
816
+ * `track-changes`.
817
+ */
818
+ commit(): void;
819
+ /**
820
+ * Counts a control as edited by the user, for a value the page writes on
821
+ * the user's behalf: assigning `value` reports nothing, as 0019 wants.
822
+ * @param {Element} el the control, or an element inside it
823
+ */
824
+ edited(el: Element): void;
768
825
  /**
769
826
  * Marks the form busy or releases it. Calls nest: only the first
770
827
  * `spinner(true)` and the matching last `spinner(false)` act, so a caller's
@@ -840,7 +897,9 @@ declare class Form extends ParsedElement {
840
897
  * datetime-local, month or week control fires its native `change` while its
841
898
  * segments are typed, as soon as they make a valid value; for those the field
842
899
  * reports when the control is left or Enter is pressed, at once for a value
843
- * picked from the browser's picker, and never a half-typed value.
900
+ * picked from the browser's picker, never with the dates a year passes
901
+ * through while it is typed. A control left incomplete reads as `null`, as
902
+ * the native one does.
844
903
  *
845
904
  * A subclass template may render more than one control, as the compare filters
846
905
  * render the two bounds of a range: the first is `_input`, the one the field is
@@ -3364,6 +3423,6 @@ declare class Plugin {
3364
3423
  defineOverlay(...data: any[]): any;
3365
3424
  }): void;
3366
3425
  }
3367
- export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, Menu, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
3426
+ export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, Menu, NumberFilter, PageLeave, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
3368
3427
 
3369
3428
  export as namespace ful;
package/dist/ful.iife.js CHANGED
@@ -231,6 +231,31 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
231
231
  }
232
232
  }
233
233
 
234
+ /**
235
+ * Asks before the page is left, through the browser's own `beforeunload`
236
+ * prompt.
237
+ */
238
+ class PageLeave {
239
+ /**
240
+ * Asks before the page is left while `isDirty()` answers true, until
241
+ * `signal` aborts. The browser words the prompt, and asks only once the
242
+ * user has interacted with the page.
243
+ * @param {() => boolean} isDirty
244
+ * @param {AbortSignal} [signal]
245
+ */
246
+ static guard(isDirty, signal) {
247
+ window.addEventListener(
248
+ 'beforeunload',
249
+ (evt) => {
250
+ if (isDirty()) {
251
+ evt.preventDefault();
252
+ }
253
+ },
254
+ { signal },
255
+ );
256
+ }
257
+ }
258
+
234
259
  /**
235
260
  * A hold on one generation of a `Claims`: `stale` turns true once a later
236
261
  * `take()` or `invalidate()` on it starts a newer generation.
@@ -1318,6 +1343,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1318
1343
  *
1319
1344
  * A native submit, by a button or by Enter in a field, is stopped and runs
1320
1345
  * `submit` instead.
1346
+ *
1347
+ * `track-changes` keeps track of the edits not saved yet (see `dirty`), and
1348
+ * `guard-leave`, which implies it, asks before the page is left while there
1349
+ * are any.
1321
1350
  */
1322
1351
  class Form extends index_mjs.ParsedElement {
1323
1352
  /** Read once at the upgrade: changing one afterwards has no effect. */
@@ -1330,6 +1359,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1330
1359
  'clear-invalid-on-change:presence',
1331
1360
  'scroll-on-error:presence',
1332
1361
  'autocomplete',
1362
+ 'track-changes:presence',
1363
+ 'guard-leave:presence',
1333
1364
  ];
1334
1365
  /** The native form holding the fields, created by the render. */
1335
1366
  form;
@@ -1367,6 +1398,27 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1367
1398
  });
1368
1399
  }
1369
1400
  this.replaceChildren(form);
1401
+ this.#tracking = this.declared('track-changes') || this.declared('guard-leave');
1402
+ if (!this.#tracking) {
1403
+ return;
1404
+ }
1405
+ this.addEventListener('input', (evt) => this.#touch(evt.target));
1406
+ this.addEventListener('change', (evt) => this.#touch(evt.target));
1407
+ this.addEventListener('focusin', (evt) => this.#focus(evt.target));
1408
+ index_mjs.Rendering.waitForChildren(this)
1409
+ .catch(() => undefined)
1410
+ .then(() => this.commit());
1411
+ this.#guard();
1412
+ }
1413
+ connectedCallback() {
1414
+ super.connectedCallback();
1415
+ if (this.#tracking) {
1416
+ this.#guard();
1417
+ }
1418
+ }
1419
+ disconnectedCallback() {
1420
+ this.#leave?.abort();
1421
+ this.#leave = null;
1370
1422
  }
1371
1423
  #submitting = false;
1372
1424
  /**
@@ -1385,7 +1437,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1385
1437
  * loader's `submit` as its third argument. A listener may replace
1386
1438
  * `request` on the detail
1387
1439
  * 6. has the loader `submit` the request and `transform` the response
1388
- * 7. fires `submit:success` with `{ submitter, values, request, response }`,
1440
+ * 7. under `track-changes`, commits the values extracted at step 2 (see
1441
+ * `commit`): an edit made while the submit was in flight stays unsaved
1442
+ * 8. fires `submit:success` with `{ submitter, values, request, response }`,
1389
1443
  * `response` being the transformed one
1390
1444
  *
1391
1445
  * Anything thrown from step 1 on, a missing loader or a throwing mapper
@@ -1411,6 +1465,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1411
1465
  try {
1412
1466
  const loader = this.component(this.declared('loader') ?? 'loaders:form').create(this);
1413
1467
  values = Bindings.extractFrom(this.form, submitter);
1468
+ const submitted = this.#tracking ? { saved: this.#snapshot(), upTo: this.#sequence } : null;
1414
1469
  request = await loader.prepare(values, this);
1415
1470
  const se = new CustomEvent('submit', {
1416
1471
  bubbles: true,
@@ -1431,6 +1486,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1431
1486
 
1432
1487
  response = await loader.submit(request, this, response);
1433
1488
  const mapped = await loader.transform(response, this);
1489
+ if (submitted) {
1490
+ this.#commitFrom(submitted.saved, submitted.upTo);
1491
+ }
1434
1492
  this.dispatchEvent(
1435
1493
  new CustomEvent('submit:success', {
1436
1494
  bubbles: true,
@@ -1456,9 +1514,208 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1456
1514
  this.spinner(false);
1457
1515
  }
1458
1516
  }
1459
- /** Resets the native form, each field returning to its default value. */
1517
+ /**
1518
+ * Resets the native form, each field returning to its default value. Under
1519
+ * `track-changes` every control counts as edited: the form is dirty if the
1520
+ * defaults differ from what was last committed.
1521
+ */
1460
1522
  reset() {
1461
1523
  this.form.reset();
1524
+ if (!this.#tracking) {
1525
+ return;
1526
+ }
1527
+ for (const name of new Set([...this.#saved.keys(), ...this.#snapshot().keys()])) {
1528
+ this.#edits.set(name, ++this.#sequence);
1529
+ }
1530
+ this.#refresh();
1531
+ }
1532
+ /** name → the serialized values the last commit recorded */
1533
+ #saved = new Map();
1534
+ /** name → the serialized values when first focused, for a name the last commit did not see */
1535
+ #focused = new Map();
1536
+ /** name → the sequence number of its latest edit since the last commit */
1537
+ #edits = new Map();
1538
+ #sequence = 0;
1539
+ #wasDirty = false;
1540
+ #frame = 0;
1541
+ /** @type {AbortController|null} */
1542
+ #leave = null;
1543
+ #tracking = false;
1544
+ /**
1545
+ * Whether, under `track-changes`, a control the user edited since the last
1546
+ * commit holds a value other than the one committed. An edit is what the
1547
+ * controls report as one: an `input` or a `change` bubbling from a control
1548
+ * of this form (0019), so assigning `value`, a select's vocabulary dropping
1549
+ * a key or a section delivering its fields is not one, and an edit undone
1550
+ * is no longer dirty. A control the last commit did not see compares with
1551
+ * its value when it was first focused. Always false without
1552
+ * `track-changes`.
1553
+ *
1554
+ * While dirty, the host matches `:state(dirty)` and the submit buttons of
1555
+ * this form carry `data-ful-dirty`; a change of the state dispatches
1556
+ * `dirty:change` with `{ dirty }`. Both are refreshed at the next frame
1557
+ * after an edit, while the property is exact at any time.
1558
+ * @returns {boolean}
1559
+ */
1560
+ get dirty() {
1561
+ if (!this.#tracking) {
1562
+ return false;
1563
+ }
1564
+ for (const name of this.#edits.keys()) {
1565
+ const now = this.#valuesOf(name);
1566
+ if (!this.#saved.has(name) && now === '[]') {
1567
+ continue;
1568
+ }
1569
+ const was = this.#saved.get(name) ?? this.#focused.get(name);
1570
+ if (was !== now) {
1571
+ return true;
1572
+ }
1573
+ }
1574
+ return false;
1575
+ }
1576
+ /**
1577
+ * Records the values of the controls as saved, so that the form is no
1578
+ * longer dirty. A submit commits by itself on success, and the form
1579
+ * commits once its fields have rendered: a page calls this for a save of
1580
+ * its own, or after writing the values it loaded. Does nothing without
1581
+ * `track-changes`.
1582
+ */
1583
+ commit() {
1584
+ if (this.#tracking) {
1585
+ this.#commitFrom(this.#snapshot(), this.#sequence);
1586
+ }
1587
+ }
1588
+ /**
1589
+ * Counts a control as edited by the user, for a value the page writes on
1590
+ * the user's behalf: assigning `value` reports nothing, as 0019 wants.
1591
+ * @param {Element} el the control, or an element inside it
1592
+ */
1593
+ edited(el) {
1594
+ this.#touch(el);
1595
+ }
1596
+ /**
1597
+ * @param {Map<string, string>} saved
1598
+ * @param {number} upTo the sequence number of the latest edit the values include
1599
+ */
1600
+ #commitFrom(saved, upTo) {
1601
+ this.#saved = saved;
1602
+ for (const [name, sequence] of this.#edits) {
1603
+ if (sequence <= upTo) {
1604
+ this.#edits.delete(name);
1605
+ }
1606
+ }
1607
+ for (const name of this.#focused.keys()) {
1608
+ if (saved.has(name)) {
1609
+ this.#focused.delete(name);
1610
+ }
1611
+ }
1612
+ this.#refresh();
1613
+ }
1614
+ /**
1615
+ * The named control of this form `target` is or sits in, if any: a field's
1616
+ * inner control is unnamed or carries `form=""`, a control of a form nested
1617
+ * in this one, like a table's filters, belongs to that form, and a
1618
+ * form-associated element with no `form` property answers its owner
1619
+ * through `internals.form`.
1620
+ * @param {any} target
1621
+ * @returns {string|null} its name
1622
+ */
1623
+ #nameOf(target) {
1624
+ for (let el = target; el && el !== this; el = el.parentElement) {
1625
+ if ((el.form ?? el.internals?.form) === this.form && el.hasAttribute('name')) {
1626
+ return el.getAttribute('name');
1627
+ }
1628
+ }
1629
+ return null;
1630
+ }
1631
+ /** @param {EventTarget|null} target */
1632
+ #touch(target) {
1633
+ if (!this.#tracking) {
1634
+ return;
1635
+ }
1636
+ const name = this.#nameOf(target);
1637
+ if (name === null) {
1638
+ return;
1639
+ }
1640
+ this.#edits.set(name, ++this.#sequence);
1641
+ if (this.#frame === 0) {
1642
+ this.#frame = requestAnimationFrame(() => this.#refresh());
1643
+ }
1644
+ }
1645
+ /** @param {EventTarget|null} target */
1646
+ #focus(target) {
1647
+ const name = this.#nameOf(target);
1648
+ if (name !== null && !this.#saved.has(name) && !this.#focused.has(name)) {
1649
+ this.#focused.set(name, this.#valuesOf(name));
1650
+ }
1651
+ }
1652
+ /**
1653
+ * The controls that contribute to the values, as `Bindings.extractFrom`
1654
+ * reads them, with no submitter.
1655
+ * @returns {any[]}
1656
+ */
1657
+ #contributing() {
1658
+ return Array.from(this.form.elements).filter(
1659
+ (el) =>
1660
+ el.hasAttribute('name') &&
1661
+ !['submit', 'reset', 'button'].includes(/** @type {any} */ (el).type) &&
1662
+ !el.matches(':disabled'),
1663
+ );
1664
+ }
1665
+ /** @returns {Map<string, string>} each name to its serialized values */
1666
+ #snapshot() {
1667
+ /** @type {Map<string, any[]>} */
1668
+ const values = new Map();
1669
+ for (const el of this.#contributing()) {
1670
+ const name = el.getAttribute('name');
1671
+ values.set(name, [...(values.get(name) ?? []), Bindings.extract(el)]);
1672
+ }
1673
+ return new Map(Array.from(values, ([name, vs]) => [name, JSON.stringify(vs)]));
1674
+ }
1675
+ /**
1676
+ * @param {string} name
1677
+ * @returns {string} the serialized values of the controls carrying it
1678
+ */
1679
+ #valuesOf(name) {
1680
+ return JSON.stringify(
1681
+ this.#contributing()
1682
+ .filter((el) => el.getAttribute('name') === name)
1683
+ .map((el) => Bindings.extract(el)),
1684
+ );
1685
+ }
1686
+ #refresh() {
1687
+ cancelAnimationFrame(this.#frame);
1688
+ this.#frame = 0;
1689
+ const dirty = this.dirty;
1690
+ this.querySelectorAll('button').forEach((el) => {
1691
+ const button = /** @type HTMLButtonElement */ (el);
1692
+ if (button.form === this.form && button.type === 'submit') {
1693
+ button.toggleAttribute('data-ful-dirty', dirty);
1694
+ }
1695
+ });
1696
+ if (dirty === this.#wasDirty) {
1697
+ return;
1698
+ }
1699
+ this.#wasDirty = dirty;
1700
+ if (dirty) {
1701
+ this.internals.states.add('dirty');
1702
+ } else {
1703
+ this.internals.states.delete('dirty');
1704
+ }
1705
+ this.dispatchEvent(
1706
+ new CustomEvent('dirty:change', {
1707
+ bubbles: true,
1708
+ cancelable: false,
1709
+ detail: { dirty },
1710
+ }),
1711
+ );
1712
+ }
1713
+ #guard() {
1714
+ if (!this.declared('guard-leave') || this.#leave) {
1715
+ return;
1716
+ }
1717
+ this.#leave = new AbortController();
1718
+ PageLeave.guard(() => this.dirty, this.#leave.signal);
1462
1719
  }
1463
1720
  #spinning = 0;
1464
1721
  /**
@@ -1646,7 +1903,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1646
1903
  * datetime-local, month or week control fires its native `change` while its
1647
1904
  * segments are typed, as soon as they make a valid value; for those the field
1648
1905
  * reports when the control is left or Enter is pressed, at once for a value
1649
- * picked from the browser's picker, and never a half-typed value.
1906
+ * picked from the browser's picker, never with the dates a year passes
1907
+ * through while it is typed. A control left incomplete reads as `null`, as
1908
+ * the native one does.
1650
1909
  *
1651
1910
  * A subclass template may render more than one control, as the compare filters
1652
1911
  * render the two bounds of a range: the first is `_input`, the one the field is
@@ -8057,6 +8316,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8057
8316
  exports.LocalStorage = LocalStorage;
8058
8317
  exports.Menu = Menu;
8059
8318
  exports.NumberFilter = NumberFilter;
8319
+ exports.PageLeave = PageLeave;
8060
8320
  exports.Pagination = Pagination;
8061
8321
  exports.Plugin = Plugin;
8062
8322
  exports.RadioGroup = RadioGroup;