@optionfactory/fml 9.0.0-rc13 → 9.0.0-rc14

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
@@ -5147,7 +5147,7 @@ var fml = (function (exports) {
5147
5147
  */
5148
5148
  static t(key, ...args) {
5149
5149
  const messages = this.l10n ?? {};
5150
- let message = messages[key];
5150
+ let message = Object.hasOwn(messages, key) ? messages[key] : undefined;
5151
5151
  if (message === undefined) {
5152
5152
  warnOnce(`missing message "${key}"`);
5153
5153
  return key;
@@ -5170,7 +5170,7 @@ var fml = (function (exports) {
5170
5170
  if (args.length === 1 && isPlainObject(args[0])) {
5171
5171
  const named = args[0];
5172
5172
  return message.replace(PLACEHOLDER, (literal, name) => {
5173
- if (!(name in named)) {
5173
+ if (!Object.hasOwn(named, name)) {
5174
5174
  warnOnce(`message "${key}" wants {${name}}`);
5175
5175
  return literal;
5176
5176
  }
@@ -6789,30 +6789,62 @@ var fml = (function (exports) {
6789
6789
  }
6790
6790
 
6791
6791
  static mutateIn(form, values) {
6792
- const names = Array.from(form.elements)
6793
- .map((el) => el.getAttribute('name'))
6794
- .filter((n) => n);
6795
- for (const [flattenedKey, value] of Object.entries(Bindings.flatten(values, '', new Set(names)))) {
6796
- for (const el of form.querySelectorAll(`[name='${CSS.escape(flattenedKey)}']`)) {
6792
+ const named = Bindings.#named(form);
6793
+ for (const [flattenedKey, value] of Object.entries(Bindings.flatten(values, '', new Set(named.keys())))) {
6794
+ for (const el of named.get(flattenedKey) ?? []) {
6797
6795
  Bindings.mutate(el, value);
6798
6796
  }
6799
6797
  }
6800
6798
  }
6801
6799
 
6800
+ /**
6801
+ * The form's own named controls, by name: `form.elements`, the platform's
6802
+ * association list, so a field in the subtree that belongs to another form
6803
+ * (a nested form built by script, a `form` attribute pointing elsewhere) is
6804
+ * not the caller's to touch, where a subtree query would have reached it.
6805
+ */
6806
+ static #named(form) {
6807
+ const named = new Map();
6808
+ for (const el of form.elements) {
6809
+ const name = el.getAttribute('name');
6810
+ if (!name) {
6811
+ continue;
6812
+ }
6813
+ const bucket = named.get(name);
6814
+ if (bucket) {
6815
+ bucket.push(el);
6816
+ } else {
6817
+ named.set(name, [el]);
6818
+ }
6819
+ }
6820
+ return named;
6821
+ }
6822
+
6802
6823
  static errors(form, es, scrollOnError) {
6803
6824
  //focus management announces the error of the field it lands on through
6804
6825
  //aria-describedby: a live region on top of that would read everything twice,
6805
- //so the polite announcement exists only when nothing takes the focus
6806
- form.querySelectorAll('ful-field-error').forEach((el) => {
6807
- el.setAttribute('aria-live', scrollOnError ? 'off' : 'polite');
6808
- });
6826
+ //so the polite announcement exists only when nothing takes the focus.
6827
+ //The regions and the banner belong to the form whose subtree holds them
6828
+ //nearest: a nested form's are not this form's to touch
6829
+ const ofForm = (el) => el.closest('form') === form;
6830
+ Array.from(form.querySelectorAll('ful-field-error'))
6831
+ .filter(ofForm)
6832
+ .forEach((el) => {
6833
+ el.setAttribute('aria-live', scrollOnError ? 'off' : 'polite');
6834
+ });
6809
6835
  const pinned = (e) => (e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT') && e.context;
6810
6836
  const fieldErrors = es.filter(pinned);
6811
6837
  const globalErrors = es.filter((e) => !pinned(e));
6812
- form.querySelectorAll(`[name]`).forEach((el) => {
6813
- el.setCustomValidity?.('');
6814
- });
6838
+ const named = Bindings.#named(form);
6839
+ for (const targets of named.values()) {
6840
+ targets.forEach((el) => {
6841
+ el.setCustomValidity?.('');
6842
+ });
6843
+ }
6815
6844
  form.querySelectorAll('ful-errors').forEach((el) => {
6845
+ if (!ofForm(el)) {
6846
+ return;
6847
+ }
6816
6848
  el.setAttribute('role', 'alert');
6817
6849
  el.replaceChildren();
6818
6850
  el.setAttribute('hidden', '');
@@ -6823,7 +6855,7 @@ var fml = (function (exports) {
6823
6855
  const parts = name.split('.');
6824
6856
  for (let i = parts.length; i !== 0; --i) {
6825
6857
  const prefix = parts.slice(0, i).join('.');
6826
- const targets = form.querySelectorAll(`[name='${CSS.escape(prefix)}']`);
6858
+ const targets = named.get(prefix) ?? [];
6827
6859
  if (targets.length === 0) {
6828
6860
  continue;
6829
6861
  }
@@ -6843,6 +6875,9 @@ var fml = (function (exports) {
6843
6875
  });
6844
6876
  const bannered = [...globalErrors, ...unmatched];
6845
6877
  form.querySelectorAll('ful-errors').forEach((el) => {
6878
+ if (!ofForm(el)) {
6879
+ return;
6880
+ }
6846
6881
  const hel = /** @type HTMLElement} */ (el);
6847
6882
  if (bannered.length === 0) {
6848
6883
  hel.innerText = '';
@@ -6857,7 +6892,8 @@ var fml = (function (exports) {
6857
6892
  if (es.length === 0 || !scrollOnError) {
6858
6893
  return;
6859
6894
  }
6860
- Array.from(form.querySelectorAll(`:invalid`))
6895
+ Array.from(form.elements)
6896
+ .filter((el) => el.matches(':invalid'))
6861
6897
  .sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y)[0]
6862
6898
  ?.focus();
6863
6899
  }
@@ -6898,6 +6934,7 @@ var fml = (function (exports) {
6898
6934
  /** the role the element internals carry, 'presentation' unless the control is its own */
6899
6935
  static ROLE = 'presentation';
6900
6936
  #control;
6937
+ #pendingFocus = null;
6901
6938
  #described;
6902
6939
  #descriptions = [];
6903
6940
  #errorId = null;
@@ -6979,12 +7016,17 @@ var fml = (function (exports) {
6979
7016
  //only where the platform cannot: a control still associated with the
6980
7017
  //form, an author's own input in a slot among them, submits on its own
6981
7018
  //and would otherwise submit twice
6982
- if (target.form === this.internals.form || !Field.#submitsOnEnter(target)) {
7019
+ if (target.form === this.form || !Field.#submitsOnEnter(target)) {
6983
7020
  return;
6984
7021
  }
6985
7022
  this._requestSubmit();
6986
7023
  });
6987
7024
  this.replaceChildren(fragment);
7025
+ if (this.#pendingFocus !== null) {
7026
+ const options = this.#pendingFocus;
7027
+ this.#pendingFocus = null;
7028
+ control.focus(options);
7029
+ }
6988
7030
  }
6989
7031
  /**
6990
7032
  * The platform's own rule for which control Enter submits from, measured on
@@ -7052,7 +7094,11 @@ var fml = (function (exports) {
7052
7094
  }
7053
7095
  }
7054
7096
  focus(options) {
7055
- this.#control?.focus(options);
7097
+ if (this.#control) {
7098
+ this.#control.focus(options);
7099
+ return;
7100
+ }
7101
+ this.#pendingFocus = options ?? {};
7056
7102
  }
7057
7103
  /**
7058
7104
  * Clears or reports one validation problem: the text lands on the field's
@@ -7079,9 +7125,13 @@ var fml = (function (exports) {
7079
7125
  this.internals.setValidity({ customError: true }, ' ');
7080
7126
  this.#fieldError.innerText = error;
7081
7127
  }
7128
+ /** The form the platform associated the field with, null outside one: a native control's own `form`. */
7129
+ get form() {
7130
+ return this.internals.form;
7131
+ }
7082
7132
  /** Submits the associated form through its first submitter, as Enter on a native control would. */
7083
7133
  _requestSubmit() {
7084
- const form = this.internals.form;
7134
+ const form = this.form;
7085
7135
  if (!form) {
7086
7136
  return;
7087
7137
  }
@@ -7439,6 +7489,11 @@ var fml = (function (exports) {
7439
7489
  );
7440
7490
  if (this.declared('clear-invalid-on-change')) {
7441
7491
  this.addEventListener('change', (/** @type any */ evt) => {
7492
+ //a form-associated custom element carries no `form` property of
7493
+ //its own: the owner reads through the internals it attached
7494
+ if ((evt.target.form ?? evt.target.internals?.form) !== this.form) {
7495
+ return;
7496
+ }
7442
7497
  evt.target.setCustomValidity?.('');
7443
7498
  });
7444
7499
  }
@@ -7504,8 +7559,9 @@ var fml = (function (exports) {
7504
7559
  );
7505
7560
  if (e instanceof Failure) {
7506
7561
  this.errors = e.problems;
7562
+ } else {
7563
+ console.warn('failed to submit form', this, 'reason:', e);
7507
7564
  }
7508
- console.warn('failed to submit form', this, 'reason:', e);
7509
7565
  } finally {
7510
7566
  this.#submitting = false;
7511
7567
  this.spinner(false);
@@ -7555,6 +7611,9 @@ var fml = (function (exports) {
7555
7611
  //same way, and a form that only dimmed its button said it to no one
7556
7612
  Attributes.set(this, 'aria-busy', spin ? 'true' : null);
7557
7613
  this.querySelectorAll('ful-spinner').forEach((el) => {
7614
+ if (el.closest('form') !== this.form) {
7615
+ return;
7616
+ }
7558
7617
  const hel = /** @type HTMLElement */ (el);
7559
7618
  if (spin) {
7560
7619
  this.#announce(hel);
@@ -7565,7 +7624,7 @@ var fml = (function (exports) {
7565
7624
  });
7566
7625
  this.querySelectorAll('input,button').forEach((el) => {
7567
7626
  const hel = /** @type HTMLButtonElement|HTMLInputElement */ (el);
7568
- if (hel.type !== 'submit' && hel.type !== 'reset') {
7627
+ if (hel.form !== this.form || (hel.type !== 'submit' && hel.type !== 'reset')) {
7569
7628
  return;
7570
7629
  }
7571
7630
  if (spin) {
@@ -8406,11 +8465,18 @@ var fml = (function (exports) {
8406
8465
  popover.style.bottom = 'auto';
8407
8466
  popover.style.margin = '0';
8408
8467
  if (stretch) {
8409
- const width = Math.min(box.width, vw - 2 * PAD);
8410
- popover.style.width = `${width}px`;
8468
+ popover.style.removeProperty('width');
8469
+ popover.style.removeProperty('min-width');
8470
+ popover.style.removeProperty('max-width');
8471
+ const cap = Math.min(parseFloat(computed.maxWidth) || vw, vw - 2 * PAD);
8472
+ popover.style.minWidth = `${Math.min(box.width, vw - 2 * PAD)}px`;
8473
+ popover.style.maxWidth = `${cap}px`;
8474
+ const width = popover.getBoundingClientRect().width;
8411
8475
  popover.style.left = `${clamp(box.left, PAD, vw - width - PAD)}px`;
8412
8476
  const height = popover.getBoundingClientRect().height;
8413
- popover.style.top = `${clamp(box.bottom + gap.top, PAD, vh - height - PAD)}px`;
8477
+ const below = box.bottom + gap.top;
8478
+ const top = below + height <= vh - PAD ? below : box.top - gap.bottom - height;
8479
+ popover.style.top = `${clamp(top, PAD, vh - height - PAD)}px`;
8414
8480
  return;
8415
8481
  }
8416
8482
  //a popover wraps against the spot it lands on: the width is measured
@@ -8455,7 +8521,9 @@ var fml = (function (exports) {
8455
8521
  'bottom',
8456
8522
  'margin',
8457
8523
  'max-width',
8524
+ 'min-width',
8458
8525
  'width',
8526
+ 'visibility',
8459
8527
  '--ful-note-callout-inline',
8460
8528
  '--ful-note-callout-block',
8461
8529
  ]) {
@@ -8463,6 +8531,16 @@ var fml = (function (exports) {
8463
8531
  }
8464
8532
  };
8465
8533
 
8534
+ const showOncePlaced = (popover, anchored) => {
8535
+ popover.style.visibility = 'hidden';
8536
+ requestAnimationFrame(() => {
8537
+ if (popover.matches(':popover-open')) {
8538
+ place(popover, anchored);
8539
+ }
8540
+ popover.style.removeProperty('visibility');
8541
+ });
8542
+ };
8543
+
8466
8544
  const reflow = () => {
8467
8545
  frame = 0;
8468
8546
  for (const [popover, anchored] of open) {
@@ -8513,8 +8591,10 @@ var fml = (function (exports) {
8513
8591
  * the popover, giving toggle and light dismiss with no script of your own
8514
8592
  * @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
8515
8593
  * step with the popover
8516
- * @param {boolean} [options.stretch] widens the popover to its invoker, which
8517
- * is what a combobox dropdown wants
8594
+ * @param {boolean} [options.stretch] widens the popover to at least its
8595
+ * invoker's width, capped by its own `max-width`, which is what a combobox
8596
+ * dropdown wants; it flips above the invoker where the viewport leaves no
8597
+ * room below
8518
8598
  * @param {boolean} [options.handPlace] places here on every platform rather
8519
8599
  * than only as a fallback, which a popover asks for when it needs to know
8520
8600
  * where its invoker ended up: the tooltip's note points a callout at it, and
@@ -8561,10 +8641,8 @@ var fml = (function (exports) {
8561
8641
  }
8562
8642
  const anchored = { invoker, stretch };
8563
8643
  popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
8564
- //placed before the showing, refined once laid out: the platform's
8565
- //centered or corner spot never paints
8566
8644
  if (evt.newState === 'open') {
8567
- place(popover, anchored);
8645
+ showOncePlaced(popover, anchored);
8568
8646
  }
8569
8647
  });
8570
8648
  popover.addEventListener('toggle', (/** @type any */ evt) => {
@@ -8582,6 +8660,57 @@ var fml = (function (exports) {
8582
8660
  window.addEventListener('resize', schedule);
8583
8661
  }
8584
8662
  }
8663
+ /**
8664
+ * Shows and places a popover beside an anchor, once: the popover shared by
8665
+ * many invokers, each open handing over its own anchor, and an anchor that
8666
+ * is a rectangle rather than an element, one read out of an iframe being
8667
+ * the case a paired invoker could not cover. No pairing, no reflow
8668
+ * following, no close wiring: closing is the caller's, or the platform's
8669
+ * for a light-dismissing popover.
8670
+ *
8671
+ * The placement is the menus' own geometry, below the anchor and
8672
+ * start-aligned, clamped into the viewport; `flip` moves it above the
8673
+ * anchor where the viewport leaves no room below, as the css
8674
+ * `position-try-fallbacks` the paired popovers declare. A popover not
8675
+ * already open is shown before it is placed, so the first position anybody
8676
+ * sees is the right one.
8677
+ *
8678
+ * @param {HTMLElement} popover the `[popover]` element to show, or an
8679
+ * already-shown element to place
8680
+ * @param {Element|DOMRect} anchor the element, or the rectangle, to place
8681
+ * beside
8682
+ * @param {object} [options]
8683
+ * @param {boolean} [options.flip] place above the anchor where no room is
8684
+ * left below
8685
+ * @param {number} [options.gap] the distance from the anchor, in pixels
8686
+ * @returns {HTMLElement} the popover
8687
+ */
8688
+ static show(popover, anchor, { flip = false, gap = 0 } = {}) {
8689
+ const placeShown = () => {
8690
+ const box = anchor instanceof Element ? anchor.getBoundingClientRect() : anchor;
8691
+ const viewport = document.documentElement;
8692
+ const here = popover.getBoundingClientRect();
8693
+ const below = box.bottom + gap;
8694
+ const above = box.top - gap - here.height;
8695
+ const top = flip && below + here.height > viewport.clientHeight - PAD && above >= PAD ? above : below;
8696
+ popover.style.right = 'auto';
8697
+ popover.style.bottom = 'auto';
8698
+ popover.style.margin = '0';
8699
+ popover.style.left = `${clamp(box.left, PAD, Math.max(PAD, viewport.clientWidth - here.width - PAD))}px`;
8700
+ popover.style.top = `${clamp(top, PAD, Math.max(PAD, viewport.clientHeight - here.height - PAD))}px`;
8701
+ };
8702
+ if (popover.matches(':popover-open') || typeof popover.showPopover !== 'function') {
8703
+ placeShown();
8704
+ return popover;
8705
+ }
8706
+ popover.style.visibility = 'hidden';
8707
+ popover.showPopover();
8708
+ requestAnimationFrame(() => {
8709
+ placeShown();
8710
+ popover.style.removeProperty('visibility');
8711
+ });
8712
+ return popover;
8713
+ }
8585
8714
  }
8586
8715
 
8587
8716
  /**
@@ -8878,12 +9007,19 @@ var fml = (function (exports) {
8878
9007
  this.hide();
8879
9008
  return;
8880
9009
  }
9010
+ if (li.hasAttribute('aria-disabled')) {
9011
+ return;
9012
+ }
8881
9013
  this.#change(li);
8882
9014
  });
8883
9015
  this.replaceChildren(fragment);
8884
9016
  }
8885
9017
  #selected() {
8886
- return this.#menu?.querySelector('[selected]') ?? this.#menu?.firstElementChild ?? null;
9018
+ return (
9019
+ this.#menu?.querySelector('[selected]:not([aria-disabled])') ??
9020
+ this.#menu?.querySelector('li:not([aria-disabled])') ??
9021
+ null
9022
+ );
8887
9023
  }
8888
9024
  #highlight(li) {
8889
9025
  if (!li) {
@@ -8915,11 +9051,20 @@ var fml = (function (exports) {
8915
9051
  const data = values.map((entry, index) => ({ index, ...entry }));
8916
9052
  this.#optionstemplate.withOverlay(data).renderTo(this.#menu);
8917
9053
  for (const [index, li] of [...this.#menu.children].entries()) {
8918
- const picked = keys.some((r) => r == values[index]?.key);
9054
+ const entry = values[index];
9055
+ const picked = keys.some((r) => r == entry?.key);
8919
9056
  li.toggleAttribute('picked', picked);
8920
9057
  //what is picked is what aria-selected means for a listbox: a tint alone
8921
9058
  //says it to whoever can see it and to no one else
8922
9059
  li.setAttribute('aria-selected', picked ? 'true' : 'false');
9060
+ //metadata.disabled refuses the entry, metadata.reason saying why
9061
+ const disabled = !!entry?.metadata?.disabled;
9062
+ li.toggleAttribute('aria-disabled', disabled);
9063
+ if (disabled && entry.metadata.reason) {
9064
+ li.setAttribute('title', String(entry.metadata.reason));
9065
+ } else {
9066
+ li.removeAttribute('title');
9067
+ }
8923
9068
  }
8924
9069
  this.#empty.toggleAttribute('hidden', values.length !== 0);
8925
9070
  this.#menu.toggleAttribute('hidden', values.length === 0);
@@ -8993,15 +9138,25 @@ var fml = (function (exports) {
8993
9138
  if (this.shown) {
8994
9139
  const selected = this.#selected();
8995
9140
  const candidate = selected?.[`${forward ? 'next' : 'previous'}ElementSibling`];
8996
- if (selected && candidate) {
8997
- this.#highlight(candidate);
9141
+ const target = candidate ? this.#walk(candidate, forward) : null;
9142
+ if (selected && target) {
9143
+ this.#highlight(target);
8998
9144
  }
8999
9145
  return;
9000
9146
  }
9001
9147
  await this.show(loader, keys);
9002
9148
  }
9149
+ #walk(from, forward) {
9150
+ for (let li = from; li; li = li[`${forward ? 'next' : 'previous'}ElementSibling`]) {
9151
+ if (!li.hasAttribute('aria-disabled')) {
9152
+ return li;
9153
+ }
9154
+ }
9155
+ return null;
9156
+ }
9003
9157
  jump(first) {
9004
- const target = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
9158
+ const edge = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
9159
+ const target = this.#walk(edge, first);
9005
9160
  if (target) {
9006
9161
  this.#highlight(target);
9007
9162
  }
@@ -9011,9 +9166,14 @@ var fml = (function (exports) {
9011
9166
  if (!selected) {
9012
9167
  return;
9013
9168
  }
9014
- const lis = Array.from(this.#menu.children);
9015
- const step = this.#page();
9016
- const target = lis[Math.max(0, Math.min(lis.length - 1, lis.indexOf(selected) + (forward ? step : -step)))];
9169
+ let target = selected;
9170
+ for (let i = 0; i !== this.#page(); ++i) {
9171
+ const next = this.#walk(target[`${forward ? 'next' : 'previous'}ElementSibling`], forward);
9172
+ if (!next) {
9173
+ break;
9174
+ }
9175
+ target = next;
9176
+ }
9017
9177
  this.#highlight(target);
9018
9178
  }
9019
9179
  #page() {
@@ -9049,6 +9209,12 @@ var fml = (function (exports) {
9049
9209
  //`set value` normalizes a list of one to a single key, and the getter answers a
9050
9210
  //scalar for a single select, so nothing downstream has to know which it was
9051
9211
  static observed = ['multiple:presence', 'item-list:presence', 'value:csv'];
9212
+ unmarshal(attr, str) {
9213
+ if (attr === 'value' && str === '' && !this.hasAttribute('multiple')) {
9214
+ return [''];
9215
+ }
9216
+ return super.unmarshal(attr, str);
9217
+ }
9052
9218
  static slots = true;
9053
9219
  //a manual popover: the combobox keeps the focus on its input and owns
9054
9220
  //the whole lifecycle (typing, arrows, blur, Escape, Tab), so no light
@@ -10142,7 +10308,7 @@ var fml = (function (exports) {
10142
10308
  <tbody data-ref="empty" hidden>
10143
10309
  <tr>
10144
10310
  <td data-tpl-colspan="schema.length">
10145
- <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
10311
+ <ful-empty data-tpl-if="slots.empty" data-ref="empty-authors"></ful-empty>
10146
10312
  <ful-empty data-tpl-if="!slots.empty">
10147
10313
  <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
10148
10314
  {{ #l10n:t('table.no-data') }}
@@ -10185,6 +10351,8 @@ var fml = (function (exports) {
10185
10351
  #loading;
10186
10352
  #noAutoload;
10187
10353
  #empty;
10354
+ #emptyAuthors;
10355
+ #emptyTemplate;
10188
10356
  #feedback;
10189
10357
  #paginator;
10190
10358
  #sorters;
@@ -10236,6 +10404,9 @@ var fml = (function (exports) {
10236
10404
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
10237
10405
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
10238
10406
  this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
10407
+ this.#emptyAuthors = this.#empty.querySelector('ful-empty[data-ref=empty-authors]');
10408
+ this.#emptyTemplate =
10409
+ slots.empty && !Fragments.isBlank(slots.empty) ? Templates.fromFragment(slots.empty) : null;
10239
10410
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
10240
10411
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
10241
10412
  this.replaceChildren(fragment);
@@ -10407,6 +10578,15 @@ var fml = (function (exports) {
10407
10578
  .render(),
10408
10579
  );
10409
10580
  this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
10581
+ if (this.#emptyTemplate && pageResponse.data.length === 0) {
10582
+ //rendered per load, the rows' own scope: the empty state can say
10583
+ //whether nothing was ever there or the filters matched nothing
10584
+ this.#emptyAuthors.replaceChildren(
10585
+ this.#emptyTemplate
10586
+ .withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
10587
+ .render(),
10588
+ );
10589
+ }
10410
10590
  //one move, one repaint: the page and the count are the same state
10411
10591
  this.#paginator.update({ current: pageRequest.page, total: pages });
10412
10592
  }
@@ -10497,6 +10677,13 @@ var fml = (function (exports) {
10497
10677
  get pinned() {
10498
10678
  return this.#allowed.length < 2;
10499
10679
  }
10680
+ /**
10681
+ * The host's fixed claim: the button and its chrome leave while the value
10682
+ * keeps answering, which is what a fixed operator asks of its glyph.
10683
+ */
10684
+ set fixed(fixed) {
10685
+ this.#button.toggleAttribute('hidden', !!fixed);
10686
+ }
10500
10687
  get value() {
10501
10688
  return this.#button.getAttribute('value');
10502
10689
  }
@@ -10680,6 +10867,7 @@ var fml = (function (exports) {
10680
10867
  */
10681
10868
  class CompareFilter extends Input {
10682
10869
  static observed = ['value:json', 'operators:csv'];
10870
+ static attributes = ['operator'];
10683
10871
  static OPERATORS = COMPARE_OPERATORS;
10684
10872
  static DEFAULT_OPERATOR = 'EQ';
10685
10873
  static template = `
@@ -10730,11 +10918,38 @@ var fml = (function (exports) {
10730
10918
  if (this._operator.value === null) {
10731
10919
  this._showDefaultOperator();
10732
10920
  }
10921
+ this.#fixOperator();
10733
10922
  //the second operand mirrors the claims like the first one does, and the
10734
10923
  //freeze reaches the operator and sensitivity buttons, whose popovers an
10735
10924
  //input's readOnly cannot touch
10736
10925
  return { ...pieces, freeze: this._container, also: [this._value2] };
10737
10926
  }
10927
+ /**
10928
+ * The singular `operator` fixes the operator for the element's life: not a
10929
+ * choice, so no menu and no button, every tuple carrying it. The plural is
10930
+ * the menu's whitelist; declaring both is a confused page and says so,
10931
+ * the singular winning, and an unknown name warns and pins the default.
10932
+ */
10933
+ #fixOperator() {
10934
+ const fixed = this.declared('operator');
10935
+ if (fixed === null) {
10936
+ return;
10937
+ }
10938
+ if (this.hasAttribute('operators')) {
10939
+ console.warn(`${this.localName}: operator fixes what operators offers a menu for, the plural is ignored`, this);
10940
+ }
10941
+ const vocabulary = this._vocabulary();
10942
+ /** @type {string|null} */
10943
+ let preferred = null;
10944
+ if (vocabulary.includes(fixed)) {
10945
+ preferred = fixed;
10946
+ } else {
10947
+ console.warn(`${this.localName}: '${fixed}' is not one of its operators, the default is pinned instead`, this);
10948
+ preferred = this._defaultOperator();
10949
+ }
10950
+ this._operator.allowed = [preferred];
10951
+ this._operator.fixed = true;
10952
+ }
10738
10953
  _showDefaultOperator() {
10739
10954
  const preferred = this._defaultOperator();
10740
10955
  const allowed = this._operator.allowed;
@@ -10771,6 +10986,9 @@ var fml = (function (exports) {
10771
10986
  return this._operator ? this._operator.allowed : this._declaredOperators;
10772
10987
  }
10773
10988
  set operators(declared) {
10989
+ if (this.declared('operator') !== null) {
10990
+ return;
10991
+ }
10774
10992
  if (!this._operator) {
10775
10993
  this._declaredOperators = ChoiceButton.narrow(declared, this._vocabulary());
10776
10994
  return;
@@ -10874,6 +11092,7 @@ var fml = (function (exports) {
10874
11092
  /** The compare filter over text, carrying a case sensitivity beside the operator. */
10875
11093
  class TextFilter extends CompareFilter {
10876
11094
  static observed = ['sensitivities:csv'];
11095
+ static attributes = ['sensitivity'];
10877
11096
  static template = `
10878
11097
  <label>{{{{ slots.default }}}}</label>
10879
11098
  {{{{ slots.info }}}}
@@ -10917,8 +11136,28 @@ var fml = (function (exports) {
10917
11136
  );
10918
11137
  this._sensitivityButton.allowed = null;
10919
11138
  this._sensitivityButton.value = SENSITIVITIES[0];
11139
+ this.#fixSensitivity();
10920
11140
  return pieces;
10921
11141
  }
11142
+ #fixSensitivity() {
11143
+ const fixed = this.declared('sensitivity');
11144
+ if (fixed === null) {
11145
+ return;
11146
+ }
11147
+ if (this.hasAttribute('sensitivities')) {
11148
+ console.warn('ful-filter-text: sensitivity fixes what sensitivities offers a menu for, the plural is ignored', this);
11149
+ }
11150
+ /** @type {string|null} */
11151
+ let preferred = null;
11152
+ if (SENSITIVITIES.includes(fixed)) {
11153
+ preferred = fixed;
11154
+ } else {
11155
+ console.warn(`ful-filter-text: '${fixed}' is not one of its sensitivities, the default is pinned instead`, this);
11156
+ preferred = 'IGNORE_CASE';
11157
+ }
11158
+ this._sensitivityButton.allowed = [preferred];
11159
+ this._sensitivityButton.fixed = true;
11160
+ }
10922
11161
  _choices() {
10923
11162
  return [...super._choices(), this._sensitivityButton].filter((c) => c);
10924
11163
  }
@@ -10930,6 +11169,9 @@ var fml = (function (exports) {
10930
11169
  return this._sensitivityButton ? this._sensitivityButton.allowed : this._declaredSensitivities;
10931
11170
  }
10932
11171
  set sensitivities(declared) {
11172
+ if (this.declared('sensitivity') !== null) {
11173
+ return;
11174
+ }
10933
11175
  if (!this._sensitivityButton) {
10934
11176
  this._declaredSensitivities = ChoiceButton.narrow(declared, SENSITIVITIES);
10935
11177
  return;
@@ -11059,6 +11301,15 @@ var fml = (function (exports) {
11059
11301
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
11060
11302
  this._value.value = v[1] ?? '';
11061
11303
  }
11304
+ formResetCallback() {
11305
+ super.formResetCallback();
11306
+ if (!this.hasAttribute('value')) {
11307
+ const allowed = this._operator.allowed;
11308
+ this._operator.value = allowed.includes(BooleanFilter.DEFAULT_OPERATOR)
11309
+ ? BooleanFilter.DEFAULT_OPERATOR
11310
+ : allowed[0];
11311
+ }
11312
+ }
11062
11313
  /** @returns {FilterCriterion|null} */
11063
11314
  get criterion() {
11064
11315
  const token = this._value.value;
@@ -11355,6 +11606,15 @@ var fml = (function (exports) {
11355
11606
  this.#body = fragment.querySelector('[data-ref=body]');
11356
11607
  this.#loading = fragment.querySelector('[data-ref=loading]');
11357
11608
  this.#error = fragment.querySelector('[data-ref=error]');
11609
+ //a dialog is a named surface: a reader announces the heading on opening,
11610
+ //which it cannot do unless the heading names the dialog. The name travels
11611
+ //as a reference rather than as aria-label text, so it stays the heading's
11612
+ //own however it is reworded
11613
+ const heading = fragment.querySelector('h2');
11614
+ if (heading) {
11615
+ heading.id ||= Attributes.uid('ful-dialog-title');
11616
+ this.#dialog.setAttribute('aria-labelledby', heading.id);
11617
+ }
11358
11618
  this.#dialog.addEventListener('close', () => {
11359
11619
  const outcome = this.#outcome();
11360
11620
  this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
@@ -11522,6 +11782,69 @@ var fml = (function (exports) {
11522
11782
  this.#loading?.setAttribute('hidden', '');
11523
11783
  this.#body?.removeAttribute('hidden');
11524
11784
  }
11785
+ /**
11786
+ * Asks a question in a dialog of the page's own, resolving with its outcome
11787
+ * and removing the element afterwards: the element stays the author's when
11788
+ * the question lives in the page, while a question asked from a button had
11789
+ * to build, render, show, answer and clean up by hand.
11790
+ *
11791
+ * The body is text or a node: a string is never read as markup, an
11792
+ * `innerHTML` sink being a thing the library does not carry. Each button is
11793
+ * a `[result, label, className?]` tuple, rendered as its answer.
11794
+ * @param {string} header
11795
+ * @param {string|Node} body
11796
+ * @param {[string, string, string?][]} [buttons]
11797
+ * @returns {Promise<DialogOutcome>}
11798
+ */
11799
+ static async ask(header, body, buttons = []) {
11800
+ const dialog = document.createElement('ful-dialog');
11801
+ dialog.setAttribute('header', header);
11802
+ if (typeof body === 'string') {
11803
+ dialog.textContent = body;
11804
+ } else if (body) {
11805
+ dialog.append(body);
11806
+ }
11807
+ //buttons are slotted only when there are any: the acknowledge button
11808
+ //is the answer of a question asked without choices of its own
11809
+ if (buttons.length > 0) {
11810
+ const choices = document.createElement('template');
11811
+ choices.setAttribute('slot', 'buttons');
11812
+ for (const [result, label, className] of buttons) {
11813
+ const button = document.createElement('button');
11814
+ button.type = 'button';
11815
+ button.dataset.result = result;
11816
+ if (className) {
11817
+ button.className = className;
11818
+ }
11819
+ button.textContent = label;
11820
+ choices.content.append(button);
11821
+ }
11822
+ dialog.append(choices);
11823
+ }
11824
+ document.body.append(dialog);
11825
+ try {
11826
+ await Rendering.waitFor(dialog);
11827
+ return await /** @type {Dialog} */ (dialog).ask();
11828
+ } finally {
11829
+ dialog.remove();
11830
+ }
11831
+ }
11832
+ /**
11833
+ * Asks a yes/no question, resolving with the confirm button's answer:
11834
+ * the cancel button, the close button and Escape all answer false, a
11835
+ * dismissal being the negative.
11836
+ * @param {string} header
11837
+ * @param {string|Node} body
11838
+ * @param {{ confirm?: string, cancel?: string }} [labels] overriding the localized defaults
11839
+ * @returns {Promise<boolean>}
11840
+ */
11841
+ static async confirm(header, body, labels = {}) {
11842
+ const outcome = await Dialog.ask(header, body, [
11843
+ ['cancel', labels.cancel ?? Localization.of().t('dialog.cancel')],
11844
+ ['confirm', labels.confirm ?? Localization.of().t('dialog.confirm'), 'ful-button'],
11845
+ ]);
11846
+ return !outcome.dismissed && outcome.result === 'confirm';
11847
+ }
11525
11848
  }
11526
11849
 
11527
11850
  /**
@@ -11534,7 +11857,7 @@ var fml = (function (exports) {
11534
11857
  * it.
11535
11858
  */
11536
11859
  class Drawer extends ParsedElement {
11537
- static attributes = ['title', 'placement', 'close-on-submit:presence'];
11860
+ static attributes = ['header', 'placement', 'close-on-submit:presence'];
11538
11861
  static slots = true;
11539
11862
  static template = `
11540
11863
  <dialog data-ref="dialog" class="ful-drawer">
@@ -11559,12 +11882,23 @@ var fml = (function (exports) {
11559
11882
  //a save answering with no body at all is still a save
11560
11883
  /** @type {{ dismissed: boolean, response: any }|null} */
11561
11884
  #answer = null;
11885
+ #closing = false;
11886
+ #slideOutEnded = (/** @type AnimationEvent */ e) => {
11887
+ if (e.target === this.#dialog) {
11888
+ this.#closeNow();
11889
+ }
11890
+ };
11562
11891
  render({ slots }) {
11563
11892
  const fragment = this.template()
11564
- .withOverlay({ slots, title: this.declared('title') ?? '' })
11893
+ .withOverlay({ slots, title: this.declared('header') ?? '' })
11565
11894
  .render();
11566
11895
  this.#dialog = fragment.querySelector('[data-ref=dialog]');
11567
11896
  this.#title = fragment.querySelector('[data-ref=title]');
11897
+ //a named surface, like a dialog: the heading announces the drawer on
11898
+ //opening only where it names it, and the reference survives every
11899
+ //textContent update() writes into the heading
11900
+ this.#title.id ||= Attributes.uid('ful-drawer-title');
11901
+ this.#dialog.setAttribute('aria-labelledby', this.#title.id);
11568
11902
  this.#loading = fragment.querySelector('[data-ref=loading]');
11569
11903
  this.#error = fragment.querySelector('[data-ref=error]');
11570
11904
  this.#content = fragment.querySelector('[data-ref=content]');
@@ -11582,6 +11916,10 @@ var fml = (function (exports) {
11582
11916
  this.close();
11583
11917
  }
11584
11918
  });
11919
+ this.#dialog.addEventListener('cancel', (e) => {
11920
+ e.preventDefault();
11921
+ this.close();
11922
+ });
11585
11923
  this.#dialog.addEventListener('close', () => {
11586
11924
  this.dispatchEvent(
11587
11925
  new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
@@ -11605,23 +11943,23 @@ var fml = (function (exports) {
11605
11943
  this.replaceChildren(fragment);
11606
11944
  wireTargets();
11607
11945
  }
11608
- get title() {
11946
+ get header() {
11609
11947
  return this.#title.textContent;
11610
11948
  }
11611
- set title(v) {
11949
+ set header(v) {
11612
11950
  this.#title.textContent = v ?? '';
11613
11951
  }
11614
11952
  /**
11615
- * Opens the drawer under the given title and waits for the callback: a
11953
+ * Opens the drawer under the given header and waits for the callback: a
11616
11954
  * resolved value paints the content section (which is returned), a
11617
11955
  * rejection paints the problems and travels to the caller, and an update
11618
11956
  * superseded by a newer one paints nothing.
11619
11957
  */
11620
- async update(title, cb) {
11958
+ async update(header, cb) {
11621
11959
  //the claim detaches any update still in flight: its outcome belongs to
11622
11960
  //an abandoned opening and must neither be painted nor own the drawer
11623
11961
  const claim = this.#updates.take();
11624
- this.title = title;
11962
+ this.header = header;
11625
11963
  this.#content.replaceChildren();
11626
11964
  this.#restChrome();
11627
11965
  this.#loading.removeAttribute('hidden');
@@ -11667,10 +12005,33 @@ var fml = (function (exports) {
11667
12005
  this.#requests.request(this, this.#content, null, null)?.catch(() => undefined);
11668
12006
  }
11669
12007
  close() {
12008
+ if (this.#closing || !this.#dialog.open) {
12009
+ return;
12010
+ }
12011
+ if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
12012
+ this.#dialog.close();
12013
+ return;
12014
+ }
12015
+ this.#dialog.setAttribute('closing', '');
12016
+ if (this.#dialog.getAnimations().length === 0) {
12017
+ this.#closeNow();
12018
+ return;
12019
+ }
12020
+ this.#closing = true;
12021
+ this.#dialog.addEventListener('animationend', this.#slideOutEnded);
12022
+ }
12023
+ #closeNow() {
12024
+ this.#stopSlidingOut();
11670
12025
  this.#dialog.close();
11671
12026
  }
12027
+ #stopSlidingOut() {
12028
+ this.#closing = false;
12029
+ this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
12030
+ this.#dialog.removeAttribute('closing');
12031
+ }
11672
12032
  /** Shows the modal, answering whether this call is the one that opened it. */
11673
12033
  #show() {
12034
+ this.#stopSlidingOut();
11674
12035
  if (this.#dialog.open) {
11675
12036
  return false;
11676
12037
  }
@@ -11717,6 +12078,9 @@ var fml = (function (exports) {
11717
12078
  if (!listenerWired) {
11718
12079
  listenerWired = true;
11719
12080
  document.addEventListener('show-toast', (/** @type any */ e) => {
12081
+ if (!e.detail) {
12082
+ return;
12083
+ }
11720
12084
  for (const region of REGIONS) {
11721
12085
  region.show(e.detail.message, e.detail);
11722
12086
  }
@@ -11728,8 +12092,13 @@ var fml = (function (exports) {
11728
12092
  * Appends a toast carrying the message (a Failure shows its problems'
11729
12093
  * reasons, one per line), severity picking the theme and the announcement,
11730
12094
  * the toast retiring through its own timer or its dismiss button.
12095
+ *
12096
+ * The timer holds while the toast is hovered or holds the focus, so an
12097
+ * actionable toast waits for its reader: `action` is `{ label, onClick }`,
12098
+ * a button beside the message whose click answers and retires. The same
12099
+ * options travel in the `show-toast` event's detail.
11731
12100
  * @param {any} message
11732
- * @param {any} [options] severity and timeout
12101
+ * @param {any} [options] severity, timeout and action
11733
12102
  * @returns {HTMLElement}
11734
12103
  */
11735
12104
  show(message, options = {}) {
@@ -11746,14 +12115,13 @@ var fml = (function (exports) {
11746
12115
  icon.setAttribute('name', 'x-lg');
11747
12116
  icon.setAttribute('aria-hidden', 'true');
11748
12117
  dismiss.append(icon);
11749
- item.append(body, dismiss);
11750
- item.addEventListener('animationend', () => {
11751
- if (item.classList.contains('ful-toast-out')) {
11752
- item.remove();
11753
- }
11754
- });
12118
+ item.append(body);
12119
+ let remaining = options.timeout ?? this.#timeout;
12120
+ let startedAt = performance.now();
12121
+ let timer = 0;
11755
12122
  const retire = () => {
11756
- //the toast may hold the focus, on its own dismiss button: handing it
12123
+ clearTimeout(timer);
12124
+ //the toast may hold the focus, on its own buttons: handing it
11757
12125
  //back to the region keeps the reader somewhere rather than on <body>
11758
12126
  if (item.contains(document.activeElement)) {
11759
12127
  /** @type HTMLElement */ (this).focus();
@@ -11767,9 +12135,49 @@ var fml = (function (exports) {
11767
12135
  item.remove();
11768
12136
  }
11769
12137
  };
12138
+ if (options.action?.label) {
12139
+ const action = document.createElement('button');
12140
+ action.type = 'button';
12141
+ action.className = 'ful-toast-action';
12142
+ action.textContent = String(options.action.label);
12143
+ action.addEventListener('click', () => {
12144
+ options.action.onClick?.();
12145
+ retire();
12146
+ });
12147
+ item.append(action);
12148
+ }
12149
+ item.append(dismiss);
12150
+ item.addEventListener('animationend', () => {
12151
+ if (item.classList.contains('ful-toast-out')) {
12152
+ item.remove();
12153
+ }
12154
+ });
12155
+ //the pointer and the focus hold independently: a count rather than a
12156
+ //flag, so the pointer leaving while the focus stays keeps the hold
12157
+ let holds = 0;
12158
+ const hold = () => {
12159
+ if (holds === 0) {
12160
+ clearTimeout(timer);
12161
+ remaining = Math.max(0, remaining - (performance.now() - startedAt));
12162
+ }
12163
+ ++holds;
12164
+ };
12165
+ const release = () => {
12166
+ if (holds === 0) {
12167
+ return;
12168
+ }
12169
+ if (--holds === 0) {
12170
+ startedAt = performance.now();
12171
+ timer = setTimeout(retire, remaining);
12172
+ }
12173
+ };
12174
+ item.addEventListener('mouseenter', hold);
12175
+ item.addEventListener('mouseleave', release);
12176
+ item.addEventListener('focusin', hold);
12177
+ item.addEventListener('focusout', release);
11770
12178
  dismiss.addEventListener('click', retire);
11771
12179
  this.append(item);
11772
- setTimeout(retire, options.timeout ?? this.#timeout);
12180
+ timer = setTimeout(retire, remaining);
11773
12181
  return item;
11774
12182
  }
11775
12183
  }
@@ -11880,7 +12288,7 @@ var fml = (function (exports) {
11880
12288
  this.#active = index;
11881
12289
  this.reflectTo('active', index);
11882
12290
  if (this.rendered && index !== previous) {
11883
- this.dispatchEvent(new CustomEvent('change', { detail: { active: index, previous } }));
12291
+ this.dispatchEvent(new CustomEvent('tabs:change', { detail: { active: index, previous } }));
11884
12292
  }
11885
12293
  if (this.#panels.length > 0 && (index !== previous || !this.rendered)) {
11886
12294
  //the activation is the reader's own gesture: the chrome reports a
@@ -12101,6 +12509,8 @@ var fml = (function (exports) {
12101
12509
  'info.tooltip': 'More information',
12102
12510
  'dialog.acknowledge': 'Got it',
12103
12511
  'dialog.close': 'Close',
12512
+ 'dialog.confirm': 'Confirm',
12513
+ 'dialog.cancel': 'Cancel',
12104
12514
  'drawer.close': 'Close',
12105
12515
  'spinner.loading': 'Loading…',
12106
12516
  'toast.region': 'Notifications',
@@ -12143,6 +12553,8 @@ var fml = (function (exports) {
12143
12553
  'info.tooltip': 'Maggiori informazioni',
12144
12554
  'dialog.acknowledge': 'Ho capito',
12145
12555
  'dialog.close': 'Chiudi',
12556
+ 'dialog.confirm': 'Conferma',
12557
+ 'dialog.cancel': 'Annulla',
12146
12558
  'drawer.close': 'Chiudi',
12147
12559
  'spinner.loading': 'Caricamento…',
12148
12560
  'toast.region': 'Notifiche',
@@ -12185,6 +12597,8 @@ var fml = (function (exports) {
12185
12597
  'info.tooltip': 'Más información',
12186
12598
  'dialog.acknowledge': 'Entendido',
12187
12599
  'dialog.close': 'Cerrar',
12600
+ 'dialog.confirm': 'Confirmar',
12601
+ 'dialog.cancel': 'Cancelar',
12188
12602
  'drawer.close': 'Cerrar',
12189
12603
  'spinner.loading': 'Cargando…',
12190
12604
  'toast.region': 'Notificaciones',
@@ -12230,6 +12644,8 @@ var fml = (function (exports) {
12230
12644
  'info.tooltip': 'Plus d’informations',
12231
12645
  'dialog.acknowledge': 'J’ai compris',
12232
12646
  'dialog.close': 'Fermer',
12647
+ 'dialog.confirm': 'Confirmer',
12648
+ 'dialog.cancel': 'Annuler',
12233
12649
  'drawer.close': 'Fermer',
12234
12650
  'spinner.loading': 'Chargement…',
12235
12651
  'toast.region': 'Notifications',
@@ -12270,7 +12686,7 @@ var fml = (function (exports) {
12270
12686
  const httpClient =
12271
12687
  this.#httpClient ?? HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
12272
12688
  //the fallback chain is baked here: en, the active language, the consumer's own strings
12273
- const language = this.#language.split('-')[0];
12689
+ const language = this.#language.split('-')[0].toLowerCase();
12274
12690
  const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
12275
12691
  registry
12276
12692
  .defineModule('l10n', Localization)