@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/ful.mjs CHANGED
@@ -1407,6 +1407,29 @@ 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_DIGITS = {
1416
+ numeric: (t) => t.replace(/\D/g, ''),
1417
+ //one separator, the first: the rest are a slip while typing, not a value
1418
+ decimal: (t) => {
1419
+ const kept = t.replace(/[^\d.,]/g, '');
1420
+ const at = kept.search(/[.,]/);
1421
+ return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
1422
+ },
1423
+ };
1424
+
1425
+ const numericFilter = (type, unsigned) => {
1426
+ const digits = NUMERIC_DIGITS[type];
1427
+ if (!digits) {
1428
+ return null;
1429
+ }
1430
+ return unsigned ? digits : (v) => signed(v, digits);
1431
+ };
1432
+
1410
1433
  const warnedBoth = new WeakSet();
1411
1434
  const filterOf = (el) => {
1412
1435
  const keep = el.declared('keep');
@@ -1426,7 +1449,7 @@ const filterOf = (el) => {
1426
1449
  const re = compiled('reject', reject);
1427
1450
  return re && ((v) => v.replace(re, ''));
1428
1451
  }
1429
- return null;
1452
+ return numericFilter(el._type(), el.declared('unsigned'));
1430
1453
  };
1431
1454
 
1432
1455
  /** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
@@ -1441,6 +1464,7 @@ class Input extends Field {
1441
1464
  'reject',
1442
1465
  'uppercase:presence',
1443
1466
  'trim:presence',
1467
+ 'unsigned:presence',
1444
1468
  'autocomplete',
1445
1469
  ];
1446
1470
  static slots = true;
@@ -1462,9 +1486,17 @@ class Input extends Field {
1462
1486
  return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
1463
1487
  }
1464
1488
  _build({ slots }) {
1465
- const type = this._type();
1489
+ const declared = this._type();
1490
+ const mode = INPUT_MODES[declared];
1491
+ //a numeric widget is a text input: the type names the keyboard, not the
1492
+ //control, so the rendered markup stays valid html
1493
+ const type = mode ? 'text' : declared;
1466
1494
  const fragment = this.template().withOverlay({ type, slots }).render();
1467
1495
  this._input = fragment.querySelector('input,textarea');
1496
+ if (mode) {
1497
+ //before the passthrough, which stays the last word
1498
+ Attributes.set(this._input, 'inputmode', mode);
1499
+ }
1468
1500
 
1469
1501
  //the browser reads autocomplete off the control it is classifying, so the
1470
1502
  //field's own token, or the form's where it declares none, is put there.
@@ -1516,13 +1548,15 @@ class Input extends Field {
1516
1548
  if (trimmed === '') {
1517
1549
  return null;
1518
1550
  }
1551
+ //the wire carries a dot wherever the comma is what the keyboard shows
1552
+ const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
1519
1553
  if (this.declared('v-type') === 'number') {
1520
1554
  //typed values are an explicit opt in, as the select's k-type: blank
1521
1555
  //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;
1556
+ const n = Number(normalized);
1557
+ return Number.isNaN(n) ? normalized : n;
1524
1558
  }
1525
- return trimmed;
1559
+ return normalized;
1526
1560
  }
1527
1561
  set value(value) {
1528
1562
  this._input.value = value === '' || value === undefined ? null : value;
@@ -2514,10 +2548,27 @@ class SelectLoader {
2514
2548
  method: el.declared('method') ?? 'POST',
2515
2549
  mode: el.declared('mode'),
2516
2550
  prefetch: el.declared('preload'),
2517
- revision: el.declared('revision'),
2551
+ revision: SelectLoader.#revisionFrom(el),
2518
2552
  responseMapper: SelectLoader.#responseMapperFrom(el),
2519
2553
  });
2520
2554
  }
2555
+ static #revisionFrom(el) {
2556
+ const declared = el.declared('revision');
2557
+ if (declared !== '') {
2558
+ return declared;
2559
+ }
2560
+ const configured = el.component('revision');
2561
+ if (configured === undefined || configured === null) {
2562
+ //not caching while the markup says to cache is the quiet failure
2563
+ //this branch exists to make loud
2564
+ console.warn(
2565
+ "a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
2566
+ el,
2567
+ );
2568
+ return null;
2569
+ }
2570
+ return typeof configured === 'function' ? configured() : configured;
2571
+ }
2521
2572
  static #responseMapperFrom(el) {
2522
2573
  if (el.declared('k-expr') && el.declared('l-expr')) {
2523
2574
  return (response) => {
@@ -2550,7 +2601,7 @@ class Dropdown extends ParsedElement {
2550
2601
  static slots = true;
2551
2602
  static template = `
2552
2603
  <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>
2604
+ <ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
2554
2605
  <menu tabindex="-1" role="listbox" hidden></menu>
2555
2606
  `;
2556
2607
  static templates = {
@@ -2572,7 +2623,7 @@ class Dropdown extends ParsedElement {
2572
2623
  ? this.template('options')
2573
2624
  : Templates.fromFragment(slots.default);
2574
2625
  this.#spinner = fragment.querySelector('ful-spinner');
2575
- this.#empty = fragment.querySelector('p[data-ref=empty]');
2626
+ this.#empty = fragment.querySelector('[data-ref=empty]');
2576
2627
  this.#menu = fragment.querySelector('menu');
2577
2628
  //the listbox is named so a combobox can point aria-controls and
2578
2629
  //aria-activedescendant at it: a reference to an unnamed element resolves
@@ -2932,7 +2983,7 @@ class Select extends Field {
2932
2983
  return;
2933
2984
  }
2934
2985
  this.#abortdload();
2935
- this.#close();
2986
+ this.#close(true);
2936
2987
  });
2937
2988
  this.#input.addEventListener('keydown', (e) => {
2938
2989
  if (!this._interactive()) {
@@ -2965,7 +3016,6 @@ class Select extends Field {
2965
3016
  this.#editing = false;
2966
3017
  this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
2967
3018
  this.#changed();
2968
- this.#syncBadges();
2969
3019
  this.#input.focus();
2970
3020
  this.#ddmenu.hide();
2971
3021
  if (!this.#multiple) {
@@ -3022,7 +3072,6 @@ class Select extends Field {
3022
3072
  }
3023
3073
  this.#values.delete(key);
3024
3074
  this.#changed();
3025
- this.#syncBadges();
3026
3075
  }
3027
3076
  #chipKeydown(e, badge) {
3028
3077
  switch (e.code) {
@@ -3118,7 +3167,7 @@ class Select extends Field {
3118
3167
  }
3119
3168
  case 'Tab': {
3120
3169
  this.#abortdload();
3121
- this.#close();
3170
+ this.#close(true);
3122
3171
  break;
3123
3172
  }
3124
3173
  }
@@ -3134,12 +3183,25 @@ class Select extends Field {
3134
3183
  }
3135
3184
  return;
3136
3185
  }
3137
- this.#browse();
3138
- this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
3186
+ this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
3139
3187
  }
3140
- #close() {
3188
+ /**
3189
+ * @param commit whether the user is leaving the field, which only a blur or
3190
+ * a Tab is: Escape cancels, and closing the list or a claim landing leaves
3191
+ * the edit in progress
3192
+ */
3193
+ #close(commit = false) {
3141
3194
  this.#ddmenu.hide();
3195
+ //emptying the box and leaving is a clear: redisplaying the label would
3196
+ //discard the edit without a word. A multiple select keeps its box empty
3197
+ //by design, so only a single one can mean it
3198
+ const cleared =
3199
+ commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
3142
3200
  this.#editing = false;
3201
+ if (cleared) {
3202
+ this.#values.clear();
3203
+ this.#changed();
3204
+ }
3143
3205
  this.#display();
3144
3206
  }
3145
3207
  /**
@@ -3148,14 +3210,15 @@ class Select extends Field {
3148
3210
  * highlighted.
3149
3211
  */
3150
3212
  #open() {
3151
- this.#browse();
3152
- return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
3213
+ return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
3153
3214
  }
3154
- #browse() {
3155
- if (this.#editing) {
3156
- return;
3157
- }
3158
- this.#input.value = '';
3215
+ /**
3216
+ * Browsing is the whole vocabulary, not the label already chosen, and the
3217
+ * label stays on screen while it happens: a select showing nothing reads as
3218
+ * an empty one.
3219
+ */
3220
+ #query() {
3221
+ return this.#editing ? this.#input.value : '';
3159
3222
  }
3160
3223
  #display() {
3161
3224
  const entry = this.#values.values().next().value;
@@ -3166,6 +3229,9 @@ class Select extends Field {
3166
3229
  return [...this.#values.values()];
3167
3230
  }
3168
3231
  #changed() {
3232
+ //settled before the event: a listener reading the control during it has to
3233
+ //see the selection that caused it
3234
+ this.#syncBadges();
3169
3235
  //the detail carries the keys the value property answers with, as every
3170
3236
  //other field's does, and the labeled selection beside them
3171
3237
  this._notifyChange({ entry: this.entry });
@@ -3812,6 +3878,7 @@ class Table extends ParsedElement {
3812
3878
  static slots = true;
3813
3879
  static config = {
3814
3880
  searchIcon: 'search',
3881
+ emptyIcon: 'inbox',
3815
3882
  };
3816
3883
  static template = `
3817
3884
  <ful-form data-tpl-if="slots.filters">
@@ -3825,10 +3892,21 @@ class Table extends ParsedElement {
3825
3892
  <tbody data-ref="initial">
3826
3893
  <tr>
3827
3894
  <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>
3895
+ <ful-empty>
3896
+ <ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
3830
3897
  {{ #l10n:t('table.initial') }}
3831
- </div>
3898
+ </ful-empty>
3899
+ </td>
3900
+ </tr>
3901
+ </tbody>
3902
+ <tbody data-ref="empty" hidden>
3903
+ <tr>
3904
+ <td data-tpl-colspan="schema.length">
3905
+ <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
3906
+ <ful-empty data-tpl-if="!slots.empty">
3907
+ <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
3908
+ {{ #l10n:t('table.no-data') }}
3909
+ </ful-empty>
3832
3910
  </td>
3833
3911
  </tr>
3834
3912
  </tbody>
@@ -3858,11 +3936,6 @@ class Table extends ParsedElement {
3858
3936
  `;
3859
3937
  static templates = {
3860
3938
  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
3939
  {{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
3867
3940
  `,
3868
3941
  };
@@ -3871,6 +3944,7 @@ class Table extends ParsedElement {
3871
3944
  #body;
3872
3945
  #loading;
3873
3946
  #noAutoload;
3947
+ #empty;
3874
3948
  #feedback;
3875
3949
  #paginator;
3876
3950
  #sorters;
@@ -3921,6 +3995,7 @@ class Table extends ParsedElement {
3921
3995
  this.#body = table.querySelector(':scope > tbody');
3922
3996
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
3923
3997
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
3998
+ this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
3924
3999
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
3925
4000
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
3926
4001
  this.replaceChildren(fragment);
@@ -4011,6 +4086,7 @@ class Table extends ParsedElement {
4011
4086
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
4012
4087
  this.#feedback.setAttribute('hidden', '');
4013
4088
  this.#noAutoload.setAttribute('hidden', '');
4089
+ this.#empty.setAttribute('hidden', '');
4014
4090
  this.setAttribute('aria-busy', 'true');
4015
4091
  try {
4016
4092
  const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
@@ -4019,6 +4095,16 @@ class Table extends ParsedElement {
4019
4095
  }
4020
4096
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
4021
4097
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
4098
+ //settled before the event: a listener counting what loaded has to
4099
+ //see the table as it now is
4100
+ this.removeAttribute('aria-busy');
4101
+ this.dispatchEvent(
4102
+ new CustomEvent('load:success', {
4103
+ bubbles: true,
4104
+ cancelable: false,
4105
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
4106
+ }),
4107
+ );
4022
4108
  } catch (/** @type any */ error) {
4023
4109
  if (claim.stale) {
4024
4110
  //the newer load owns the table and its outcome: a superseded
@@ -4035,12 +4121,15 @@ class Table extends ParsedElement {
4035
4121
  error,
4036
4122
  `${error}`,
4037
4123
  );
4124
+ this.removeAttribute('aria-busy');
4125
+ this.dispatchEvent(
4126
+ new CustomEvent('load:failure', {
4127
+ bubbles: true,
4128
+ cancelable: false,
4129
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
4130
+ }),
4131
+ );
4038
4132
  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
4133
  }
4045
4134
  }
4046
4135
  /** Hands the loader to the callback, for runtime reconfigurations. */
@@ -4077,6 +4166,7 @@ class Table extends ParsedElement {
4077
4166
  })
4078
4167
  .render(),
4079
4168
  );
4169
+ this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
4080
4170
  //one move, one repaint: the page and the count are the same state
4081
4171
  this.#paginator.update({ current: pageRequest.page, total: pages });
4082
4172
  }
@@ -4330,6 +4420,19 @@ const operatorLabel = (op) => t(`filters.op.${op}`);
4330
4420
  const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
4331
4421
  const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
4332
4422
 
4423
+ /**
4424
+ * The operands are the words the reader chose, not the keys the wire carries.
4425
+ * @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
4426
+ */
4427
+
4428
+ const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
4429
+
4430
+ /** @returns {FilterCriterion|null} */
4431
+ const asCriterion = (label, operator, operands) => {
4432
+ const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
4433
+ return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
4434
+ };
4435
+
4333
4436
  /**
4334
4437
  * The shared shape of every operator-and-operands filter: an operator menu, one
4335
4438
  * or two operands of the type the subclass declares, and a tuple that mirrors
@@ -4469,6 +4572,18 @@ class CompareFilter extends Input {
4469
4572
  _syncBetween() {
4470
4573
  this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
4471
4574
  }
4575
+ /**
4576
+ * The operands as shown rather than as serialized: a date filter describes
4577
+ * what is in its control, not the iso string it sends.
4578
+ * @returns {FilterCriterion|null}
4579
+ */
4580
+ get criterion() {
4581
+ const operands =
4582
+ this._operator.value === 'BETWEEN'
4583
+ ? [this._value1.value, this._value2.value]
4584
+ : [this._value1.value];
4585
+ return asCriterion(labelTextOf(this), this._operator.value, operands);
4586
+ }
4472
4587
  get disabled() {
4473
4588
  return super.disabled;
4474
4589
  }
@@ -4704,6 +4819,13 @@ class BooleanFilter extends Field {
4704
4819
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
4705
4820
  this._value.value = v[1] ?? '';
4706
4821
  }
4822
+ /** @returns {FilterCriterion|null} */
4823
+ get criterion() {
4824
+ const token = this._value.value;
4825
+ return asCriterion(labelTextOf(this), this._operator.value, [
4826
+ token === '' ? '' : booleanValueLabel(token),
4827
+ ]);
4828
+ }
4707
4829
  get disabled() {
4708
4830
  return super.disabled;
4709
4831
  }
@@ -4716,6 +4838,51 @@ class BooleanFilter extends Field {
4716
4838
  }
4717
4839
  }
4718
4840
 
4841
+ /**
4842
+ * Set membership over a select's vocabulary, answering for both of data-jpa's
4843
+ * set annotations: InEnum and InList take the same bare array of values and no
4844
+ * operator, differing only in what the server converts them to.
4845
+ */
4846
+ class InFilter extends Select {
4847
+ static attributes = ['operator'];
4848
+ /** the selection as the array the wire takes, whether one key was chosen or many */
4849
+ #keys() {
4850
+ const chosen = super.value;
4851
+ if (this.multiple) {
4852
+ return chosen;
4853
+ }
4854
+ return chosen === null || chosen === undefined ? [] : [chosen];
4855
+ }
4856
+ get value() {
4857
+ const keys = this.#keys();
4858
+ //an empty set is no filter: the table drops a falsy value, where an
4859
+ //empty array would travel and match nothing
4860
+ if (keys.length === 0) {
4861
+ return null;
4862
+ }
4863
+ const operator = this.declared('operator');
4864
+ return operator ? [operator, ...keys] : keys;
4865
+ }
4866
+ set value(v) {
4867
+ const operator = this.declared('operator');
4868
+ let keys = v === null || v === undefined ? [] : Array.isArray(v) ? v : [v];
4869
+ //what the getter answers has to be assignable back, and it carries the
4870
+ //operator in front of the keys
4871
+ if (operator && keys[0] === operator) {
4872
+ keys = keys.slice(1);
4873
+ }
4874
+ super.value = this.multiple ? keys : (keys[0] ?? null);
4875
+ }
4876
+ /** @returns {FilterCriterion|null} */
4877
+ get criterion() {
4878
+ return asCriterion(
4879
+ labelTextOf(this),
4880
+ this.declared('operator'),
4881
+ [super.entry].flat().filter((e) => e).map((e) => e.label),
4882
+ );
4883
+ }
4884
+ }
4885
+
4719
4886
  /**
4720
4887
  * The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
4721
4888
  * ful-drawer: every activation of a section fires the section:requested family
@@ -5879,6 +6046,7 @@ class Plugin {
5879
6046
  .defineElement('ful-filter-number', NumberFilter)
5880
6047
  .defineElement('ful-filter-boolean', BooleanFilter)
5881
6048
  .defineElement('ful-filter-text', TextFilter)
6049
+ .defineElement('ful-filter-in', InFilter)
5882
6050
  .defineElement('ful-select', Select)
5883
6051
  .defineElement('ful-dropdown', Dropdown)
5884
6052
  .defineComponent('loaders:select', SelectLoader)
@@ -5895,5 +6063,5 @@ class Plugin {
5895
6063
  }
5896
6064
  }
5897
6065
 
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 };
6066
+ 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
6067
  //# sourceMappingURL=ful.mjs.map