@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/ful.mjs CHANGED
@@ -1407,6 +1407,22 @@ const compiled = (attr, pattern) =>
1407
1407
  */
1408
1408
  const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
1409
1409
 
1410
+ /** Rendering a text input is what costs these the native min/max/step enforcement. */
1411
+ const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
1412
+
1413
+ const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
1414
+
1415
+ const NUMERIC_FILTERS = {
1416
+ numeric: (v) => signed(v, (t) => t.replace(/\D/g, '')),
1417
+ //one separator, the first: the rest are a slip while typing, not a value
1418
+ decimal: (v) =>
1419
+ signed(v, (t) => {
1420
+ const kept = t.replace(/[^\d.,]/g, '');
1421
+ const at = kept.search(/[.,]/);
1422
+ return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
1423
+ }),
1424
+ };
1425
+
1410
1426
  const warnedBoth = new WeakSet();
1411
1427
  const filterOf = (el) => {
1412
1428
  const keep = el.declared('keep');
@@ -1426,7 +1442,7 @@ const filterOf = (el) => {
1426
1442
  const re = compiled('reject', reject);
1427
1443
  return re && ((v) => v.replace(re, ''));
1428
1444
  }
1429
- return null;
1445
+ return NUMERIC_FILTERS[el._type()] ?? null;
1430
1446
  };
1431
1447
 
1432
1448
  /** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
@@ -1462,9 +1478,17 @@ class Input extends Field {
1462
1478
  return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
1463
1479
  }
1464
1480
  _build({ slots }) {
1465
- const type = this._type();
1481
+ const declared = this._type();
1482
+ const mode = INPUT_MODES[declared];
1483
+ //a numeric widget is a text input: the type names the keyboard, not the
1484
+ //control, so the rendered markup stays valid html
1485
+ const type = mode ? 'text' : declared;
1466
1486
  const fragment = this.template().withOverlay({ type, slots }).render();
1467
1487
  this._input = fragment.querySelector('input,textarea');
1488
+ if (mode) {
1489
+ //before the passthrough, which stays the last word
1490
+ Attributes.set(this._input, 'inputmode', mode);
1491
+ }
1468
1492
 
1469
1493
  //the browser reads autocomplete off the control it is classifying, so the
1470
1494
  //field's own token, or the form's where it declares none, is put there.
@@ -1516,13 +1540,15 @@ class Input extends Field {
1516
1540
  if (trimmed === '') {
1517
1541
  return null;
1518
1542
  }
1543
+ //the wire carries a dot wherever the comma is what the keyboard shows
1544
+ const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
1519
1545
  if (this.declared('v-type') === 'number') {
1520
1546
  //typed values are an explicit opt in, as the select's k-type: blank
1521
1547
  //stays null, and a value that does not decode is kept as it is
1522
- const n = Number(trimmed);
1523
- return Number.isNaN(n) ? trimmed : n;
1548
+ const n = Number(normalized);
1549
+ return Number.isNaN(n) ? normalized : n;
1524
1550
  }
1525
- return trimmed;
1551
+ return normalized;
1526
1552
  }
1527
1553
  set value(value) {
1528
1554
  this._input.value = value === '' || value === undefined ? null : value;
@@ -2514,10 +2540,27 @@ class SelectLoader {
2514
2540
  method: el.declared('method') ?? 'POST',
2515
2541
  mode: el.declared('mode'),
2516
2542
  prefetch: el.declared('preload'),
2517
- revision: el.declared('revision'),
2543
+ revision: SelectLoader.#revisionFrom(el),
2518
2544
  responseMapper: SelectLoader.#responseMapperFrom(el),
2519
2545
  });
2520
2546
  }
2547
+ static #revisionFrom(el) {
2548
+ const declared = el.declared('revision');
2549
+ if (declared !== '') {
2550
+ return declared;
2551
+ }
2552
+ const configured = el.component('revision');
2553
+ if (configured === undefined || configured === null) {
2554
+ //not caching while the markup says to cache is the quiet failure
2555
+ //this branch exists to make loud
2556
+ console.warn(
2557
+ "a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
2558
+ el,
2559
+ );
2560
+ return null;
2561
+ }
2562
+ return typeof configured === 'function' ? configured() : configured;
2563
+ }
2521
2564
  static #responseMapperFrom(el) {
2522
2565
  if (el.declared('k-expr') && el.declared('l-expr')) {
2523
2566
  return (response) => {
@@ -2550,7 +2593,7 @@ class Dropdown extends ParsedElement {
2550
2593
  static slots = true;
2551
2594
  static template = `
2552
2595
  <ful-spinner class="centered" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
2553
- <p data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</p>
2596
+ <ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
2554
2597
  <menu tabindex="-1" role="listbox" hidden></menu>
2555
2598
  `;
2556
2599
  static templates = {
@@ -2572,7 +2615,7 @@ class Dropdown extends ParsedElement {
2572
2615
  ? this.template('options')
2573
2616
  : Templates.fromFragment(slots.default);
2574
2617
  this.#spinner = fragment.querySelector('ful-spinner');
2575
- this.#empty = fragment.querySelector('p[data-ref=empty]');
2618
+ this.#empty = fragment.querySelector('[data-ref=empty]');
2576
2619
  this.#menu = fragment.querySelector('menu');
2577
2620
  //the listbox is named so a combobox can point aria-controls and
2578
2621
  //aria-activedescendant at it: a reference to an unnamed element resolves
@@ -2932,7 +2975,7 @@ class Select extends Field {
2932
2975
  return;
2933
2976
  }
2934
2977
  this.#abortdload();
2935
- this.#close();
2978
+ this.#close(true);
2936
2979
  });
2937
2980
  this.#input.addEventListener('keydown', (e) => {
2938
2981
  if (!this._interactive()) {
@@ -2965,7 +3008,6 @@ class Select extends Field {
2965
3008
  this.#editing = false;
2966
3009
  this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
2967
3010
  this.#changed();
2968
- this.#syncBadges();
2969
3011
  this.#input.focus();
2970
3012
  this.#ddmenu.hide();
2971
3013
  if (!this.#multiple) {
@@ -3022,7 +3064,6 @@ class Select extends Field {
3022
3064
  }
3023
3065
  this.#values.delete(key);
3024
3066
  this.#changed();
3025
- this.#syncBadges();
3026
3067
  }
3027
3068
  #chipKeydown(e, badge) {
3028
3069
  switch (e.code) {
@@ -3118,7 +3159,7 @@ class Select extends Field {
3118
3159
  }
3119
3160
  case 'Tab': {
3120
3161
  this.#abortdload();
3121
- this.#close();
3162
+ this.#close(true);
3122
3163
  break;
3123
3164
  }
3124
3165
  }
@@ -3134,12 +3175,25 @@ class Select extends Field {
3134
3175
  }
3135
3176
  return;
3136
3177
  }
3137
- this.#browse();
3138
- this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
3178
+ this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
3139
3179
  }
3140
- #close() {
3180
+ /**
3181
+ * @param commit whether the user is leaving the field, which only a blur or
3182
+ * a Tab is: Escape cancels, and closing the list or a claim landing leaves
3183
+ * the edit in progress
3184
+ */
3185
+ #close(commit = false) {
3141
3186
  this.#ddmenu.hide();
3187
+ //emptying the box and leaving is a clear: redisplaying the label would
3188
+ //discard the edit without a word. A multiple select keeps its box empty
3189
+ //by design, so only a single one can mean it
3190
+ const cleared =
3191
+ commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
3142
3192
  this.#editing = false;
3193
+ if (cleared) {
3194
+ this.#values.clear();
3195
+ this.#changed();
3196
+ }
3143
3197
  this.#display();
3144
3198
  }
3145
3199
  /**
@@ -3148,14 +3202,15 @@ class Select extends Field {
3148
3202
  * highlighted.
3149
3203
  */
3150
3204
  #open() {
3151
- this.#browse();
3152
- return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
3205
+ return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
3153
3206
  }
3154
- #browse() {
3155
- if (this.#editing) {
3156
- return;
3157
- }
3158
- this.#input.value = '';
3207
+ /**
3208
+ * Browsing is the whole vocabulary, not the label already chosen, and the
3209
+ * label stays on screen while it happens: a select showing nothing reads as
3210
+ * an empty one.
3211
+ */
3212
+ #query() {
3213
+ return this.#editing ? this.#input.value : '';
3159
3214
  }
3160
3215
  #display() {
3161
3216
  const entry = this.#values.values().next().value;
@@ -3166,6 +3221,9 @@ class Select extends Field {
3166
3221
  return [...this.#values.values()];
3167
3222
  }
3168
3223
  #changed() {
3224
+ //settled before the event: a listener reading the control during it has to
3225
+ //see the selection that caused it
3226
+ this.#syncBadges();
3169
3227
  //the detail carries the keys the value property answers with, as every
3170
3228
  //other field's does, and the labeled selection beside them
3171
3229
  this._notifyChange({ entry: this.entry });
@@ -3812,6 +3870,7 @@ class Table extends ParsedElement {
3812
3870
  static slots = true;
3813
3871
  static config = {
3814
3872
  searchIcon: 'search',
3873
+ emptyIcon: 'inbox',
3815
3874
  };
3816
3875
  static template = `
3817
3876
  <ful-form data-tpl-if="slots.filters">
@@ -3825,10 +3884,21 @@ class Table extends ParsedElement {
3825
3884
  <tbody data-ref="initial">
3826
3885
  <tr>
3827
3886
  <td data-tpl-colspan="schema.length">
3828
- <div>
3829
- <p data-tpl-if="config.searchIcon"><ful-icon data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon></p>
3887
+ <ful-empty>
3888
+ <ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
3830
3889
  {{ #l10n:t('table.initial') }}
3831
- </div>
3890
+ </ful-empty>
3891
+ </td>
3892
+ </tr>
3893
+ </tbody>
3894
+ <tbody data-ref="empty" hidden>
3895
+ <tr>
3896
+ <td data-tpl-colspan="schema.length">
3897
+ <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
3898
+ <ful-empty data-tpl-if="!slots.empty">
3899
+ <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
3900
+ {{ #l10n:t('table.no-data') }}
3901
+ </ful-empty>
3832
3902
  </td>
3833
3903
  </tr>
3834
3904
  </tbody>
@@ -3858,11 +3928,6 @@ class Table extends ParsedElement {
3858
3928
  `;
3859
3929
  static templates = {
3860
3930
  row: `
3861
- <tr data-tpl-if="pageResponse.data.length == 0">
3862
- <td data-tpl-colspan="schema.length">
3863
- {{ #l10n:t('table.no-data') }}
3864
- </td>
3865
- </tr>
3866
3931
  {{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
3867
3932
  `,
3868
3933
  };
@@ -3871,6 +3936,7 @@ class Table extends ParsedElement {
3871
3936
  #body;
3872
3937
  #loading;
3873
3938
  #noAutoload;
3939
+ #empty;
3874
3940
  #feedback;
3875
3941
  #paginator;
3876
3942
  #sorters;
@@ -3921,6 +3987,7 @@ class Table extends ParsedElement {
3921
3987
  this.#body = table.querySelector(':scope > tbody');
3922
3988
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
3923
3989
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
3990
+ this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
3924
3991
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
3925
3992
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
3926
3993
  this.replaceChildren(fragment);
@@ -4011,6 +4078,7 @@ class Table extends ParsedElement {
4011
4078
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
4012
4079
  this.#feedback.setAttribute('hidden', '');
4013
4080
  this.#noAutoload.setAttribute('hidden', '');
4081
+ this.#empty.setAttribute('hidden', '');
4014
4082
  this.setAttribute('aria-busy', 'true');
4015
4083
  try {
4016
4084
  const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
@@ -4019,6 +4087,16 @@ class Table extends ParsedElement {
4019
4087
  }
4020
4088
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
4021
4089
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
4090
+ //settled before the event: a listener counting what loaded has to
4091
+ //see the table as it now is
4092
+ this.removeAttribute('aria-busy');
4093
+ this.dispatchEvent(
4094
+ new CustomEvent('load:success', {
4095
+ bubbles: true,
4096
+ cancelable: false,
4097
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
4098
+ }),
4099
+ );
4022
4100
  } catch (/** @type any */ error) {
4023
4101
  if (claim.stale) {
4024
4102
  //the newer load owns the table and its outcome: a superseded
@@ -4035,12 +4113,15 @@ class Table extends ParsedElement {
4035
4113
  error,
4036
4114
  `${error}`,
4037
4115
  );
4116
+ this.removeAttribute('aria-busy');
4117
+ this.dispatchEvent(
4118
+ new CustomEvent('load:failure', {
4119
+ bubbles: true,
4120
+ cancelable: false,
4121
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
4122
+ }),
4123
+ );
4038
4124
  throw error;
4039
- } finally {
4040
- //a superseded load owns nothing, the newer one's busy state included
4041
- if (!claim.stale) {
4042
- this.removeAttribute('aria-busy');
4043
- }
4044
4125
  }
4045
4126
  }
4046
4127
  /** Hands the loader to the callback, for runtime reconfigurations. */
@@ -4077,6 +4158,7 @@ class Table extends ParsedElement {
4077
4158
  })
4078
4159
  .render(),
4079
4160
  );
4161
+ this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
4080
4162
  //one move, one repaint: the page and the count are the same state
4081
4163
  this.#paginator.update({ current: pageRequest.page, total: pages });
4082
4164
  }
@@ -4330,6 +4412,19 @@ const operatorLabel = (op) => t(`filters.op.${op}`);
4330
4412
  const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
4331
4413
  const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
4332
4414
 
4415
+ /**
4416
+ * The operands are the words the reader chose, not the keys the wire carries.
4417
+ * @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
4418
+ */
4419
+
4420
+ const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
4421
+
4422
+ /** @returns {FilterCriterion|null} */
4423
+ const asCriterion = (label, operator, operands) => {
4424
+ const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
4425
+ return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
4426
+ };
4427
+
4333
4428
  /**
4334
4429
  * The shared shape of every operator-and-operands filter: an operator menu, one
4335
4430
  * or two operands of the type the subclass declares, and a tuple that mirrors
@@ -4469,6 +4564,18 @@ class CompareFilter extends Input {
4469
4564
  _syncBetween() {
4470
4565
  this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
4471
4566
  }
4567
+ /**
4568
+ * The operands as shown rather than as serialized: a date filter describes
4569
+ * what is in its control, not the iso string it sends.
4570
+ * @returns {FilterCriterion|null}
4571
+ */
4572
+ get criterion() {
4573
+ const operands =
4574
+ this._operator.value === 'BETWEEN'
4575
+ ? [this._value1.value, this._value2.value]
4576
+ : [this._value1.value];
4577
+ return asCriterion(labelTextOf(this), this._operator.value, operands);
4578
+ }
4472
4579
  get disabled() {
4473
4580
  return super.disabled;
4474
4581
  }
@@ -4704,6 +4811,13 @@ class BooleanFilter extends Field {
4704
4811
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
4705
4812
  this._value.value = v[1] ?? '';
4706
4813
  }
4814
+ /** @returns {FilterCriterion|null} */
4815
+ get criterion() {
4816
+ const token = this._value.value;
4817
+ return asCriterion(labelTextOf(this), this._operator.value, [
4818
+ token === '' ? '' : booleanValueLabel(token),
4819
+ ]);
4820
+ }
4707
4821
  get disabled() {
4708
4822
  return super.disabled;
4709
4823
  }
@@ -4716,6 +4830,37 @@ class BooleanFilter extends Field {
4716
4830
  }
4717
4831
  }
4718
4832
 
4833
+ /**
4834
+ * Set membership over a select's vocabulary, answering for both of data-jpa's
4835
+ * set annotations: InEnum and InList take the same bare array of values and no
4836
+ * operator, differing only in what the server converts them to.
4837
+ */
4838
+ class InFilter extends Select {
4839
+ async upgrade() {
4840
+ //the snapshot is taken inside super.upgrade(), so an attribute this
4841
+ //element does not let an author turn off has to be written before it
4842
+ this.toggleAttribute('multiple', true);
4843
+ await super.upgrade();
4844
+ }
4845
+ get value() {
4846
+ const keys = super.value;
4847
+ //an empty set is no filter: the table drops a falsy value, where an
4848
+ //empty array would travel and match nothing
4849
+ return keys.length === 0 ? null : keys;
4850
+ }
4851
+ set value(v) {
4852
+ super.value = v ?? [];
4853
+ }
4854
+ /** @returns {FilterCriterion|null} */
4855
+ get criterion() {
4856
+ return asCriterion(
4857
+ labelTextOf(this),
4858
+ null,
4859
+ (super.entry ?? []).map((e) => e.label),
4860
+ );
4861
+ }
4862
+ }
4863
+
4719
4864
  /**
4720
4865
  * The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
4721
4866
  * ful-drawer: every activation of a section fires the section:requested family
@@ -5879,6 +6024,7 @@ class Plugin {
5879
6024
  .defineElement('ful-filter-number', NumberFilter)
5880
6025
  .defineElement('ful-filter-boolean', BooleanFilter)
5881
6026
  .defineElement('ful-filter-text', TextFilter)
6027
+ .defineElement('ful-filter-in', InFilter)
5882
6028
  .defineElement('ful-select', Select)
5883
6029
  .defineElement('ful-dropdown', Dropdown)
5884
6030
  .defineComponent('loaders:select', SelectLoader)
@@ -5895,5 +6041,5 @@ class Plugin {
5895
6041
  }
5896
6042
  }
5897
6043
 
5898
- export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
6044
+ export { Accordion, Anchors, AsyncEvents, Bindings, BooleanFilter, Checkbox, Claims, CompareFilter, Dialog, Drawer, Dropdown, Field, Form, FormLoader, InFilter, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LocalDate, LocalDateFilter, LocalStorage, NumberFilter, Pagination, Plugin, RadioGroup, Select, SelectLoader, SessionStorage, SortButton, Table, TableLoader, TableSchemaParser, Tabs, TextFilter, Timing, Toasts, Tooltip, VersionedLocalStorage, VersionedSessionStorage, Wizard, describable };
5899
6045
  //# sourceMappingURL=ful.mjs.map