@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/fml.iife.js CHANGED
@@ -7848,6 +7848,8 @@ var fml = (function (exports) {
7848
7848
 
7849
7849
  const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
7850
7850
 
7851
+ const SEGMENTED = new Set(['date', 'time', 'datetime-local', 'month', 'week']);
7852
+
7851
7853
  const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
7852
7854
 
7853
7855
  const NUMERIC_DIGITS = {
@@ -7909,14 +7911,18 @@ var fml = (function (exports) {
7909
7911
  * `autocomplete` attribute of the nearest enclosing `<form>`, which the
7910
7912
  * platform does not pass on because the control carries `form=""`.
7911
7913
  * - `uppercase`, `trim` and `v-type` shape the value as it is read; see `value`.
7912
- *
7913
- * The control's native `change` is stopped and republished as the field's own.
7914
+ * - The control's native `change` is stopped and the field dispatches its own
7915
+ * once the user commits a new value, as a text input does. A date, time,
7916
+ * datetime-local, month or week control fires its native `change` while its
7917
+ * segments are typed, as soon as they make a valid value; for those the field
7918
+ * 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.
7914
7920
  *
7915
7921
  * A subclass template may render more than one control, as the compare filters
7916
7922
  * render the two bounds of a range: the first is `_input`, the one the field is
7917
- * named by and whose `change` it republishes, and every one of them gets the
7918
- * inputmode, `autocomplete`, the `input-` passthrough, the keystroke filter and
7919
- * the placeholder. The passthrough leaves `input-id` to the first alone, so no
7923
+ * named by, and every one of them gets the inputmode, `autocomplete`, the
7924
+ * `input-` passthrough, the keystroke filter, the placeholder and the `change`
7925
+ * reporting. The passthrough leaves `input-id` to the first alone, so no
7920
7926
  * two controls share an id.
7921
7927
  */
7922
7928
  class Input extends Field {
@@ -7983,10 +7989,6 @@ var fml = (function (exports) {
7983
7989
  for (const control of this.#controls) {
7984
7990
  this.#configure(control, mode, autocomplete, strip);
7985
7991
  }
7986
- this._input.addEventListener('change', (evt) => {
7987
- evt.stopPropagation();
7988
- this._notifyChange();
7989
- });
7990
7992
  return {
7991
7993
  fragment,
7992
7994
  control: this._input,
@@ -8010,6 +8012,7 @@ var fml = (function (exports) {
8010
8012
  if (control !== this._input) {
8011
8013
  Attributes.set(control, 'id', id || null);
8012
8014
  }
8015
+ this.#reportChanges(control);
8013
8016
  control.addEventListener('input', () => {
8014
8017
  if (!strip) {
8015
8018
  return;
@@ -8028,14 +8031,55 @@ var fml = (function (exports) {
8028
8031
  control.setSelectionRange(caret, caret);
8029
8032
  });
8030
8033
  }
8034
+ /** @param {HTMLInputElement|HTMLTextAreaElement} control */
8035
+ #reportChanges(control) {
8036
+ if (!SEGMENTED.has(control.type)) {
8037
+ control.addEventListener('change', (evt) => {
8038
+ evt.stopPropagation();
8039
+ this._notifyChange();
8040
+ });
8041
+ return;
8042
+ }
8043
+ /** @type {string|null} */
8044
+ let committed = null;
8045
+ let typed = false;
8046
+ const commit = () => {
8047
+ typed = false;
8048
+ const now = JSON.stringify(this.value);
8049
+ if (now === committed) {
8050
+ return;
8051
+ }
8052
+ committed = now;
8053
+ this._notifyChange();
8054
+ };
8055
+ control.addEventListener('focus', () => {
8056
+ committed = JSON.stringify(this.value);
8057
+ });
8058
+ control.addEventListener('keydown', (/** @type any */ evt) => {
8059
+ if (evt.key === 'Enter' && !evt.isComposing) {
8060
+ commit();
8061
+ return;
8062
+ }
8063
+ typed = true;
8064
+ });
8065
+ control.addEventListener('blur', commit);
8066
+ control.addEventListener('change', (evt) => {
8067
+ evt.stopPropagation();
8068
+ if (!typed) {
8069
+ commit();
8070
+ }
8071
+ });
8072
+ }
8031
8073
  /**
8032
8074
  * The control's text, shaped in this order: upper cased under `uppercase`,
8033
8075
  * trimmed under `trim`, `null` when that leaves it empty, every `,` read as
8034
8076
  * `.` for `type="decimal"`, and decoded to a number under
8035
8077
  * `v-type="number"`, where text that does not decode is answered as it is.
8036
8078
  * Writing `null`, `undefined` or `''` empties the control; anything else is
8037
- * written as its string. Answers a `string`, a `number` or `null`; typed
8038
- * `any` because subclasses answer other values.
8079
+ * written as its string. Writing the text the control already holds leaves
8080
+ * it untouched, so a date or time control keeps the segments being typed.
8081
+ * Answers a `string`, a `number` or `null`; typed `any` because subclasses
8082
+ * answer other values.
8039
8083
  * @type {any}
8040
8084
  */
8041
8085
  get value() {
@@ -8055,7 +8099,11 @@ var fml = (function (exports) {
8055
8099
  return normalized;
8056
8100
  }
8057
8101
  set value(value) {
8058
- this._input.value = value === '' || value === undefined ? null : value;
8102
+ const text = value === '' || value === undefined || value === null ? '' : `${value}`;
8103
+ if (this._input.value === text) {
8104
+ return;
8105
+ }
8106
+ this._input.value = text;
8059
8107
  }
8060
8108
  /**
8061
8109
  * The control's placeholder, `null` when there is none. The control always
@@ -8324,7 +8372,7 @@ var fml = (function (exports) {
8324
8372
  }
8325
8373
  /** @param {string|null|undefined} v an ISO instant; a falsy value empties the input */
8326
8374
  set value(v) {
8327
- this._input.value = v ? Instant.isoToLocal(v) : '';
8375
+ super.value = v ? Instant.isoToLocal(v) : '';
8328
8376
  }
8329
8377
  /**
8330
8378
  * The lower bound as a UTC ISO instant.
@@ -8357,7 +8405,6 @@ var fml = (function (exports) {
8357
8405
  }
8358
8406
  /** @param {string|null} v */
8359
8407
  set step(v) {
8360
-
8361
8408
  this._input.step = v ?? '';
8362
8409
  }
8363
8410
  }
@@ -10234,6 +10281,15 @@ var fml = (function (exports) {
10234
10281
  #query() {
10235
10282
  return this.#editing ? this.#input.value : '';
10236
10283
  }
10284
+ /**
10285
+ * The text typed to search the vocabulary: what the dropdown was last
10286
+ * asked to match. `''` while the field is not being searched, its control
10287
+ * then showing the selection's label rather than a query.
10288
+ * @returns {string}
10289
+ */
10290
+ get query() {
10291
+ return this.#query();
10292
+ }
10237
10293
  #display() {
10238
10294
  const entry = this.#values.values().next().value;
10239
10295
  this.#input.value = this.#multiple ? '' : (entry?.label ?? '');
@@ -11910,10 +11966,6 @@ var fml = (function (exports) {
11910
11966
  },
11911
11967
  });
11912
11968
  this.operators = this.declared('operators');
11913
- this._value2.addEventListener('change', (evt) => {
11914
- evt.stopPropagation();
11915
- this._notifyChange();
11916
- });
11917
11969
  if (this._operator.value === null) {
11918
11970
  this._showDefaultOperator();
11919
11971
  }
@@ -12391,9 +12443,7 @@ var fml = (function (exports) {
12391
12443
  */
12392
12444
  get criterion() {
12393
12445
  const token = this._value.value;
12394
- return asCriterion(labelTextOf(this), this._operator.value, [
12395
- token === '' ? '' : booleanValueLabel(token),
12396
- ]);
12446
+ return asCriterion(labelTextOf(this), this._operator.value, [token === '' ? '' : booleanValueLabel(token)]);
12397
12447
  }
12398
12448
  /**
12399
12449
  * The field's own disabled claim. Setting it disables the value button and
@@ -12682,6 +12732,18 @@ var fml = (function (exports) {
12682
12732
  get section() {
12683
12733
  return this.#section;
12684
12734
  }
12735
+ /**
12736
+ * Dispatches the dismissal on the host and calls `close` unless a listener
12737
+ * prevented it. A dismissal that is not cancelable announces a close the
12738
+ * platform makes whatever the page does, so `close` is not called for it.
12739
+ * @param {CustomEvent} asked
12740
+ * @param {() => void} close
12741
+ */
12742
+ dismiss(asked, close) {
12743
+ if (this.#host.dispatchEvent(asked) && asked.cancelable) {
12744
+ close();
12745
+ }
12746
+ }
12685
12747
  /**
12686
12748
  * Calls `dismiss` when a press and its release both land on the backdrop,
12687
12749
  * so a selection dragged out of the panel and released outside does not.
@@ -12820,6 +12882,18 @@ var fml = (function (exports) {
12820
12882
  * and a press and release both on the backdrop close the dialog, each as a
12821
12883
  * dismissal, as Escape does.
12822
12884
  *
12885
+ * Before any of those three gestures closes it, the element dispatches a
12886
+ * cancelable, non-bubbling `dialog:dismiss` with `detail: { reason }`, the
12887
+ * reason being `button`, `backdrop` or `escape`; a listener calling
12888
+ * `preventDefault()` keeps it open, to guard unsaved edits. A `data-result`
12889
+ * button, `close-on-submit` and `close()` answer rather than dismiss, and
12890
+ * dispatch none.
12891
+ *
12892
+ * The platform lets a page refuse Escape once per user interaction: a second
12893
+ * Escape with no click or keystroke in between closes the dialog whatever the
12894
+ * page does, `requires-answer` included. That close is still announced by a
12895
+ * `dialog:dismiss`, which is then not cancelable.
12896
+ *
12823
12897
  * `requires-answer` withholds the close button and refuses Escape and the
12824
12898
  * backdrop, so the dialog is closed only by a button carrying `data-result`
12825
12899
  * or from code through `close()`.
@@ -12892,21 +12966,32 @@ var fml = (function (exports) {
12892
12966
  this.#dialog.close(result);
12893
12967
  }
12894
12968
  });
12969
+ const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
12970
+ this.#sections.dismiss(
12971
+ new CustomEvent('dialog:dismiss', { cancelable: !platformCloses, detail: { reason } }),
12972
+ () => this.#dialog.close(''),
12973
+ );
12895
12974
  if (!requiresAnswer) {
12896
- this.#sections.onBackdrop(() => this.#dialog.close(''));
12975
+ this.#sections.onBackdrop(() => dismiss('backdrop'));
12897
12976
  }
12898
- fragment
12899
- .querySelector('[data-ref=close]')
12900
- ?.addEventListener('click', () => this.#dialog.close(''));
12977
+ fragment.querySelector('[data-ref=close]')?.addEventListener('click', () => dismiss('button'));
12901
12978
  if (closeOnSubmit) {
12902
12979
  this.#sections.onSubmitted((response) => {
12903
12980
  this.#answer = { dismissed: false, result: null, response };
12904
12981
  this.#dialog.close('submitted');
12905
12982
  });
12906
12983
  }
12907
- if (requiresAnswer) {
12908
- this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
12909
- }
12984
+ this.#dialog.addEventListener('cancel', (/** @type any */ e) => {
12985
+ if (!e.cancelable) {
12986
+ this.#dialog.returnValue = '';
12987
+ dismiss('escape', true);
12988
+ return;
12989
+ }
12990
+ e.preventDefault();
12991
+ if (!requiresAnswer) {
12992
+ dismiss('escape');
12993
+ }
12994
+ });
12910
12995
  this.replaceChildren(fragment);
12911
12996
  wireTargets();
12912
12997
  }
@@ -13107,7 +13192,14 @@ var fml = (function (exports) {
13107
13192
  * default, when absent) or `start`, following the writing direction.
13108
13193
  *
13109
13194
  * The close button, Escape, and a press and release both on the backdrop all
13110
- * go through `close()`. Every close dispatches a non-bubbling `close` event on
13195
+ * go through `close()`, once a cancelable, non-bubbling `drawer:dismiss` with
13196
+ * `detail: { reason }` (`button`, `backdrop` or `escape`) has been dispatched
13197
+ * on the element and no listener prevented it: `preventDefault()` keeps the
13198
+ * drawer open, to guard unsaved edits. `close()` and `close-on-submit`
13199
+ * dispatch none. The platform lets a page refuse Escape once per user
13200
+ * interaction: a second Escape with no click or keystroke in between closes
13201
+ * the drawer at once, without the slide, and its `drawer:dismiss` is then not
13202
+ * cancelable. Every close dispatches a non-bubbling `close` event on
13111
13203
  * the element with `detail: { dismissed, response }`: `{ dismissed: false,
13112
13204
  * response }` when a form closed it under `close-on-submit`, and
13113
13205
  * `{ dismissed: true, response: null }` for any other close.
@@ -13163,13 +13255,23 @@ var fml = (function (exports) {
13163
13255
  if (placement) {
13164
13256
  this.#dialog.setAttribute('placement', placement);
13165
13257
  }
13258
+ const dismiss = (/** @type {'button'|'backdrop'|'escape'} */ reason, platformCloses = false) =>
13259
+ this.#sections.dismiss(
13260
+ new CustomEvent('drawer:dismiss', { cancelable: !platformCloses, detail: { reason } }),
13261
+ () => this.close(),
13262
+ );
13166
13263
  /** @type {HTMLElement} */ (fragment.querySelector('[data-ref=close]')).addEventListener('click', () =>
13167
- this.close(),
13264
+ dismiss('button'),
13168
13265
  );
13169
- this.#sections.onBackdrop(() => this.close());
13266
+ this.#sections.onBackdrop(() => dismiss('backdrop'));
13170
13267
  this.#dialog.addEventListener('cancel', (e) => {
13268
+ if (!e.cancelable) {
13269
+ this.#stopSlidingOut();
13270
+ dismiss('escape', true);
13271
+ return;
13272
+ }
13171
13273
  e.preventDefault();
13172
- this.close();
13274
+ dismiss('escape');
13173
13275
  });
13174
13276
  this.#dialog.addEventListener('close', () => {
13175
13277
  this.dispatchEvent(
@@ -13852,11 +13954,9 @@ var fml = (function (exports) {
13852
13954
  }
13853
13955
  /**
13854
13956
  * Moves to the section carrying `data-step="<ref>"`. A move makes that step
13855
- * and section current, moves the focus to the section, dispatches `change`
13856
- * on the host (not bubbling) with detail `{ index, step }`, then fires the
13857
- * section requests for it. A native `change` of a control inside a section
13858
- * bubbles through the host under the same name, so a listener tells the
13859
- * two apart by `e.target`.
13957
+ * and section current, moves the focus to the section, dispatches
13958
+ * `wizard:change` on the host (not bubbling) with detail `{ index, step }`,
13959
+ * then fires the section requests for it.
13860
13960
  * @param {string} ref the section's `data-step`
13861
13961
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
13862
13962
  * the answers once they are delivered, or to undefined when nobody
@@ -13913,7 +14013,7 @@ var fml = (function (exports) {
13913
14013
  this.#apply(clamped);
13914
14014
  this.#sections[this.#index].focus();
13915
14015
  if (this.rendered) {
13916
- this.dispatchEvent(new CustomEvent('change', { detail: { index: this.#index, step: this.step } }));
14016
+ this.dispatchEvent(new CustomEvent('wizard:change', { detail: { index: this.#index, step: this.step } }));
13917
14017
  }
13918
14018
  return this.#enter(clamped);
13919
14019
  }