@optionfactory/fml 9.0.0-rc16 → 9.0.0-rc19

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,13 +1761,53 @@ 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
1809
+ * written as its string, replacing whatever was being typed, as a native
1810
+ * control's value does. Answers a `string`, a `number` or `null`; typed
1768
1811
  * `any` because subclasses answer other values.
1769
1812
  * @type {any}
1770
1813
  */
@@ -1785,7 +1828,7 @@ class Input extends Field {
1785
1828
  return normalized;
1786
1829
  }
1787
1830
  set value(value) {
1788
- this._input.value = value === '' || value === undefined ? null : value;
1831
+ this._input.value = value === '' || value === undefined || value === null ? '' : `${value}`;
1789
1832
  }
1790
1833
  /**
1791
1834
  * The control's placeholder, `null` when there is none. The control always
@@ -2054,7 +2097,7 @@ class InputInstant extends Input {
2054
2097
  }
2055
2098
  /** @param {string|null|undefined} v an ISO instant; a falsy value empties the input */
2056
2099
  set value(v) {
2057
- this._input.value = v ? Instant.isoToLocal(v) : '';
2100
+ super.value = v ? Instant.isoToLocal(v) : '';
2058
2101
  }
2059
2102
  /**
2060
2103
  * The lower bound as a UTC ISO instant.
@@ -2087,7 +2130,6 @@ class InputInstant extends Input {
2087
2130
  }
2088
2131
  /** @param {string|null} v */
2089
2132
  set step(v) {
2090
-
2091
2133
  this._input.step = v ?? '';
2092
2134
  }
2093
2135
  }
@@ -3964,6 +4006,15 @@ class Select extends Field {
3964
4006
  #query() {
3965
4007
  return this.#editing ? this.#input.value : '';
3966
4008
  }
4009
+ /**
4010
+ * The text typed to search the vocabulary: what the dropdown was last
4011
+ * asked to match. `''` while the field is not being searched, its control
4012
+ * then showing the selection's label rather than a query.
4013
+ * @returns {string}
4014
+ */
4015
+ get query() {
4016
+ return this.#query();
4017
+ }
3967
4018
  #display() {
3968
4019
  const entry = this.#values.values().next().value;
3969
4020
  this.#input.value = this.#multiple ? '' : (entry?.label ?? '');
@@ -5640,10 +5691,6 @@ class CompareFilter extends Input {
5640
5691
  },
5641
5692
  });
5642
5693
  this.operators = this.declared('operators');
5643
- this._value2.addEventListener('change', (evt) => {
5644
- evt.stopPropagation();
5645
- this._notifyChange();
5646
- });
5647
5694
  if (this._operator.value === null) {
5648
5695
  this._showDefaultOperator();
5649
5696
  }
@@ -6121,9 +6168,7 @@ class BooleanFilter extends Field {
6121
6168
  */
6122
6169
  get criterion() {
6123
6170
  const token = this._value.value;
6124
- return asCriterion(labelTextOf(this), this._operator.value, [
6125
- token === '' ? '' : booleanValueLabel(token),
6126
- ]);
6171
+ return asCriterion(labelTextOf(this), this._operator.value, [token === '' ? '' : booleanValueLabel(token)]);
6127
6172
  }
6128
6173
  /**
6129
6174
  * The field's own disabled claim. Setting it disables the value button and
@@ -6412,6 +6457,18 @@ class DialogSections {
6412
6457
  get section() {
6413
6458
  return this.#section;
6414
6459
  }
6460
+ /**
6461
+ * Dispatches the dismissal on the host and calls `close` unless a listener
6462
+ * prevented it. A dismissal that is not cancelable announces a close the
6463
+ * platform makes whatever the page does, so `close` is not called for it.
6464
+ * @param {CustomEvent} asked
6465
+ * @param {() => void} close
6466
+ */
6467
+ dismiss(asked, close) {
6468
+ if (this.#host.dispatchEvent(asked) && asked.cancelable) {
6469
+ close();
6470
+ }
6471
+ }
6415
6472
  /**
6416
6473
  * Calls `dismiss` when a press and its release both land on the backdrop,
6417
6474
  * so a selection dragged out of the panel and released outside does not.
@@ -6550,6 +6607,18 @@ const wireTargets = () => {
6550
6607
  * and a press and release both on the backdrop close the dialog, each as a
6551
6608
  * dismissal, as Escape does.
6552
6609
  *
6610
+ * Before any of those three gestures closes it, the element dispatches a
6611
+ * cancelable, non-bubbling `dialog:dismiss` with `detail: { reason }`, the
6612
+ * reason being `button`, `backdrop` or `escape`; a listener calling
6613
+ * `preventDefault()` keeps it open, to guard unsaved edits. A `data-result`
6614
+ * button, `close-on-submit` and `close()` answer rather than dismiss, and
6615
+ * dispatch none.
6616
+ *
6617
+ * The platform lets a page refuse Escape once per user interaction: a second
6618
+ * Escape with no click or keystroke in between closes the dialog whatever the
6619
+ * page does, `requires-answer` included. That close is still announced by a
6620
+ * `dialog:dismiss`, which is then not cancelable.
6621
+ *
6553
6622
  * `requires-answer` withholds the close button and refuses Escape and the
6554
6623
  * backdrop, so the dialog is closed only by a button carrying `data-result`
6555
6624
  * or from code through `close()`.
@@ -6622,21 +6691,32 @@ class Dialog extends ParsedElement {
6622
6691
  this.#dialog.close(result);
6623
6692
  }
6624
6693
  });
6694
+ const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
6695
+ this.#sections.dismiss(
6696
+ new CustomEvent('dialog:dismiss', { cancelable: !platformCloses, detail: { reason } }),
6697
+ () => this.#dialog.close(''),
6698
+ );
6625
6699
  if (!requiresAnswer) {
6626
- this.#sections.onBackdrop(() => this.#dialog.close(''));
6700
+ this.#sections.onBackdrop(() => dismiss('backdrop'));
6627
6701
  }
6628
- fragment
6629
- .querySelector('[data-ref=close]')
6630
- ?.addEventListener('click', () => this.#dialog.close(''));
6702
+ fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => dismiss('button'));
6631
6703
  if (closeOnSubmit) {
6632
6704
  this.#sections.onSubmitted((response) => {
6633
6705
  this.#answer = { dismissed: false, result: null, response };
6634
6706
  this.#dialog.close('submitted');
6635
6707
  });
6636
6708
  }
6637
- if (requiresAnswer) {
6638
- this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
6639
- }
6709
+ this.#dialog.addEventListener('cancel', (/** @type any */ e) => {
6710
+ if (!e.cancelable) {
6711
+ this.#dialog.returnValue = '';
6712
+ dismiss('escape', true);
6713
+ return;
6714
+ }
6715
+ e.preventDefault();
6716
+ if (!requiresAnswer) {
6717
+ dismiss('escape');
6718
+ }
6719
+ });
6640
6720
  this.replaceChildren(fragment);
6641
6721
  wireTargets();
6642
6722
  }
@@ -6837,7 +6917,14 @@ class Dialog extends ParsedElement {
6837
6917
  * default, when absent) or `start`, following the writing direction.
6838
6918
  *
6839
6919
  * 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
6920
+ * go through `close()`, once a cancelable, non-bubbling `drawer:dismiss` with
6921
+ * `detail: { reason }` (`button`, `backdrop` or `escape`) has been dispatched
6922
+ * on the element and no listener prevented it: `preventDefault()` keeps the
6923
+ * drawer open, to guard unsaved edits. `close()` and `close-on-submit`
6924
+ * dispatch none. The platform lets a page refuse Escape once per user
6925
+ * interaction: a second Escape with no click or keystroke in between closes
6926
+ * the drawer at once, without the slide, and its `drawer:dismiss` is then not
6927
+ * cancelable. Every close dispatches a non-bubbling `close` event on
6841
6928
  * the element with `detail: { dismissed, response }`: `{ dismissed: false,
6842
6929
  * response }` when a form closed it under `close-on-submit`, and
6843
6930
  * `{ dismissed: true, response: null }` for any other close.
@@ -6893,13 +6980,23 @@ class Drawer extends ParsedElement {
6893
6980
  if (placement) {
6894
6981
  this.#dialog.setAttribute('placement', placement);
6895
6982
  }
6983
+ const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
6984
+ this.#sections.dismiss(
6985
+ new CustomEvent('drawer:dismiss', { cancelable: !platformCloses, detail: { reason } }),
6986
+ () => this.close(),
6987
+ );
6896
6988
  /** @type {HTMLElement} */ (fragment.querySelector('[data-ref=close]')).addEventListener('click', () =>
6897
- this.close(),
6989
+ dismiss('button'),
6898
6990
  );
6899
- this.#sections.onBackdrop(() => this.close());
6991
+ this.#sections.onBackdrop(() => dismiss('backdrop'));
6900
6992
  this.#dialog.addEventListener('cancel', (e) => {
6993
+ if (!e.cancelable) {
6994
+ this.#stopSlidingOut();
6995
+ dismiss('escape', true);
6996
+ return;
6997
+ }
6901
6998
  e.preventDefault();
6902
- this.close();
6999
+ dismiss('escape');
6903
7000
  });
6904
7001
  this.#dialog.addEventListener('close', () => {
6905
7002
  this.dispatchEvent(
@@ -7582,11 +7679,9 @@ class Wizard extends ParsedElement {
7582
7679
  }
7583
7680
  /**
7584
7681
  * 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`.
7682
+ * and section current, moves the focus to the section, dispatches
7683
+ * `wizard:change` on the host (not bubbling) with detail `{ index, step }`,
7684
+ * then fires the section requests for it.
7590
7685
  * @param {string} ref the section's `data-step`
7591
7686
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
7592
7687
  * the answers once they are delivered, or to undefined when nobody
@@ -7643,7 +7738,7 @@ class Wizard extends ParsedElement {
7643
7738
  this.#apply(clamped);
7644
7739
  this.#sections[this.#index].focus();
7645
7740
  if (this.rendered) {
7646
- this.dispatchEvent(new CustomEvent('change', { detail: { index: this.#index, step: this.step } }));
7741
+ this.dispatchEvent(new CustomEvent('wizard:change', { detail: { index: this.#index, step: this.step } }));
7647
7742
  }
7648
7743
  return this.#enter(clamped);
7649
7744
  }