@optionfactory/fml 9.0.0-rc10 → 9.0.0-rc12

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,29 @@ 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_DIGITS = {
7645
+ numeric: (t) => t.replace(/\D/g, ''),
7646
+ //one separator, the first: the rest are a slip while typing, not a value
7647
+ decimal: (t) => {
7648
+ const kept = t.replace(/[^\d.,]/g, '');
7649
+ const at = kept.search(/[.,]/);
7650
+ return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
7651
+ },
7652
+ };
7653
+
7654
+ const numericFilter = (type, unsigned) => {
7655
+ const digits = NUMERIC_DIGITS[type];
7656
+ if (!digits) {
7657
+ return null;
7658
+ }
7659
+ return unsigned ? digits : (v) => signed(v, digits);
7660
+ };
7661
+
7639
7662
  const warnedBoth = new WeakSet();
7640
7663
  const filterOf = (el) => {
7641
7664
  const keep = el.declared('keep');
@@ -7655,7 +7678,7 @@ var fml = (function (exports) {
7655
7678
  const re = compiled('reject', reject);
7656
7679
  return re && ((v) => v.replace(re, ''));
7657
7680
  }
7658
- return null;
7681
+ return numericFilter(el._type(), el.declared('unsigned'));
7659
7682
  };
7660
7683
 
7661
7684
  /** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
@@ -7670,6 +7693,7 @@ var fml = (function (exports) {
7670
7693
  'reject',
7671
7694
  'uppercase:presence',
7672
7695
  'trim:presence',
7696
+ 'unsigned:presence',
7673
7697
  'autocomplete',
7674
7698
  ];
7675
7699
  static slots = true;
@@ -7691,9 +7715,17 @@ var fml = (function (exports) {
7691
7715
  return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
7692
7716
  }
7693
7717
  _build({ slots }) {
7694
- const type = this._type();
7718
+ const declared = this._type();
7719
+ const mode = INPUT_MODES[declared];
7720
+ //a numeric widget is a text input: the type names the keyboard, not the
7721
+ //control, so the rendered markup stays valid html
7722
+ const type = mode ? 'text' : declared;
7695
7723
  const fragment = this.template().withOverlay({ type, slots }).render();
7696
7724
  this._input = fragment.querySelector('input,textarea');
7725
+ if (mode) {
7726
+ //before the passthrough, which stays the last word
7727
+ Attributes.set(this._input, 'inputmode', mode);
7728
+ }
7697
7729
 
7698
7730
  //the browser reads autocomplete off the control it is classifying, so the
7699
7731
  //field's own token, or the form's where it declares none, is put there.
@@ -7745,13 +7777,15 @@ var fml = (function (exports) {
7745
7777
  if (trimmed === '') {
7746
7778
  return null;
7747
7779
  }
7780
+ //the wire carries a dot wherever the comma is what the keyboard shows
7781
+ const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
7748
7782
  if (this.declared('v-type') === 'number') {
7749
7783
  //typed values are an explicit opt in, as the select's k-type: blank
7750
7784
  //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;
7785
+ const n = Number(normalized);
7786
+ return Number.isNaN(n) ? normalized : n;
7753
7787
  }
7754
- return trimmed;
7788
+ return normalized;
7755
7789
  }
7756
7790
  set value(value) {
7757
7791
  this._input.value = value === '' || value === undefined ? null : value;
@@ -8743,10 +8777,27 @@ var fml = (function (exports) {
8743
8777
  method: el.declared('method') ?? 'POST',
8744
8778
  mode: el.declared('mode'),
8745
8779
  prefetch: el.declared('preload'),
8746
- revision: el.declared('revision'),
8780
+ revision: SelectLoader.#revisionFrom(el),
8747
8781
  responseMapper: SelectLoader.#responseMapperFrom(el),
8748
8782
  });
8749
8783
  }
8784
+ static #revisionFrom(el) {
8785
+ const declared = el.declared('revision');
8786
+ if (declared !== '') {
8787
+ return declared;
8788
+ }
8789
+ const configured = el.component('revision');
8790
+ if (configured === undefined || configured === null) {
8791
+ //not caching while the markup says to cache is the quiet failure
8792
+ //this branch exists to make loud
8793
+ console.warn(
8794
+ "a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
8795
+ el,
8796
+ );
8797
+ return null;
8798
+ }
8799
+ return typeof configured === 'function' ? configured() : configured;
8800
+ }
8750
8801
  static #responseMapperFrom(el) {
8751
8802
  if (el.declared('k-expr') && el.declared('l-expr')) {
8752
8803
  return (response) => {
@@ -8779,7 +8830,7 @@ var fml = (function (exports) {
8779
8830
  static slots = true;
8780
8831
  static template = `
8781
8832
  <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>
8833
+ <ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
8783
8834
  <menu tabindex="-1" role="listbox" hidden></menu>
8784
8835
  `;
8785
8836
  static templates = {
@@ -8801,7 +8852,7 @@ var fml = (function (exports) {
8801
8852
  ? this.template('options')
8802
8853
  : Templates.fromFragment(slots.default);
8803
8854
  this.#spinner = fragment.querySelector('ful-spinner');
8804
- this.#empty = fragment.querySelector('p[data-ref=empty]');
8855
+ this.#empty = fragment.querySelector('[data-ref=empty]');
8805
8856
  this.#menu = fragment.querySelector('menu');
8806
8857
  //the listbox is named so a combobox can point aria-controls and
8807
8858
  //aria-activedescendant at it: a reference to an unnamed element resolves
@@ -9161,7 +9212,7 @@ var fml = (function (exports) {
9161
9212
  return;
9162
9213
  }
9163
9214
  this.#abortdload();
9164
- this.#close();
9215
+ this.#close(true);
9165
9216
  });
9166
9217
  this.#input.addEventListener('keydown', (e) => {
9167
9218
  if (!this._interactive()) {
@@ -9194,7 +9245,6 @@ var fml = (function (exports) {
9194
9245
  this.#editing = false;
9195
9246
  this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
9196
9247
  this.#changed();
9197
- this.#syncBadges();
9198
9248
  this.#input.focus();
9199
9249
  this.#ddmenu.hide();
9200
9250
  if (!this.#multiple) {
@@ -9251,7 +9301,6 @@ var fml = (function (exports) {
9251
9301
  }
9252
9302
  this.#values.delete(key);
9253
9303
  this.#changed();
9254
- this.#syncBadges();
9255
9304
  }
9256
9305
  #chipKeydown(e, badge) {
9257
9306
  switch (e.code) {
@@ -9347,7 +9396,7 @@ var fml = (function (exports) {
9347
9396
  }
9348
9397
  case 'Tab': {
9349
9398
  this.#abortdload();
9350
- this.#close();
9399
+ this.#close(true);
9351
9400
  break;
9352
9401
  }
9353
9402
  }
@@ -9363,12 +9412,25 @@ var fml = (function (exports) {
9363
9412
  }
9364
9413
  return;
9365
9414
  }
9366
- this.#browse();
9367
- this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
9415
+ this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
9368
9416
  }
9369
- #close() {
9417
+ /**
9418
+ * @param commit whether the user is leaving the field, which only a blur or
9419
+ * a Tab is: Escape cancels, and closing the list or a claim landing leaves
9420
+ * the edit in progress
9421
+ */
9422
+ #close(commit = false) {
9370
9423
  this.#ddmenu.hide();
9424
+ //emptying the box and leaving is a clear: redisplaying the label would
9425
+ //discard the edit without a word. A multiple select keeps its box empty
9426
+ //by design, so only a single one can mean it
9427
+ const cleared =
9428
+ commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
9371
9429
  this.#editing = false;
9430
+ if (cleared) {
9431
+ this.#values.clear();
9432
+ this.#changed();
9433
+ }
9372
9434
  this.#display();
9373
9435
  }
9374
9436
  /**
@@ -9377,14 +9439,15 @@ var fml = (function (exports) {
9377
9439
  * highlighted.
9378
9440
  */
9379
9441
  #open() {
9380
- this.#browse();
9381
- return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
9442
+ return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
9382
9443
  }
9383
- #browse() {
9384
- if (this.#editing) {
9385
- return;
9386
- }
9387
- this.#input.value = '';
9444
+ /**
9445
+ * Browsing is the whole vocabulary, not the label already chosen, and the
9446
+ * label stays on screen while it happens: a select showing nothing reads as
9447
+ * an empty one.
9448
+ */
9449
+ #query() {
9450
+ return this.#editing ? this.#input.value : '';
9388
9451
  }
9389
9452
  #display() {
9390
9453
  const entry = this.#values.values().next().value;
@@ -9395,6 +9458,9 @@ var fml = (function (exports) {
9395
9458
  return [...this.#values.values()];
9396
9459
  }
9397
9460
  #changed() {
9461
+ //settled before the event: a listener reading the control during it has to
9462
+ //see the selection that caused it
9463
+ this.#syncBadges();
9398
9464
  //the detail carries the keys the value property answers with, as every
9399
9465
  //other field's does, and the labeled selection beside them
9400
9466
  this._notifyChange({ entry: this.entry });
@@ -10041,6 +10107,7 @@ var fml = (function (exports) {
10041
10107
  static slots = true;
10042
10108
  static config = {
10043
10109
  searchIcon: 'search',
10110
+ emptyIcon: 'inbox',
10044
10111
  };
10045
10112
  static template = `
10046
10113
  <ful-form data-tpl-if="slots.filters">
@@ -10054,10 +10121,21 @@ var fml = (function (exports) {
10054
10121
  <tbody data-ref="initial">
10055
10122
  <tr>
10056
10123
  <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>
10124
+ <ful-empty>
10125
+ <ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
10059
10126
  {{ #l10n:t('table.initial') }}
10060
- </div>
10127
+ </ful-empty>
10128
+ </td>
10129
+ </tr>
10130
+ </tbody>
10131
+ <tbody data-ref="empty" hidden>
10132
+ <tr>
10133
+ <td data-tpl-colspan="schema.length">
10134
+ <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
10135
+ <ful-empty data-tpl-if="!slots.empty">
10136
+ <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
10137
+ {{ #l10n:t('table.no-data') }}
10138
+ </ful-empty>
10061
10139
  </td>
10062
10140
  </tr>
10063
10141
  </tbody>
@@ -10087,11 +10165,6 @@ var fml = (function (exports) {
10087
10165
  `;
10088
10166
  static templates = {
10089
10167
  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
10168
  {{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
10096
10169
  `,
10097
10170
  };
@@ -10100,6 +10173,7 @@ var fml = (function (exports) {
10100
10173
  #body;
10101
10174
  #loading;
10102
10175
  #noAutoload;
10176
+ #empty;
10103
10177
  #feedback;
10104
10178
  #paginator;
10105
10179
  #sorters;
@@ -10150,6 +10224,7 @@ var fml = (function (exports) {
10150
10224
  this.#body = table.querySelector(':scope > tbody');
10151
10225
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
10152
10226
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
10227
+ this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
10153
10228
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
10154
10229
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
10155
10230
  this.replaceChildren(fragment);
@@ -10240,6 +10315,7 @@ var fml = (function (exports) {
10240
10315
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
10241
10316
  this.#feedback.setAttribute('hidden', '');
10242
10317
  this.#noAutoload.setAttribute('hidden', '');
10318
+ this.#empty.setAttribute('hidden', '');
10243
10319
  this.setAttribute('aria-busy', 'true');
10244
10320
  try {
10245
10321
  const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
@@ -10248,6 +10324,16 @@ var fml = (function (exports) {
10248
10324
  }
10249
10325
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
10250
10326
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
10327
+ //settled before the event: a listener counting what loaded has to
10328
+ //see the table as it now is
10329
+ this.removeAttribute('aria-busy');
10330
+ this.dispatchEvent(
10331
+ new CustomEvent('load:success', {
10332
+ bubbles: true,
10333
+ cancelable: false,
10334
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
10335
+ }),
10336
+ );
10251
10337
  } catch (/** @type any */ error) {
10252
10338
  if (claim.stale) {
10253
10339
  //the newer load owns the table and its outcome: a superseded
@@ -10264,12 +10350,15 @@ var fml = (function (exports) {
10264
10350
  error,
10265
10351
  `${error}`,
10266
10352
  );
10353
+ this.removeAttribute('aria-busy');
10354
+ this.dispatchEvent(
10355
+ new CustomEvent('load:failure', {
10356
+ bubbles: true,
10357
+ cancelable: false,
10358
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
10359
+ }),
10360
+ );
10267
10361
  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
10362
  }
10274
10363
  }
10275
10364
  /** Hands the loader to the callback, for runtime reconfigurations. */
@@ -10306,6 +10395,7 @@ var fml = (function (exports) {
10306
10395
  })
10307
10396
  .render(),
10308
10397
  );
10398
+ this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
10309
10399
  //one move, one repaint: the page and the count are the same state
10310
10400
  this.#paginator.update({ current: pageRequest.page, total: pages });
10311
10401
  }
@@ -10559,6 +10649,19 @@ var fml = (function (exports) {
10559
10649
  const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
10560
10650
  const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
10561
10651
 
10652
+ /**
10653
+ * The operands are the words the reader chose, not the keys the wire carries.
10654
+ * @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
10655
+ */
10656
+
10657
+ const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
10658
+
10659
+ /** @returns {FilterCriterion|null} */
10660
+ const asCriterion = (label, operator, operands) => {
10661
+ const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
10662
+ return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
10663
+ };
10664
+
10562
10665
  /**
10563
10666
  * The shared shape of every operator-and-operands filter: an operator menu, one
10564
10667
  * or two operands of the type the subclass declares, and a tuple that mirrors
@@ -10698,6 +10801,18 @@ var fml = (function (exports) {
10698
10801
  _syncBetween() {
10699
10802
  this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
10700
10803
  }
10804
+ /**
10805
+ * The operands as shown rather than as serialized: a date filter describes
10806
+ * what is in its control, not the iso string it sends.
10807
+ * @returns {FilterCriterion|null}
10808
+ */
10809
+ get criterion() {
10810
+ const operands =
10811
+ this._operator.value === 'BETWEEN'
10812
+ ? [this._value1.value, this._value2.value]
10813
+ : [this._value1.value];
10814
+ return asCriterion(labelTextOf(this), this._operator.value, operands);
10815
+ }
10701
10816
  get disabled() {
10702
10817
  return super.disabled;
10703
10818
  }
@@ -10933,6 +11048,13 @@ var fml = (function (exports) {
10933
11048
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
10934
11049
  this._value.value = v[1] ?? '';
10935
11050
  }
11051
+ /** @returns {FilterCriterion|null} */
11052
+ get criterion() {
11053
+ const token = this._value.value;
11054
+ return asCriterion(labelTextOf(this), this._operator.value, [
11055
+ token === '' ? '' : booleanValueLabel(token),
11056
+ ]);
11057
+ }
10936
11058
  get disabled() {
10937
11059
  return super.disabled;
10938
11060
  }
@@ -10945,6 +11067,51 @@ var fml = (function (exports) {
10945
11067
  }
10946
11068
  }
10947
11069
 
11070
+ /**
11071
+ * Set membership over a select's vocabulary, answering for both of data-jpa's
11072
+ * set annotations: InEnum and InList take the same bare array of values and no
11073
+ * operator, differing only in what the server converts them to.
11074
+ */
11075
+ class InFilter extends Select {
11076
+ static attributes = ['operator'];
11077
+ /** the selection as the array the wire takes, whether one key was chosen or many */
11078
+ #keys() {
11079
+ const chosen = super.value;
11080
+ if (this.multiple) {
11081
+ return chosen;
11082
+ }
11083
+ return chosen === null || chosen === undefined ? [] : [chosen];
11084
+ }
11085
+ get value() {
11086
+ const keys = this.#keys();
11087
+ //an empty set is no filter: the table drops a falsy value, where an
11088
+ //empty array would travel and match nothing
11089
+ if (keys.length === 0) {
11090
+ return null;
11091
+ }
11092
+ const operator = this.declared('operator');
11093
+ return operator ? [operator, ...keys] : keys;
11094
+ }
11095
+ set value(v) {
11096
+ const operator = this.declared('operator');
11097
+ let keys = v === null || v === undefined ? [] : Array.isArray(v) ? v : [v];
11098
+ //what the getter answers has to be assignable back, and it carries the
11099
+ //operator in front of the keys
11100
+ if (operator && keys[0] === operator) {
11101
+ keys = keys.slice(1);
11102
+ }
11103
+ super.value = this.multiple ? keys : (keys[0] ?? null);
11104
+ }
11105
+ /** @returns {FilterCriterion|null} */
11106
+ get criterion() {
11107
+ return asCriterion(
11108
+ labelTextOf(this),
11109
+ this.declared('operator'),
11110
+ [super.entry].flat().filter((e) => e).map((e) => e.label),
11111
+ );
11112
+ }
11113
+ }
11114
+
10948
11115
  /**
10949
11116
  * The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
10950
11117
  * ful-drawer: every activation of a section fires the section:requested family
@@ -12108,6 +12275,7 @@ var fml = (function (exports) {
12108
12275
  .defineElement('ful-filter-number', NumberFilter)
12109
12276
  .defineElement('ful-filter-boolean', BooleanFilter)
12110
12277
  .defineElement('ful-filter-text', TextFilter)
12278
+ .defineElement('ful-filter-in', InFilter)
12111
12279
  .defineElement('ful-select', Select)
12112
12280
  .defineElement('ful-dropdown', Dropdown)
12113
12281
  .defineComponent('loaders:select', SelectLoader)
@@ -12140,6 +12308,7 @@ var fml = (function (exports) {
12140
12308
  Field: Field,
12141
12309
  Form: Form,
12142
12310
  FormLoader: FormLoader,
12311
+ InFilter: InFilter,
12143
12312
  Input: Input,
12144
12313
  InputFile: InputFile,
12145
12314
  InputInstant: InputInstant,
@@ -12208,6 +12377,7 @@ var fml = (function (exports) {
12208
12377
  exports.HttpInterceptorChain = HttpInterceptorChain;
12209
12378
  exports.HttpMultipartRequestCustomizer = HttpMultipartRequestCustomizer;
12210
12379
  exports.HttpRequestBuilder = HttpRequestBuilder;
12380
+ exports.InFilter = InFilter;
12211
12381
  exports.Input = Input;
12212
12382
  exports.InputFile = InputFile;
12213
12383
  exports.InputInstant = InputInstant;