@optionfactory/fml 9.0.0-rc16 → 9.0.0-rc18

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.mjs CHANGED
@@ -1578,6 +1578,8 @@ const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomp
1578
1578
 
1579
1579
  const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
1580
1580
 
1581
+ const SEGMENTED = new Set(['date', 'time', 'datetime-local', 'month', 'week']);
1582
+
1581
1583
  const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
1582
1584
 
1583
1585
  const NUMERIC_DIGITS = {
@@ -1639,14 +1641,18 @@ const filterOf = (el) => {
1639
1641
  * `autocomplete` attribute of the nearest enclosing `<form>`, which the
1640
1642
  * platform does not pass on because the control carries `form=""`.
1641
1643
  * - `uppercase`, `trim` and `v-type` shape the value as it is read; see `value`.
1642
- *
1643
- * The control's native `change` is stopped and republished as the field's own.
1644
+ * - The control's native `change` is stopped and the field dispatches its own
1645
+ * once the user commits a new value, as a text input does. A date, time,
1646
+ * datetime-local, month or week control fires its native `change` while its
1647
+ * segments are typed, as soon as they make a valid value; for those the field
1648
+ * 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.
1644
1650
  *
1645
1651
  * A subclass template may render more than one control, as the compare filters
1646
1652
  * render the two bounds of a range: the first is `_input`, the one the field is
1647
- * named by and whose `change` it republishes, and every one of them gets the
1648
- * inputmode, `autocomplete`, the `input-` passthrough, the keystroke filter and
1649
- * the placeholder. The passthrough leaves `input-id` to the first alone, so no
1653
+ * named by, and every one of them gets the inputmode, `autocomplete`, the
1654
+ * `input-` passthrough, the keystroke filter, the placeholder and the `change`
1655
+ * reporting. The passthrough leaves `input-id` to the first alone, so no
1650
1656
  * two controls share an id.
1651
1657
  */
1652
1658
  class Input extends Field {
@@ -1713,10 +1719,6 @@ class Input extends Field {
1713
1719
  for (const control of this.#controls) {
1714
1720
  this.#configure(control, mode, autocomplete, strip);
1715
1721
  }
1716
- this._input.addEventListener('change', (evt) => {
1717
- evt.stopPropagation();
1718
- this._notifyChange();
1719
- });
1720
1722
  return {
1721
1723
  fragment,
1722
1724
  control: this._input,
@@ -1740,6 +1742,7 @@ class Input extends Field {
1740
1742
  if (control !== this._input) {
1741
1743
  Attributes.set(control, 'id', id || null);
1742
1744
  }
1745
+ this.#reportChanges(control);
1743
1746
  control.addEventListener('input', () => {
1744
1747
  if (!strip) {
1745
1748
  return;
@@ -1758,14 +1761,55 @@ class Input extends Field {
1758
1761
  control.setSelectionRange(caret, caret);
1759
1762
  });
1760
1763
  }
1764
+ /** @param {HTMLInputElement|HTMLTextAreaElement} control */
1765
+ #reportChanges(control) {
1766
+ if (!SEGMENTED.has(control.type)) {
1767
+ control.addEventListener('change', (evt) => {
1768
+ evt.stopPropagation();
1769
+ this._notifyChange();
1770
+ });
1771
+ return;
1772
+ }
1773
+ /** @type {string|null} */
1774
+ let committed = null;
1775
+ let typed = false;
1776
+ const commit = () => {
1777
+ typed = false;
1778
+ const now = JSON.stringify(this.value);
1779
+ if (now === committed) {
1780
+ return;
1781
+ }
1782
+ committed = now;
1783
+ this._notifyChange();
1784
+ };
1785
+ control.addEventListener('focus', () => {
1786
+ committed = JSON.stringify(this.value);
1787
+ });
1788
+ control.addEventListener('keydown', (/** @type any */ evt) => {
1789
+ if (evt.key === 'Enter' && !evt.isComposing) {
1790
+ commit();
1791
+ return;
1792
+ }
1793
+ typed = true;
1794
+ });
1795
+ control.addEventListener('blur', commit);
1796
+ control.addEventListener('change', (evt) => {
1797
+ evt.stopPropagation();
1798
+ if (!typed) {
1799
+ commit();
1800
+ }
1801
+ });
1802
+ }
1761
1803
  /**
1762
1804
  * The control's text, shaped in this order: upper cased under `uppercase`,
1763
1805
  * trimmed under `trim`, `null` when that leaves it empty, every `,` read as
1764
1806
  * `.` for `type="decimal"`, and decoded to a number under
1765
1807
  * `v-type="number"`, where text that does not decode is answered as it is.
1766
1808
  * Writing `null`, `undefined` or `''` empties the control; anything else is
1767
- * written as its string. Answers a `string`, a `number` or `null`; typed
1768
- * `any` because subclasses answer other values.
1809
+ * written as its string. Writing the text the control already holds leaves
1810
+ * it untouched, so a date or time control keeps the segments being typed.
1811
+ * Answers a `string`, a `number` or `null`; typed `any` because subclasses
1812
+ * answer other values.
1769
1813
  * @type {any}
1770
1814
  */
1771
1815
  get value() {
@@ -1785,7 +1829,11 @@ class Input extends Field {
1785
1829
  return normalized;
1786
1830
  }
1787
1831
  set value(value) {
1788
- this._input.value = value === '' || value === undefined ? null : value;
1832
+ const text = value === '' || value === undefined || value === null ? '' : `${value}`;
1833
+ if (this._input.value === text) {
1834
+ return;
1835
+ }
1836
+ this._input.value = text;
1789
1837
  }
1790
1838
  /**
1791
1839
  * The control's placeholder, `null` when there is none. The control always
@@ -2054,7 +2102,7 @@ class InputInstant extends Input {
2054
2102
  }
2055
2103
  /** @param {string|null|undefined} v an ISO instant; a falsy value empties the input */
2056
2104
  set value(v) {
2057
- this._input.value = v ? Instant.isoToLocal(v) : '';
2105
+ super.value = v ? Instant.isoToLocal(v) : '';
2058
2106
  }
2059
2107
  /**
2060
2108
  * The lower bound as a UTC ISO instant.
@@ -2087,7 +2135,6 @@ class InputInstant extends Input {
2087
2135
  }
2088
2136
  /** @param {string|null} v */
2089
2137
  set step(v) {
2090
-
2091
2138
  this._input.step = v ?? '';
2092
2139
  }
2093
2140
  }
@@ -3964,6 +4011,15 @@ class Select extends Field {
3964
4011
  #query() {
3965
4012
  return this.#editing ? this.#input.value : '';
3966
4013
  }
4014
+ /**
4015
+ * The text typed to search the vocabulary: what the dropdown was last
4016
+ * asked to match. `''` while the field is not being searched, its control
4017
+ * then showing the selection's label rather than a query.
4018
+ * @returns {string}
4019
+ */
4020
+ get query() {
4021
+ return this.#query();
4022
+ }
3967
4023
  #display() {
3968
4024
  const entry = this.#values.values().next().value;
3969
4025
  this.#input.value = this.#multiple ? '' : (entry?.label ?? '');
@@ -5640,10 +5696,6 @@ class CompareFilter extends Input {
5640
5696
  },
5641
5697
  });
5642
5698
  this.operators = this.declared('operators');
5643
- this._value2.addEventListener('change', (evt) => {
5644
- evt.stopPropagation();
5645
- this._notifyChange();
5646
- });
5647
5699
  if (this._operator.value === null) {
5648
5700
  this._showDefaultOperator();
5649
5701
  }
@@ -6121,9 +6173,7 @@ class BooleanFilter extends Field {
6121
6173
  */
6122
6174
  get criterion() {
6123
6175
  const token = this._value.value;
6124
- return asCriterion(labelTextOf(this), this._operator.value, [
6125
- token === '' ? '' : booleanValueLabel(token),
6126
- ]);
6176
+ return asCriterion(labelTextOf(this), this._operator.value, [token === '' ? '' : booleanValueLabel(token)]);
6127
6177
  }
6128
6178
  /**
6129
6179
  * The field's own disabled claim. Setting it disables the value button and
@@ -6412,6 +6462,18 @@ class DialogSections {
6412
6462
  get section() {
6413
6463
  return this.#section;
6414
6464
  }
6465
+ /**
6466
+ * Dispatches the dismissal on the host and calls `close` unless a listener
6467
+ * prevented it. A dismissal that is not cancelable announces a close the
6468
+ * platform makes whatever the page does, so `close` is not called for it.
6469
+ * @param {CustomEvent} asked
6470
+ * @param {() => void} close
6471
+ */
6472
+ dismiss(asked, close) {
6473
+ if (this.#host.dispatchEvent(asked) && asked.cancelable) {
6474
+ close();
6475
+ }
6476
+ }
6415
6477
  /**
6416
6478
  * Calls `dismiss` when a press and its release both land on the backdrop,
6417
6479
  * so a selection dragged out of the panel and released outside does not.
@@ -6550,6 +6612,18 @@ const wireTargets = () => {
6550
6612
  * and a press and release both on the backdrop close the dialog, each as a
6551
6613
  * dismissal, as Escape does.
6552
6614
  *
6615
+ * Before any of those three gestures closes it, the element dispatches a
6616
+ * cancelable, non-bubbling `dialog:dismiss` with `detail: { reason }`, the
6617
+ * reason being `button`, `backdrop` or `escape`; a listener calling
6618
+ * `preventDefault()` keeps it open, to guard unsaved edits. A `data-result`
6619
+ * button, `close-on-submit` and `close()` answer rather than dismiss, and
6620
+ * dispatch none.
6621
+ *
6622
+ * The platform lets a page refuse Escape once per user interaction: a second
6623
+ * Escape with no click or keystroke in between closes the dialog whatever the
6624
+ * page does, `requires-answer` included. That close is still announced by a
6625
+ * `dialog:dismiss`, which is then not cancelable.
6626
+ *
6553
6627
  * `requires-answer` withholds the close button and refuses Escape and the
6554
6628
  * backdrop, so the dialog is closed only by a button carrying `data-result`
6555
6629
  * or from code through `close()`.
@@ -6622,21 +6696,32 @@ class Dialog extends ParsedElement {
6622
6696
  this.#dialog.close(result);
6623
6697
  }
6624
6698
  });
6699
+ const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
6700
+ this.#sections.dismiss(
6701
+ new CustomEvent('dialog:dismiss', { cancelable: !platformCloses, detail: { reason } }),
6702
+ () => this.#dialog.close(''),
6703
+ );
6625
6704
  if (!requiresAnswer) {
6626
- this.#sections.onBackdrop(() => this.#dialog.close(''));
6705
+ this.#sections.onBackdrop(() => dismiss('backdrop'));
6627
6706
  }
6628
- fragment
6629
- .querySelector('[data-ref=close]')
6630
- ?.addEventListener('click', () => this.#dialog.close(''));
6707
+ fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => dismiss('button'));
6631
6708
  if (closeOnSubmit) {
6632
6709
  this.#sections.onSubmitted((response) => {
6633
6710
  this.#answer = { dismissed: false, result: null, response };
6634
6711
  this.#dialog.close('submitted');
6635
6712
  });
6636
6713
  }
6637
- if (requiresAnswer) {
6638
- this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
6639
- }
6714
+ this.#dialog.addEventListener('cancel', (/** @type any */ e) => {
6715
+ if (!e.cancelable) {
6716
+ this.#dialog.returnValue = '';
6717
+ dismiss('escape', true);
6718
+ return;
6719
+ }
6720
+ e.preventDefault();
6721
+ if (!requiresAnswer) {
6722
+ dismiss('escape');
6723
+ }
6724
+ });
6640
6725
  this.replaceChildren(fragment);
6641
6726
  wireTargets();
6642
6727
  }
@@ -6837,7 +6922,14 @@ class Dialog extends ParsedElement {
6837
6922
  * default, when absent) or `start`, following the writing direction.
6838
6923
  *
6839
6924
  * The close button, Escape, and a press and release both on the backdrop all
6840
- * go through `close()`. Every close dispatches a non-bubbling `close` event on
6925
+ * go through `close()`, once a cancelable, non-bubbling `drawer:dismiss` with
6926
+ * `detail: { reason }` (`button`, `backdrop` or `escape`) has been dispatched
6927
+ * on the element and no listener prevented it: `preventDefault()` keeps the
6928
+ * drawer open, to guard unsaved edits. `close()` and `close-on-submit`
6929
+ * dispatch none. The platform lets a page refuse Escape once per user
6930
+ * interaction: a second Escape with no click or keystroke in between closes
6931
+ * the drawer at once, without the slide, and its `drawer:dismiss` is then not
6932
+ * cancelable. Every close dispatches a non-bubbling `close` event on
6841
6933
  * the element with `detail: { dismissed, response }`: `{ dismissed: false,
6842
6934
  * response }` when a form closed it under `close-on-submit`, and
6843
6935
  * `{ dismissed: true, response: null }` for any other close.
@@ -6893,13 +6985,23 @@ class Drawer extends ParsedElement {
6893
6985
  if (placement) {
6894
6986
  this.#dialog.setAttribute('placement', placement);
6895
6987
  }
6988
+ const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
6989
+ this.#sections.dismiss(
6990
+ new CustomEvent('drawer:dismiss', { cancelable: !platformCloses, detail: { reason } }),
6991
+ () => this.close(),
6992
+ );
6896
6993
  /** @type {HTMLElement} */ (fragment.querySelector('[data-ref=close]')).addEventListener('click', () =>
6897
- this.close(),
6994
+ dismiss('button'),
6898
6995
  );
6899
- this.#sections.onBackdrop(() => this.close());
6996
+ this.#sections.onBackdrop(() => dismiss('backdrop'));
6900
6997
  this.#dialog.addEventListener('cancel', (e) => {
6998
+ if (!e.cancelable) {
6999
+ this.#stopSlidingOut();
7000
+ dismiss('escape', true);
7001
+ return;
7002
+ }
6901
7003
  e.preventDefault();
6902
- this.close();
7004
+ dismiss('escape');
6903
7005
  });
6904
7006
  this.#dialog.addEventListener('close', () => {
6905
7007
  this.dispatchEvent(
@@ -7582,11 +7684,9 @@ class Wizard extends ParsedElement {
7582
7684
  }
7583
7685
  /**
7584
7686
  * Moves to the section carrying `data-step="<ref>"`. A move makes that step
7585
- * and section current, moves the focus to the section, dispatches `change`
7586
- * on the host (not bubbling) with detail `{ index, step }`, then fires the
7587
- * section requests for it. A native `change` of a control inside a section
7588
- * bubbles through the host under the same name, so a listener tells the
7589
- * two apart by `e.target`.
7687
+ * and section current, moves the focus to the section, dispatches
7688
+ * `wizard:change` on the host (not bubbling) with detail `{ index, step }`,
7689
+ * then fires the section requests for it.
7590
7690
  * @param {string} ref the section's `data-step`
7591
7691
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
7592
7692
  * the answers once they are delivered, or to undefined when nobody
@@ -7643,7 +7743,7 @@ class Wizard extends ParsedElement {
7643
7743
  this.#apply(clamped);
7644
7744
  this.#sections[this.#index].focus();
7645
7745
  if (this.rendered) {
7646
- this.dispatchEvent(new CustomEvent('change', { detail: { index: this.#index, step: this.step } }));
7746
+ this.dispatchEvent(new CustomEvent('wizard:change', { detail: { index: this.#index, step: this.step } }));
7647
7747
  }
7648
7748
  return this.#enter(clamped);
7649
7749
  }