@optionfactory/fml 9.0.0-rc10 → 9.0.0-rc11

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
@@ -7636,6 +7636,22 @@ var fml = (function (exports) {
7636
7636
  */
7637
7637
  const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
7638
7638
 
7639
+ /** Rendering a text input is what costs these the native min/max/step enforcement. */
7640
+ const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
7641
+
7642
+ const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
7643
+
7644
+ const NUMERIC_FILTERS = {
7645
+ numeric: (v) => signed(v, (t) => t.replace(/\D/g, '')),
7646
+ //one separator, the first: the rest are a slip while typing, not a value
7647
+ decimal: (v) =>
7648
+ signed(v, (t) => {
7649
+ const kept = t.replace(/[^\d.,]/g, '');
7650
+ const at = kept.search(/[.,]/);
7651
+ return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
7652
+ }),
7653
+ };
7654
+
7639
7655
  const warnedBoth = new WeakSet();
7640
7656
  const filterOf = (el) => {
7641
7657
  const keep = el.declared('keep');
@@ -7655,7 +7671,7 @@ var fml = (function (exports) {
7655
7671
  const re = compiled('reject', reject);
7656
7672
  return re && ((v) => v.replace(re, ''));
7657
7673
  }
7658
- return null;
7674
+ return NUMERIC_FILTERS[el._type()] ?? null;
7659
7675
  };
7660
7676
 
7661
7677
  /** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
@@ -7691,9 +7707,17 @@ var fml = (function (exports) {
7691
7707
  return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
7692
7708
  }
7693
7709
  _build({ slots }) {
7694
- const type = this._type();
7710
+ const declared = this._type();
7711
+ const mode = INPUT_MODES[declared];
7712
+ //a numeric widget is a text input: the type names the keyboard, not the
7713
+ //control, so the rendered markup stays valid html
7714
+ const type = mode ? 'text' : declared;
7695
7715
  const fragment = this.template().withOverlay({ type, slots }).render();
7696
7716
  this._input = fragment.querySelector('input,textarea');
7717
+ if (mode) {
7718
+ //before the passthrough, which stays the last word
7719
+ Attributes.set(this._input, 'inputmode', mode);
7720
+ }
7697
7721
 
7698
7722
  //the browser reads autocomplete off the control it is classifying, so the
7699
7723
  //field's own token, or the form's where it declares none, is put there.
@@ -7745,13 +7769,15 @@ var fml = (function (exports) {
7745
7769
  if (trimmed === '') {
7746
7770
  return null;
7747
7771
  }
7772
+ //the wire carries a dot wherever the comma is what the keyboard shows
7773
+ const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
7748
7774
  if (this.declared('v-type') === 'number') {
7749
7775
  //typed values are an explicit opt in, as the select's k-type: blank
7750
7776
  //stays null, and a value that does not decode is kept as it is
7751
- const n = Number(trimmed);
7752
- return Number.isNaN(n) ? trimmed : n;
7777
+ const n = Number(normalized);
7778
+ return Number.isNaN(n) ? normalized : n;
7753
7779
  }
7754
- return trimmed;
7780
+ return normalized;
7755
7781
  }
7756
7782
  set value(value) {
7757
7783
  this._input.value = value === '' || value === undefined ? null : value;
@@ -8743,10 +8769,27 @@ var fml = (function (exports) {
8743
8769
  method: el.declared('method') ?? 'POST',
8744
8770
  mode: el.declared('mode'),
8745
8771
  prefetch: el.declared('preload'),
8746
- revision: el.declared('revision'),
8772
+ revision: SelectLoader.#revisionFrom(el),
8747
8773
  responseMapper: SelectLoader.#responseMapperFrom(el),
8748
8774
  });
8749
8775
  }
8776
+ static #revisionFrom(el) {
8777
+ const declared = el.declared('revision');
8778
+ if (declared !== '') {
8779
+ return declared;
8780
+ }
8781
+ const configured = el.component('revision');
8782
+ if (configured === undefined || configured === null) {
8783
+ //not caching while the markup says to cache is the quiet failure
8784
+ //this branch exists to make loud
8785
+ console.warn(
8786
+ "a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
8787
+ el,
8788
+ );
8789
+ return null;
8790
+ }
8791
+ return typeof configured === 'function' ? configured() : configured;
8792
+ }
8750
8793
  static #responseMapperFrom(el) {
8751
8794
  if (el.declared('k-expr') && el.declared('l-expr')) {
8752
8795
  return (response) => {
@@ -8779,7 +8822,7 @@ var fml = (function (exports) {
8779
8822
  static slots = true;
8780
8823
  static template = `
8781
8824
  <ful-spinner class="centered" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
8782
- <p data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</p>
8825
+ <ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
8783
8826
  <menu tabindex="-1" role="listbox" hidden></menu>
8784
8827
  `;
8785
8828
  static templates = {
@@ -8801,7 +8844,7 @@ var fml = (function (exports) {
8801
8844
  ? this.template('options')
8802
8845
  : Templates.fromFragment(slots.default);
8803
8846
  this.#spinner = fragment.querySelector('ful-spinner');
8804
- this.#empty = fragment.querySelector('p[data-ref=empty]');
8847
+ this.#empty = fragment.querySelector('[data-ref=empty]');
8805
8848
  this.#menu = fragment.querySelector('menu');
8806
8849
  //the listbox is named so a combobox can point aria-controls and
8807
8850
  //aria-activedescendant at it: a reference to an unnamed element resolves
@@ -9161,7 +9204,7 @@ var fml = (function (exports) {
9161
9204
  return;
9162
9205
  }
9163
9206
  this.#abortdload();
9164
- this.#close();
9207
+ this.#close(true);
9165
9208
  });
9166
9209
  this.#input.addEventListener('keydown', (e) => {
9167
9210
  if (!this._interactive()) {
@@ -9194,7 +9237,6 @@ var fml = (function (exports) {
9194
9237
  this.#editing = false;
9195
9238
  this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
9196
9239
  this.#changed();
9197
- this.#syncBadges();
9198
9240
  this.#input.focus();
9199
9241
  this.#ddmenu.hide();
9200
9242
  if (!this.#multiple) {
@@ -9251,7 +9293,6 @@ var fml = (function (exports) {
9251
9293
  }
9252
9294
  this.#values.delete(key);
9253
9295
  this.#changed();
9254
- this.#syncBadges();
9255
9296
  }
9256
9297
  #chipKeydown(e, badge) {
9257
9298
  switch (e.code) {
@@ -9347,7 +9388,7 @@ var fml = (function (exports) {
9347
9388
  }
9348
9389
  case 'Tab': {
9349
9390
  this.#abortdload();
9350
- this.#close();
9391
+ this.#close(true);
9351
9392
  break;
9352
9393
  }
9353
9394
  }
@@ -9363,12 +9404,25 @@ var fml = (function (exports) {
9363
9404
  }
9364
9405
  return;
9365
9406
  }
9366
- this.#browse();
9367
- this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
9407
+ this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
9368
9408
  }
9369
- #close() {
9409
+ /**
9410
+ * @param commit whether the user is leaving the field, which only a blur or
9411
+ * a Tab is: Escape cancels, and closing the list or a claim landing leaves
9412
+ * the edit in progress
9413
+ */
9414
+ #close(commit = false) {
9370
9415
  this.#ddmenu.hide();
9416
+ //emptying the box and leaving is a clear: redisplaying the label would
9417
+ //discard the edit without a word. A multiple select keeps its box empty
9418
+ //by design, so only a single one can mean it
9419
+ const cleared =
9420
+ commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
9371
9421
  this.#editing = false;
9422
+ if (cleared) {
9423
+ this.#values.clear();
9424
+ this.#changed();
9425
+ }
9372
9426
  this.#display();
9373
9427
  }
9374
9428
  /**
@@ -9377,14 +9431,15 @@ var fml = (function (exports) {
9377
9431
  * highlighted.
9378
9432
  */
9379
9433
  #open() {
9380
- this.#browse();
9381
- return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
9434
+ return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
9382
9435
  }
9383
- #browse() {
9384
- if (this.#editing) {
9385
- return;
9386
- }
9387
- this.#input.value = '';
9436
+ /**
9437
+ * Browsing is the whole vocabulary, not the label already chosen, and the
9438
+ * label stays on screen while it happens: a select showing nothing reads as
9439
+ * an empty one.
9440
+ */
9441
+ #query() {
9442
+ return this.#editing ? this.#input.value : '';
9388
9443
  }
9389
9444
  #display() {
9390
9445
  const entry = this.#values.values().next().value;
@@ -9395,6 +9450,9 @@ var fml = (function (exports) {
9395
9450
  return [...this.#values.values()];
9396
9451
  }
9397
9452
  #changed() {
9453
+ //settled before the event: a listener reading the control during it has to
9454
+ //see the selection that caused it
9455
+ this.#syncBadges();
9398
9456
  //the detail carries the keys the value property answers with, as every
9399
9457
  //other field's does, and the labeled selection beside them
9400
9458
  this._notifyChange({ entry: this.entry });
@@ -10041,6 +10099,7 @@ var fml = (function (exports) {
10041
10099
  static slots = true;
10042
10100
  static config = {
10043
10101
  searchIcon: 'search',
10102
+ emptyIcon: 'inbox',
10044
10103
  };
10045
10104
  static template = `
10046
10105
  <ful-form data-tpl-if="slots.filters">
@@ -10054,10 +10113,21 @@ var fml = (function (exports) {
10054
10113
  <tbody data-ref="initial">
10055
10114
  <tr>
10056
10115
  <td data-tpl-colspan="schema.length">
10057
- <div>
10058
- <p data-tpl-if="config.searchIcon"><ful-icon data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon></p>
10116
+ <ful-empty>
10117
+ <ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
10059
10118
  {{ #l10n:t('table.initial') }}
10060
- </div>
10119
+ </ful-empty>
10120
+ </td>
10121
+ </tr>
10122
+ </tbody>
10123
+ <tbody data-ref="empty" hidden>
10124
+ <tr>
10125
+ <td data-tpl-colspan="schema.length">
10126
+ <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
10127
+ <ful-empty data-tpl-if="!slots.empty">
10128
+ <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
10129
+ {{ #l10n:t('table.no-data') }}
10130
+ </ful-empty>
10061
10131
  </td>
10062
10132
  </tr>
10063
10133
  </tbody>
@@ -10087,11 +10157,6 @@ var fml = (function (exports) {
10087
10157
  `;
10088
10158
  static templates = {
10089
10159
  row: `
10090
- <tr data-tpl-if="pageResponse.data.length == 0">
10091
- <td data-tpl-colspan="schema.length">
10092
- {{ #l10n:t('table.no-data') }}
10093
- </td>
10094
- </tr>
10095
10160
  {{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
10096
10161
  `,
10097
10162
  };
@@ -10100,6 +10165,7 @@ var fml = (function (exports) {
10100
10165
  #body;
10101
10166
  #loading;
10102
10167
  #noAutoload;
10168
+ #empty;
10103
10169
  #feedback;
10104
10170
  #paginator;
10105
10171
  #sorters;
@@ -10150,6 +10216,7 @@ var fml = (function (exports) {
10150
10216
  this.#body = table.querySelector(':scope > tbody');
10151
10217
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
10152
10218
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
10219
+ this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
10153
10220
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
10154
10221
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
10155
10222
  this.replaceChildren(fragment);
@@ -10240,6 +10307,7 @@ var fml = (function (exports) {
10240
10307
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
10241
10308
  this.#feedback.setAttribute('hidden', '');
10242
10309
  this.#noAutoload.setAttribute('hidden', '');
10310
+ this.#empty.setAttribute('hidden', '');
10243
10311
  this.setAttribute('aria-busy', 'true');
10244
10312
  try {
10245
10313
  const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
@@ -10248,6 +10316,16 @@ var fml = (function (exports) {
10248
10316
  }
10249
10317
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
10250
10318
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
10319
+ //settled before the event: a listener counting what loaded has to
10320
+ //see the table as it now is
10321
+ this.removeAttribute('aria-busy');
10322
+ this.dispatchEvent(
10323
+ new CustomEvent('load:success', {
10324
+ bubbles: true,
10325
+ cancelable: false,
10326
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
10327
+ }),
10328
+ );
10251
10329
  } catch (/** @type any */ error) {
10252
10330
  if (claim.stale) {
10253
10331
  //the newer load owns the table and its outcome: a superseded
@@ -10264,12 +10342,15 @@ var fml = (function (exports) {
10264
10342
  error,
10265
10343
  `${error}`,
10266
10344
  );
10345
+ this.removeAttribute('aria-busy');
10346
+ this.dispatchEvent(
10347
+ new CustomEvent('load:failure', {
10348
+ bubbles: true,
10349
+ cancelable: false,
10350
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
10351
+ }),
10352
+ );
10267
10353
  throw error;
10268
- } finally {
10269
- //a superseded load owns nothing, the newer one's busy state included
10270
- if (!claim.stale) {
10271
- this.removeAttribute('aria-busy');
10272
- }
10273
10354
  }
10274
10355
  }
10275
10356
  /** Hands the loader to the callback, for runtime reconfigurations. */
@@ -10306,6 +10387,7 @@ var fml = (function (exports) {
10306
10387
  })
10307
10388
  .render(),
10308
10389
  );
10390
+ this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
10309
10391
  //one move, one repaint: the page and the count are the same state
10310
10392
  this.#paginator.update({ current: pageRequest.page, total: pages });
10311
10393
  }
@@ -10559,6 +10641,19 @@ var fml = (function (exports) {
10559
10641
  const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
10560
10642
  const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
10561
10643
 
10644
+ /**
10645
+ * The operands are the words the reader chose, not the keys the wire carries.
10646
+ * @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
10647
+ */
10648
+
10649
+ const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
10650
+
10651
+ /** @returns {FilterCriterion|null} */
10652
+ const asCriterion = (label, operator, operands) => {
10653
+ const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
10654
+ return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
10655
+ };
10656
+
10562
10657
  /**
10563
10658
  * The shared shape of every operator-and-operands filter: an operator menu, one
10564
10659
  * or two operands of the type the subclass declares, and a tuple that mirrors
@@ -10698,6 +10793,18 @@ var fml = (function (exports) {
10698
10793
  _syncBetween() {
10699
10794
  this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
10700
10795
  }
10796
+ /**
10797
+ * The operands as shown rather than as serialized: a date filter describes
10798
+ * what is in its control, not the iso string it sends.
10799
+ * @returns {FilterCriterion|null}
10800
+ */
10801
+ get criterion() {
10802
+ const operands =
10803
+ this._operator.value === 'BETWEEN'
10804
+ ? [this._value1.value, this._value2.value]
10805
+ : [this._value1.value];
10806
+ return asCriterion(labelTextOf(this), this._operator.value, operands);
10807
+ }
10701
10808
  get disabled() {
10702
10809
  return super.disabled;
10703
10810
  }
@@ -10933,6 +11040,13 @@ var fml = (function (exports) {
10933
11040
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
10934
11041
  this._value.value = v[1] ?? '';
10935
11042
  }
11043
+ /** @returns {FilterCriterion|null} */
11044
+ get criterion() {
11045
+ const token = this._value.value;
11046
+ return asCriterion(labelTextOf(this), this._operator.value, [
11047
+ token === '' ? '' : booleanValueLabel(token),
11048
+ ]);
11049
+ }
10936
11050
  get disabled() {
10937
11051
  return super.disabled;
10938
11052
  }
@@ -10945,6 +11059,37 @@ var fml = (function (exports) {
10945
11059
  }
10946
11060
  }
10947
11061
 
11062
+ /**
11063
+ * Set membership over a select's vocabulary, answering for both of data-jpa's
11064
+ * set annotations: InEnum and InList take the same bare array of values and no
11065
+ * operator, differing only in what the server converts them to.
11066
+ */
11067
+ class InFilter extends Select {
11068
+ async upgrade() {
11069
+ //the snapshot is taken inside super.upgrade(), so an attribute this
11070
+ //element does not let an author turn off has to be written before it
11071
+ this.toggleAttribute('multiple', true);
11072
+ await super.upgrade();
11073
+ }
11074
+ get value() {
11075
+ const keys = super.value;
11076
+ //an empty set is no filter: the table drops a falsy value, where an
11077
+ //empty array would travel and match nothing
11078
+ return keys.length === 0 ? null : keys;
11079
+ }
11080
+ set value(v) {
11081
+ super.value = v ?? [];
11082
+ }
11083
+ /** @returns {FilterCriterion|null} */
11084
+ get criterion() {
11085
+ return asCriterion(
11086
+ labelTextOf(this),
11087
+ null,
11088
+ (super.entry ?? []).map((e) => e.label),
11089
+ );
11090
+ }
11091
+ }
11092
+
10948
11093
  /**
10949
11094
  * The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
10950
11095
  * ful-drawer: every activation of a section fires the section:requested family
@@ -12108,6 +12253,7 @@ var fml = (function (exports) {
12108
12253
  .defineElement('ful-filter-number', NumberFilter)
12109
12254
  .defineElement('ful-filter-boolean', BooleanFilter)
12110
12255
  .defineElement('ful-filter-text', TextFilter)
12256
+ .defineElement('ful-filter-in', InFilter)
12111
12257
  .defineElement('ful-select', Select)
12112
12258
  .defineElement('ful-dropdown', Dropdown)
12113
12259
  .defineComponent('loaders:select', SelectLoader)
@@ -12140,6 +12286,7 @@ var fml = (function (exports) {
12140
12286
  Field: Field,
12141
12287
  Form: Form,
12142
12288
  FormLoader: FormLoader,
12289
+ InFilter: InFilter,
12143
12290
  Input: Input,
12144
12291
  InputFile: InputFile,
12145
12292
  InputInstant: InputInstant,
@@ -12208,6 +12355,7 @@ var fml = (function (exports) {
12208
12355
  exports.HttpInterceptorChain = HttpInterceptorChain;
12209
12356
  exports.HttpMultipartRequestCustomizer = HttpMultipartRequestCustomizer;
12210
12357
  exports.HttpRequestBuilder = HttpRequestBuilder;
12358
+ exports.InFilter = InFilter;
12211
12359
  exports.Input = Input;
12212
12360
  exports.InputFile = InputFile;
12213
12361
  exports.InputInstant = InputInstant;