@optionfactory/fml 9.0.0-rc1 → 9.0.0-rc11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fml.iife.js CHANGED
@@ -6462,6 +6462,48 @@ var fml = (function (exports) {
6462
6462
  }
6463
6463
  }
6464
6464
 
6465
+ /**
6466
+ * The protocol by which content standing inside a field becomes part of the
6467
+ * accessible description of that field's control.
6468
+ *
6469
+ * A field owns its control's `aria-describedby`: it is the only thing that
6470
+ * knows which element the description belongs on, and it already writes the
6471
+ * entry for its own error region. Content the author slotted into the field
6472
+ * cannot write that attribute itself without becoming a second owner of it, and
6473
+ * it cannot be wired by the field either, because a slotted custom element
6474
+ * renders after the field has mounted and has nothing to point at when the
6475
+ * field looks.
6476
+ *
6477
+ * So the content asks, once it has something to offer. `describable(el)`
6478
+ * answers the nearest ancestor that accepts a description, and the caller hands
6479
+ * its element to that ancestor's `describedBy`, which answers whether it was
6480
+ * taken. Nothing here names a field or a tooltip: the relation is expressed as
6481
+ * a capability, so the two ends need not import each other, which matters
6482
+ * because the library's own arrow runs from the forms to the disclosures.
6483
+ *
6484
+ * The lookup lives here rather than at its one call site so the protocol has a
6485
+ * name, a place to be documented and a single definition to change.
6486
+ */
6487
+
6488
+ /**
6489
+ * @typedef {{ describedBy(el: HTMLElement): boolean }} Describable
6490
+ */
6491
+
6492
+ /**
6493
+ * The nearest ancestor of `el` that accepts elements into the description of
6494
+ * whatever it considers its control, or null when nothing in the ancestry does.
6495
+ * @param {Element} el
6496
+ * @returns {(Element & Describable) | null}
6497
+ */
6498
+ const describable = (el) => {
6499
+ for (let at = el.parentElement; at; at = at.parentElement) {
6500
+ if (typeof (/** @type {any} */ (at).describedBy) === 'function') {
6501
+ return /** @type {any} */ (at);
6502
+ }
6503
+ }
6504
+ return null;
6505
+ };
6506
+
6465
6507
  /**
6466
6508
  * Sleeping, debouncing and throttling. Debounce and throttle both return the
6467
6509
  * wrapped function together with a cancel function.
@@ -6856,6 +6898,9 @@ var fml = (function (exports) {
6856
6898
  /** the role the element internals carry, 'presentation' unless the control is its own */
6857
6899
  static ROLE = 'presentation';
6858
6900
  #control;
6901
+ #described;
6902
+ #descriptions = [];
6903
+ #errorId = null;
6859
6904
  #fieldError;
6860
6905
  #claims;
6861
6906
  #announces;
@@ -6907,15 +6952,18 @@ var fml = (function (exports) {
6907
6952
  true,
6908
6953
  );
6909
6954
  }
6910
- //the error region describes the control, or the host where there is no
6955
+ //the description lands on the control, or on the host where there is no
6911
6956
  //single control to describe (a radio group's legend names its fieldset)
6957
+ this.#described = described ?? control;
6912
6958
  if (error) {
6913
- (described ?? control).ariaDescribedByElements = [error];
6959
+ //named for what it is, the generic id being for whoever brings no name
6960
+ error.id = error.id || Attributes.uid('ful-field-error');
6961
+ this.#errorId = error.id;
6914
6962
  }
6963
+ //anything handed over before the field had a target lands here
6964
+ this.#describe();
6915
6965
  if (label) {
6916
- control.ariaLabelledByElements = [label];
6917
- //a label that does not natively target the control still focuses it
6918
- label.addEventListener('click', () => this.focus());
6966
+ Field.#name(this, label, control);
6919
6967
  }
6920
6968
  //the platform's implicit submission, stood in for where the field's own
6921
6969
  //protocol took it away: the inner controls carry form="", so Enter in one
@@ -6948,6 +6996,61 @@ var fml = (function (exports) {
6948
6996
  static #submitsOnEnter(el) {
6949
6997
  return el instanceof HTMLInputElement && !['file', 'button', 'submit', 'reset', 'image'].includes(el.type);
6950
6998
  }
6999
+ /**
7000
+ * Adds an element to the accessible description of the field's control and
7001
+ * answers whether the field took it.
7002
+ *
7003
+ * A field takes one whenever it is offered, before its own render as
7004
+ * readily as after: content slotted into a field is a custom element of its
7005
+ * own and may upgrade on either side of the field it stands in, which
7006
+ * happens in both directions in practice, a tooltip beating an async select
7007
+ * to its render while losing to a plain input. A description handed over
7008
+ * early waits here and is written the moment the field has somewhere to
7009
+ * write it, so the caller never has to know the order.
7010
+ *
7011
+ * The reference lands on the element handed over rather than on a wrapper
7012
+ * around it: a hidden element is included in a description only where it is
7013
+ * named directly, and content that reaches the description through a
7014
+ * wrapper is skipped while it is hidden. A popover closed until someone
7015
+ * opens it is exactly that, so the caller passes the popover itself.
7016
+ *
7017
+ * An attribute rather than `ariaDescribedByElements`: the property reflects
7018
+ * to nothing, so the description would live in the accessibility tree alone
7019
+ * and vanish entirely on a browser without aria element reflection.
7020
+ *
7021
+ * This is the field's half of the description protocol; `describable` in
7022
+ * `ful/descriptions.mjs` is the half the content uses to find the field.
7023
+ * @param {HTMLElement} el
7024
+ * @returns {boolean}
7025
+ */
7026
+ describedBy(el) {
7027
+ if (!el) {
7028
+ return false;
7029
+ }
7030
+ if (!el.id) {
7031
+ el.id = Attributes.uid('ful-described');
7032
+ }
7033
+ if (!this.#descriptions.includes(el.id)) {
7034
+ this.#descriptions.push(el.id);
7035
+ }
7036
+ this.#describe();
7037
+ return true;
7038
+ }
7039
+ /**
7040
+ * Writes the description the field has collected, the error region last:
7041
+ * the standing explanations are what the field always says, the problem is
7042
+ * the news. The field owns the attribute outright rather than appending to
7043
+ * whatever is there, so the order does not depend on who arrived when.
7044
+ */
7045
+ #describe() {
7046
+ if (!this.#described) {
7047
+ return;
7048
+ }
7049
+ const ids = [...this.#descriptions, this.#errorId].filter((id) => id);
7050
+ if (ids.length) {
7051
+ this.#described.setAttribute('aria-describedby', ids.join(' '));
7052
+ }
7053
+ }
6951
7054
  focus(options) {
6952
7055
  this.#control?.focus(options);
6953
7056
  }
@@ -7004,6 +7107,49 @@ var fml = (function (exports) {
7004
7107
  }),
7005
7108
  );
7006
7109
  }
7110
+ /** The html elements a label's `for` may point at, `input[type=hidden]` excepted. */
7111
+ static #LABELABLE = new Set(['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA']);
7112
+ /**
7113
+ * Names the control from the field's label, natively wherever the platform
7114
+ * allows it.
7115
+ *
7116
+ * `for` and `id` are the form the dom itself carries, so the association is
7117
+ * there for anything reading the markup rather than the accessibility tree:
7118
+ * an audit tool, the browser's autofill, a translation pass. It also makes
7119
+ * the label's click reach the control the way it does in a plain form, which
7120
+ * is focus for a text control and activation for a checkbox, so the field
7121
+ * needs no handler of its own.
7122
+ *
7123
+ * A control the platform will not let a label target, a composite carrying
7124
+ * `role="radiogroup"` among them, takes `aria-labelledby` instead. That is an
7125
+ * attribute too, so the association is equally visible; what it does not carry
7126
+ * is the label's click, which is why the handler stays on that path only.
7127
+ *
7128
+ * Neither branch uses `ariaLabelledByElements`. The property reflects to no
7129
+ * attribute, so the name lived in the accessibility tree alone: nothing reading
7130
+ * the dom saw it, and on a browser without aria element reflection the
7131
+ * assignment is a silent expando and the field has no name at all.
7132
+ * @param {any} field
7133
+ * @param {HTMLElement} label
7134
+ * @param {any} control
7135
+ */
7136
+ static #name(field, label, control) {
7137
+ const labelable =
7138
+ Field.#LABELABLE.has(control.tagName) && control.getAttribute('type') !== 'hidden';
7139
+ if (!labelable) {
7140
+ if (!label.id) {
7141
+ label.id = Attributes.uid('ful-label');
7142
+ }
7143
+ control.setAttribute('aria-labelledby', label.id);
7144
+ //aria-labelledby carries the name but not the label's click
7145
+ label.addEventListener('click', () => field.focus());
7146
+ return;
7147
+ }
7148
+ if (!control.id) {
7149
+ control.id = Attributes.uid('ful-control');
7150
+ }
7151
+ label.setAttribute('for', control.id);
7152
+ }
7007
7153
  /**
7008
7154
  * Whether the field's chrome should answer a gesture. Badges, dropzones,
7009
7155
  * menus and labels are not form controls, so their handlers must ask the
@@ -7141,8 +7287,9 @@ var fml = (function (exports) {
7141
7287
  * three claims reach it
7142
7288
  * - `error` is the field's live region
7143
7289
  * - `label`, when given, names the control and focuses it on click
7144
- * - `described` moves the error's description off the control and onto
7145
- * another element, the host where no single control can carry it
7290
+ * - `described` moves the description off the control and onto another
7291
+ * element, the host where no single control can carry it: the error
7292
+ * region and anything `describedBy` is later handed both land there
7146
7293
  * - `claims` moves the three claims onto a wrapper the field disables as a
7147
7294
  * whole, leaving focus and aria on the control
7148
7295
  * - `announces` is the element whose role carries `aria-readonly` and
@@ -7254,6 +7401,7 @@ var fml = (function (exports) {
7254
7401
  'response-mapper',
7255
7402
  'clear-invalid-on-change:presence',
7256
7403
  'scroll-on-error:presence',
7404
+ 'autocomplete',
7257
7405
  ];
7258
7406
  form;
7259
7407
  render() {
@@ -7264,6 +7412,10 @@ var fml = (function (exports) {
7264
7412
  //internals messages custom elements have no default UI for
7265
7413
  form.setAttribute('novalidate', '');
7266
7414
  Attributes.forward('form-', this, form);
7415
+ //the fields read it off whichever of the two they reach first, which depends
7416
+ //on whether they upgraded before or after this render: they cannot read it
7417
+ //off their own control, which carries form="" and so has no form owner
7418
+ Attributes.set(form, 'autocomplete', this.declared('autocomplete'));
7267
7419
  form.replaceChildren(...this.childNodes);
7268
7420
  form.addEventListener('submit', async (e) => {
7269
7421
  e.preventDefault();
@@ -7468,6 +7620,38 @@ var fml = (function (exports) {
7468
7620
  * `reject="[^0-9]"` both leave the digits. Keeping is the one worth reaching for,
7469
7621
  * the rejecting spelling of an allowed set being a double negative.
7470
7622
  */
7623
+ /**
7624
+ * The autofill token a field inherits from the form around it.
7625
+ *
7626
+ * A control is rendered with `form=""` so that the host is the only thing that
7627
+ * submits, which also leaves it without a form owner, and the platform resolves
7628
+ * `autocomplete` through the form owner. So a form declaring it reaches nothing
7629
+ * on its own and the field reads the setting off the form element instead.
7630
+ *
7631
+ * The `form` a `ful-form` renders answers here, the host copying its token onto
7632
+ * it, and a plain `form` around ful fields answers too: the platform meant the
7633
+ * same thing by it, and its inheritance is broken here for the same reason. An
7634
+ * ancestor always upgrades before its descendants, so the rendered form is in
7635
+ * place by the time a field of its own builds.
7636
+ */
7637
+ const inheritedAutocomplete = (el) => el.closest('form')?.getAttribute('autocomplete') ?? null;
7638
+
7639
+ /** Rendering a text input is what costs these the native min/max/step enforcement. */
7640
+ const INPUT_MODES = { numeric: 'numeric', decimal: 'decimal' };
7641
+
7642
+ const signed = (v, digits) => (v.startsWith('-') ? '-' : '') + digits(v);
7643
+
7644
+ const NUMERIC_FILTERS = {
7645
+ numeric: (v) => signed(v, (t) => t.replace(/\D/g, '')),
7646
+ //one separator, the first: the rest are a slip while typing, not a value
7647
+ decimal: (v) =>
7648
+ signed(v, (t) => {
7649
+ const kept = t.replace(/[^\d.,]/g, '');
7650
+ const at = kept.search(/[.,]/);
7651
+ return at === -1 ? kept : kept.slice(0, at + 1) + kept.slice(at + 1).replace(/[.,]/g, '');
7652
+ }),
7653
+ };
7654
+
7471
7655
  const warnedBoth = new WeakSet();
7472
7656
  const filterOf = (el) => {
7473
7657
  const keep = el.declared('keep');
@@ -7487,7 +7671,7 @@ var fml = (function (exports) {
7487
7671
  const re = compiled('reject', reject);
7488
7672
  return re && ((v) => v.replace(re, ''));
7489
7673
  }
7490
- return null;
7674
+ return NUMERIC_FILTERS[el._type()] ?? null;
7491
7675
  };
7492
7676
 
7493
7677
  /** A labelled text input over any native type or textarea; the temporal inputs are its subclasses. */
@@ -7495,7 +7679,15 @@ var fml = (function (exports) {
7495
7679
  static observed = ['placeholder'];
7496
7680
  //configuration: the control is built from them and the value getter reads them,
7497
7681
  //but none of them is meant to change once the element is up
7498
- static attributes = ['type', 'v-type', 'keep', 'reject', 'uppercase:presence', 'trim:presence'];
7682
+ static attributes = [
7683
+ 'type',
7684
+ 'v-type',
7685
+ 'keep',
7686
+ 'reject',
7687
+ 'uppercase:presence',
7688
+ 'trim:presence',
7689
+ 'autocomplete',
7690
+ ];
7499
7691
  static slots = true;
7500
7692
  static template = `
7501
7693
  <label>{{{{ slots.default }}}}</label>
@@ -7515,10 +7707,26 @@ var fml = (function (exports) {
7515
7707
  return this.declared('type') ?? (this.declared('v-type') === 'number' ? 'number' : 'text');
7516
7708
  }
7517
7709
  _build({ slots }) {
7518
- const type = this._type();
7710
+ const declared = this._type();
7711
+ const mode = INPUT_MODES[declared];
7712
+ //a numeric widget is a text input: the type names the keyboard, not the
7713
+ //control, so the rendered markup stays valid html
7714
+ const type = mode ? 'text' : declared;
7519
7715
  const fragment = this.template().withOverlay({ type, slots }).render();
7520
7716
  this._input = fragment.querySelector('input,textarea');
7521
-
7717
+ if (mode) {
7718
+ //before the passthrough, which stays the last word
7719
+ Attributes.set(this._input, 'inputmode', mode);
7720
+ }
7721
+
7722
+ //the browser reads autocomplete off the control it is classifying, so the
7723
+ //field's own token, or the form's where it declares none, is put there.
7724
+ //Set before the passthrough, which stays the last word
7725
+ Attributes.set(
7726
+ this._input,
7727
+ 'autocomplete',
7728
+ this.declared('autocomplete') ?? inheritedAutocomplete(this),
7729
+ );
7522
7730
  Attributes.forward('input-', this, this._input);
7523
7731
  this._input.addEventListener('input', (evt) => {
7524
7732
  const strip = filterOf(this);
@@ -7561,13 +7769,15 @@ var fml = (function (exports) {
7561
7769
  if (trimmed === '') {
7562
7770
  return null;
7563
7771
  }
7772
+ //the wire carries a dot wherever the comma is what the keyboard shows
7773
+ const normalized = this._type() === 'decimal' ? trimmed.replaceAll(',', '.') : trimmed;
7564
7774
  if (this.declared('v-type') === 'number') {
7565
7775
  //typed values are an explicit opt in, as the select's k-type: blank
7566
7776
  //stays null, and a value that does not decode is kept as it is
7567
- const n = Number(trimmed);
7568
- return Number.isNaN(n) ? trimmed : n;
7777
+ const n = Number(normalized);
7778
+ return Number.isNaN(n) ? normalized : n;
7569
7779
  }
7570
- return trimmed;
7780
+ return normalized;
7571
7781
  }
7572
7782
  set value(value) {
7573
7783
  this._input.value = value === '' || value === undefined ? null : value;
@@ -8156,8 +8366,14 @@ var fml = (function (exports) {
8156
8366
  const box = invoker.getBoundingClientRect();
8157
8367
  const here = popover.getBoundingClientRect();
8158
8368
  //against the padding box, which is what a percentage inset resolves against
8159
- popover.style.setProperty('--ful-note-callout-inline', `${box.left + box.width / 2 - here.left - popover.clientLeft}px`);
8160
- popover.style.setProperty('--ful-note-callout-block', `${box.top + box.height / 2 - here.top - popover.clientTop}px`);
8369
+ popover.style.setProperty(
8370
+ '--ful-note-callout-inline',
8371
+ `${box.left + box.width / 2 - here.left - popover.clientLeft}px`,
8372
+ );
8373
+ popover.style.setProperty(
8374
+ '--ful-note-callout-block',
8375
+ `${box.top + box.height / 2 - here.top - popover.clientTop}px`,
8376
+ );
8161
8377
  };
8162
8378
 
8163
8379
  const place = (popover, anchored) => {
@@ -8261,67 +8477,93 @@ var fml = (function (exports) {
8261
8477
  };
8262
8478
 
8263
8479
  /**
8264
- * Wires an invoker/popover pair ful anchors through css: where the
8265
- * platform lacks anchor positioning the popover is placed beside its
8266
- * invoker whenever it opens, stretched to the invoker's width when
8267
- * asked, and cleaned up when it closes. Where the css works the call
8268
- * is a no-op.
8269
- *
8270
- * `handPlace` takes the placement here on every platform, which a popover
8271
- * asks for when it needs to know where its invoker ended up: the note's
8272
- * callout points at the invoker, and a pseudo-element cannot read an anchor
8273
- * that is not inside its own containing block, so the note is measured rather
8274
- * than placed by the css. Its stylesheet declares no position-area to match.
8480
+ * CSS anchor positioning for a popover and the invoker it belongs to, with the
8481
+ * hand-placed fallback for the platforms that do not have it.
8275
8482
  */
8276
- const wireAnchoredPopover = (
8277
- invoker,
8278
- popover,
8279
- { prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
8280
- ) => {
8281
- const uid = Attributes.uid(prefix);
8282
- if (invoke) {
8283
- //popovertarget needs a target that can be named
8284
- popover.id = popover.id || uid;
8285
- invoker.setAttribute('popovertarget', popover.id);
8286
- }
8287
- const anchor = `--${uid}`;
8288
- invoker.style.anchorName = anchor;
8289
- popover.style.positionAnchor = anchor;
8290
- if (expanded) {
8291
- invoker.setAttribute('aria-expanded', 'false');
8483
+ class Anchors {
8484
+ /**
8485
+ * Anchors a popover to its invoker.
8486
+ *
8487
+ * The invoker is given an `anchor-name` and the popover a `position-anchor`
8488
+ * pointing at it, which is what a stylesheet needs to place the popover
8489
+ * itself: the library's own menus say `top: anchor(bottom); left:
8490
+ * anchor(left)`. **Writing that css is the caller's half of this.** Without
8491
+ * it the popover lands wherever the user agent puts a popover, which is not
8492
+ * beside the invoker.
8493
+ *
8494
+ * Where the platform has no anchor positioning the popover is placed here
8495
+ * instead, beside the invoker whenever it opens, clamped into the viewport,
8496
+ * following it on scroll and resize, and cleaned up on close. That placement
8497
+ * draws the geometry the css above describes, so the two agree.
8498
+ *
8499
+ * @param {HTMLElement} invoker the element the popover belongs to
8500
+ * @param {HTMLElement} popover the `[popover]` element to place
8501
+ * @param {object} [options]
8502
+ * @param {string} [options.prefix] prefixes the generated anchor name and id,
8503
+ * so the dom says which component a name belongs to
8504
+ * @param {boolean} [options.invoke] points the invoker's `popovertarget` at
8505
+ * the popover, giving toggle and light dismiss with no script of your own
8506
+ * @param {boolean} [options.expanded] keeps the invoker's `aria-expanded` in
8507
+ * step with the popover
8508
+ * @param {boolean} [options.stretch] widens the popover to its invoker, which
8509
+ * is what a combobox dropdown wants
8510
+ * @param {boolean} [options.handPlace] places here on every platform rather
8511
+ * than only as a fallback, which a popover asks for when it needs to know
8512
+ * where its invoker ended up: the tooltip's note points a callout at it, and
8513
+ * a pseudo-element cannot read an anchor outside its own containing block.
8514
+ * Such a popover declares no anchor placement in css, there being none to
8515
+ * agree with
8516
+ */
8517
+ static wire(
8518
+ invoker,
8519
+ popover,
8520
+ { prefix = 'ful-anchor', invoke = false, expanded = false, stretch = false, handPlace = false } = {},
8521
+ ) {
8522
+ const uid = Attributes.uid(prefix);
8523
+ if (invoke) {
8524
+ //popovertarget needs a target that can be named
8525
+ popover.id = popover.id || uid;
8526
+ invoker.setAttribute('popovertarget', popover.id);
8527
+ }
8528
+ const anchor = `--${uid}`;
8529
+ invoker.style.anchorName = anchor;
8530
+ popover.style.positionAnchor = anchor;
8531
+ if (expanded) {
8532
+ invoker.setAttribute('aria-expanded', 'false');
8533
+ popover.addEventListener('toggle', (/** @type any */ evt) => {
8534
+ invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
8535
+ });
8536
+ }
8537
+ //the naming above is what the stylesheet reads, so it happens either way:
8538
+ //only the hand placement below is the fallback, and only for a popover that
8539
+ //did not ask to be placed here whatever the platform offers
8540
+ if (!handPlace && platformAnchors()) {
8541
+ return;
8542
+ }
8543
+ const anchored = { invoker, stretch };
8544
+ popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
8545
+ //placed before the showing, refined once laid out: the platform's
8546
+ //centered or corner spot never paints
8547
+ if (evt.newState === 'open') {
8548
+ place(popover, anchored);
8549
+ }
8550
+ });
8292
8551
  popover.addEventListener('toggle', (/** @type any */ evt) => {
8293
- invoker.setAttribute('aria-expanded', evt.newState === 'open' ? 'true' : 'false');
8552
+ if (evt.newState === 'open') {
8553
+ open.set(popover, anchored);
8554
+ place(popover, anchored);
8555
+ } else {
8556
+ open.delete(popover);
8557
+ unplace(popover);
8558
+ }
8294
8559
  });
8295
- }
8296
- //the naming above is what the stylesheet reads, so it happens either way:
8297
- //only the hand placement below is the fallback, and only for a popover that
8298
- //did not ask to be placed here whatever the platform offers
8299
- if (!handPlace && platformAnchors()) {
8300
- return;
8301
- }
8302
- const anchored = { invoker, stretch };
8303
- popover.addEventListener('beforetoggle', (/** @type any */ evt) => {
8304
- //placed before the showing, refined once laid out: the platform's
8305
- //centered or corner spot never paints
8306
- if (evt.newState === 'open') {
8307
- place(popover, anchored);
8308
- }
8309
- });
8310
- popover.addEventListener('toggle', (/** @type any */ evt) => {
8311
- if (evt.newState === 'open') {
8312
- open.set(popover, anchored);
8313
- place(popover, anchored);
8314
- } else {
8315
- open.delete(popover);
8316
- unplace(popover);
8560
+ if (!reflowWired) {
8561
+ reflowWired = true;
8562
+ document.addEventListener('scroll', schedule, true);
8563
+ window.addEventListener('resize', schedule);
8317
8564
  }
8318
- });
8319
- if (!reflowWired) {
8320
- reflowWired = true;
8321
- document.addEventListener('scroll', schedule, true);
8322
- window.addEventListener('resize', schedule);
8323
8565
  }
8324
- };
8566
+ }
8325
8567
 
8326
8568
  /**
8327
8569
  * Fetches a select's whole vocabulary from a url and serves every later read
@@ -8527,10 +8769,27 @@ var fml = (function (exports) {
8527
8769
  method: el.declared('method') ?? 'POST',
8528
8770
  mode: el.declared('mode'),
8529
8771
  prefetch: el.declared('preload'),
8530
- revision: el.declared('revision'),
8772
+ revision: SelectLoader.#revisionFrom(el),
8531
8773
  responseMapper: SelectLoader.#responseMapperFrom(el),
8532
8774
  });
8533
8775
  }
8776
+ static #revisionFrom(el) {
8777
+ const declared = el.declared('revision');
8778
+ if (declared !== '') {
8779
+ return declared;
8780
+ }
8781
+ const configured = el.component('revision');
8782
+ if (configured === undefined || configured === null) {
8783
+ //not caching while the markup says to cache is the quiet failure
8784
+ //this branch exists to make loud
8785
+ console.warn(
8786
+ "a valueless revision asks the registry for one, and no 'revision' component is defined: the vocabulary will not be cached",
8787
+ el,
8788
+ );
8789
+ return null;
8790
+ }
8791
+ return typeof configured === 'function' ? configured() : configured;
8792
+ }
8534
8793
  static #responseMapperFrom(el) {
8535
8794
  if (el.declared('k-expr') && el.declared('l-expr')) {
8536
8795
  return (response) => {
@@ -8563,7 +8822,7 @@ var fml = (function (exports) {
8563
8822
  static slots = true;
8564
8823
  static template = `
8565
8824
  <ful-spinner class="centered" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
8566
- <p data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</p>
8825
+ <ful-empty data-ref="empty" aria-live="polite" hidden>{{ #l10n:t('dropdown.empty') }}</ful-empty>
8567
8826
  <menu tabindex="-1" role="listbox" hidden></menu>
8568
8827
  `;
8569
8828
  static templates = {
@@ -8585,7 +8844,7 @@ var fml = (function (exports) {
8585
8844
  ? this.template('options')
8586
8845
  : Templates.fromFragment(slots.default);
8587
8846
  this.#spinner = fragment.querySelector('ful-spinner');
8588
- this.#empty = fragment.querySelector('p[data-ref=empty]');
8847
+ this.#empty = fragment.querySelector('[data-ref=empty]');
8589
8848
  this.#menu = fragment.querySelector('menu');
8590
8849
  //the listbox is named so a combobox can point aria-controls and
8591
8850
  //aria-activedescendant at it: a reference to an unnamed element resolves
@@ -8850,7 +9109,7 @@ var fml = (function (exports) {
8850
9109
  });
8851
9110
  //each pair carries its own anchor: two selects on a page must not share one
8852
9111
  const group = fragment.querySelector('ful-control-group');
8853
- wireAnchoredPopover(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
9112
+ Anchors.wire(group, this.#ddmenu, { prefix: 'ful-select', stretch: true });
8854
9113
  [this.#dload, this.#abortdload] = Timing.throttle(400, () => this.#open());
8855
9114
  this.#wireChrome();
8856
9115
  this.#wireChips();
@@ -8872,6 +9131,14 @@ var fml = (function (exports) {
8872
9131
  if (!this._interactive()) {
8873
9132
  return;
8874
9133
  }
9134
+ //a click on another control inside the select is that control's, not
9135
+ //the select's: a tooltip marker slotted into `info`, a button a page
9136
+ //put in an affix. Without this, reading the note beside a select
9137
+ //also stole the focus and dropped the dropdown over the note
9138
+ const elsewhere = e.target.closest('button, a[href], input, select, textarea');
9139
+ if (elsewhere && elsewhere !== this.#input) {
9140
+ return;
9141
+ }
8875
9142
  if (this.#ddmenu.shown) {
8876
9143
  this.#close();
8877
9144
  return;
@@ -8937,7 +9204,7 @@ var fml = (function (exports) {
8937
9204
  return;
8938
9205
  }
8939
9206
  this.#abortdload();
8940
- this.#close();
9207
+ this.#close(true);
8941
9208
  });
8942
9209
  this.#input.addEventListener('keydown', (e) => {
8943
9210
  if (!this._interactive()) {
@@ -8970,7 +9237,6 @@ var fml = (function (exports) {
8970
9237
  this.#editing = false;
8971
9238
  this.#values.set(this.#coerceKey(e.detail.entry.key), e.detail.entry);
8972
9239
  this.#changed();
8973
- this.#syncBadges();
8974
9240
  this.#input.focus();
8975
9241
  this.#ddmenu.hide();
8976
9242
  if (!this.#multiple) {
@@ -9027,7 +9293,6 @@ var fml = (function (exports) {
9027
9293
  }
9028
9294
  this.#values.delete(key);
9029
9295
  this.#changed();
9030
- this.#syncBadges();
9031
9296
  }
9032
9297
  #chipKeydown(e, badge) {
9033
9298
  switch (e.code) {
@@ -9123,7 +9388,7 @@ var fml = (function (exports) {
9123
9388
  }
9124
9389
  case 'Tab': {
9125
9390
  this.#abortdload();
9126
- this.#close();
9391
+ this.#close(true);
9127
9392
  break;
9128
9393
  }
9129
9394
  }
@@ -9139,12 +9404,25 @@ var fml = (function (exports) {
9139
9404
  }
9140
9405
  return;
9141
9406
  }
9142
- this.#browse();
9143
- this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#input.value), [...this.#values.keys()]);
9407
+ this.#ddmenu.moveOrShow(forward, () => this.#loader.load(this.#query()), [...this.#values.keys()]);
9144
9408
  }
9145
- #close() {
9409
+ /**
9410
+ * @param commit whether the user is leaving the field, which only a blur or
9411
+ * a Tab is: Escape cancels, and closing the list or a claim landing leaves
9412
+ * the edit in progress
9413
+ */
9414
+ #close(commit = false) {
9146
9415
  this.#ddmenu.hide();
9416
+ //emptying the box and leaving is a clear: redisplaying the label would
9417
+ //discard the edit without a word. A multiple select keeps its box empty
9418
+ //by design, so only a single one can mean it
9419
+ const cleared =
9420
+ commit && this.#editing && !this.#multiple && this.#input.value === '' && this.#values.size !== 0;
9147
9421
  this.#editing = false;
9422
+ if (cleared) {
9423
+ this.#values.clear();
9424
+ this.#changed();
9425
+ }
9148
9426
  this.#display();
9149
9427
  }
9150
9428
  /**
@@ -9153,14 +9431,15 @@ var fml = (function (exports) {
9153
9431
  * highlighted.
9154
9432
  */
9155
9433
  #open() {
9156
- this.#browse();
9157
- return this.#ddmenu.show(() => this.#loader.load(this.#input.value), [...this.#values.keys()]);
9434
+ return this.#ddmenu.show(() => this.#loader.load(this.#query()), [...this.#values.keys()]);
9158
9435
  }
9159
- #browse() {
9160
- if (this.#editing) {
9161
- return;
9162
- }
9163
- this.#input.value = '';
9436
+ /**
9437
+ * Browsing is the whole vocabulary, not the label already chosen, and the
9438
+ * label stays on screen while it happens: a select showing nothing reads as
9439
+ * an empty one.
9440
+ */
9441
+ #query() {
9442
+ return this.#editing ? this.#input.value : '';
9164
9443
  }
9165
9444
  #display() {
9166
9445
  const entry = this.#values.values().next().value;
@@ -9171,6 +9450,9 @@ var fml = (function (exports) {
9171
9450
  return [...this.#values.values()];
9172
9451
  }
9173
9452
  #changed() {
9453
+ //settled before the event: a listener reading the control during it has to
9454
+ //see the selection that caused it
9455
+ this.#syncBadges();
9174
9456
  //the detail carries the keys the value property answers with, as every
9175
9457
  //other field's does, and the labeled selection beside them
9176
9458
  this._notifyChange({ entry: this.entry });
@@ -9440,16 +9722,10 @@ var fml = (function (exports) {
9440
9722
  evt.stopPropagation();
9441
9723
  this._notifyChange();
9442
9724
  });
9725
+ //the base points the label at the input with for/id, so the click toggles
9726
+ //the way it does in a plain form: the input's own change listener above
9727
+ //carries the notification, and readonly is refused by the freeze below
9443
9728
  const label = fragment.querySelector('label');
9444
- //the label neither wraps the input nor targets it, so the toggle is the
9445
- //field's; the base adds the focus
9446
- label.addEventListener('click', () => {
9447
- if (!this._interactive()) {
9448
- return;
9449
- }
9450
- this.value = !this.value;
9451
- this._notifyChange();
9452
- });
9453
9729
  //a checkbox has no editable text to preserve, so readonly freezes the
9454
9730
  //whole choice, label click included: the container is the frozen piece
9455
9731
  return {
@@ -9823,6 +10099,7 @@ var fml = (function (exports) {
9823
10099
  static slots = true;
9824
10100
  static config = {
9825
10101
  searchIcon: 'search',
10102
+ emptyIcon: 'inbox',
9826
10103
  };
9827
10104
  static template = `
9828
10105
  <ful-form data-tpl-if="slots.filters">
@@ -9836,10 +10113,21 @@ var fml = (function (exports) {
9836
10113
  <tbody data-ref="initial">
9837
10114
  <tr>
9838
10115
  <td data-tpl-colspan="schema.length">
9839
- <div>
9840
- <p data-tpl-if="config.searchIcon"><ful-icon data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon></p>
10116
+ <ful-empty>
10117
+ <ful-icon data-tpl-if="config.searchIcon" data-tpl-name="config.searchIcon" aria-hidden="true"></ful-icon>
9841
10118
  {{ #l10n:t('table.initial') }}
9842
- </div>
10119
+ </ful-empty>
10120
+ </td>
10121
+ </tr>
10122
+ </tbody>
10123
+ <tbody data-ref="empty" hidden>
10124
+ <tr>
10125
+ <td data-tpl-colspan="schema.length">
10126
+ <ful-empty data-tpl-if="slots.empty">{{{{ slots.empty }}}}</ful-empty>
10127
+ <ful-empty data-tpl-if="!slots.empty">
10128
+ <ful-icon data-tpl-if="config.emptyIcon" data-tpl-name="config.emptyIcon" aria-hidden="true"></ful-icon>
10129
+ {{ #l10n:t('table.no-data') }}
10130
+ </ful-empty>
9843
10131
  </td>
9844
10132
  </tr>
9845
10133
  </tbody>
@@ -9869,11 +10157,6 @@ var fml = (function (exports) {
9869
10157
  `;
9870
10158
  static templates = {
9871
10159
  row: `
9872
- <tr data-tpl-if="pageResponse.data.length == 0">
9873
- <td data-tpl-colspan="schema.length">
9874
- {{ #l10n:t('table.no-data') }}
9875
- </td>
9876
- </tr>
9877
10160
  {{{{ schema.rowsTemplate.withOverlay({'rows': pageResponse.data}).render() }}}}
9878
10161
  `,
9879
10162
  };
@@ -9882,6 +10165,7 @@ var fml = (function (exports) {
9882
10165
  #body;
9883
10166
  #loading;
9884
10167
  #noAutoload;
10168
+ #empty;
9885
10169
  #feedback;
9886
10170
  #paginator;
9887
10171
  #sorters;
@@ -9932,6 +10216,7 @@ var fml = (function (exports) {
9932
10216
  this.#body = table.querySelector(':scope > tbody');
9933
10217
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
9934
10218
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
10219
+ this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
9935
10220
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
9936
10221
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
9937
10222
  this.replaceChildren(fragment);
@@ -10013,10 +10298,16 @@ var fml = (function (exports) {
10013
10298
  //started is stale, and neither renders nor updates the request a later
10014
10299
  //reload replays, whichever order the responses arrive in
10015
10300
  const claim = this.#loads.take();
10016
- this.#body.replaceChildren();
10017
- this.#loading.removeAttribute('hidden');
10301
+ //the rows stay while the table revalidates. Emptying the body and raising
10302
+ //the spinner row in its place collapsed the table to one tall row and
10303
+ //expanded it again on every sort, page and reload: two layout jumps for
10304
+ //what is the same table with newer rows in it. The spinner is for the load
10305
+ //with nothing to show yet, the first one and the one after a failure; the
10306
+ //rest announce themselves through aria-busy, which the stylesheet reads
10307
+ this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
10018
10308
  this.#feedback.setAttribute('hidden', '');
10019
10309
  this.#noAutoload.setAttribute('hidden', '');
10310
+ this.#empty.setAttribute('hidden', '');
10020
10311
  this.setAttribute('aria-busy', 'true');
10021
10312
  try {
10022
10313
  const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
@@ -10025,6 +10316,16 @@ var fml = (function (exports) {
10025
10316
  }
10026
10317
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
10027
10318
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
10319
+ //settled before the event: a listener counting what loaded has to
10320
+ //see the table as it now is
10321
+ this.removeAttribute('aria-busy');
10322
+ this.dispatchEvent(
10323
+ new CustomEvent('load:success', {
10324
+ bubbles: true,
10325
+ cancelable: false,
10326
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
10327
+ }),
10328
+ );
10028
10329
  } catch (/** @type any */ error) {
10029
10330
  if (claim.stale) {
10030
10331
  //the newer load owns the table and its outcome: a superseded
@@ -10032,17 +10333,24 @@ var fml = (function (exports) {
10032
10333
  return;
10033
10334
  }
10034
10335
  this.#loading.setAttribute('hidden', '');
10336
+ //the rows the failed load was replacing go with it: what the table
10337
+ //holds is no longer what the request asked for, and leaving them
10338
+ //under the error would say the opposite
10339
+ this.#body.replaceChildren();
10035
10340
  this.#feedback.removeAttribute('hidden');
10036
10341
  this.#feedback.querySelector('[data-ref=feedback-error]').textContent = Failure.problemsText(
10037
10342
  error,
10038
10343
  `${error}`,
10039
10344
  );
10345
+ this.removeAttribute('aria-busy');
10346
+ this.dispatchEvent(
10347
+ new CustomEvent('load:failure', {
10348
+ bubbles: true,
10349
+ cancelable: false,
10350
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
10351
+ }),
10352
+ );
10040
10353
  throw error;
10041
- } finally {
10042
- //a superseded load owns nothing, the newer one's busy state included
10043
- if (!claim.stale) {
10044
- this.removeAttribute('aria-busy');
10045
- }
10046
10354
  }
10047
10355
  }
10048
10356
  /** Hands the loader to the callback, for runtime reconfigurations. */
@@ -10079,6 +10387,7 @@ var fml = (function (exports) {
10079
10387
  })
10080
10388
  .render(),
10081
10389
  );
10390
+ this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
10082
10391
  //one move, one repaint: the page and the count are the same state
10083
10392
  this.#paginator.update({ current: pageRequest.page, total: pages });
10084
10393
  }
@@ -10178,6 +10487,20 @@ var fml = (function (exports) {
10178
10487
  //menu is where the localized words live
10179
10488
  this.#button.textContent = this.#display(choice);
10180
10489
  Attributes.set(this.#button, 'aria-label', this.#labelFor(choice));
10490
+ this.#mark();
10491
+ }
10492
+ /**
10493
+ * Marks the item the button currently holds, which is what a menu of one
10494
+ * choice among several owes the reader: the glyph on the button says which
10495
+ * one it is only to somebody who already knows the glyphs. The items are
10496
+ * `menuitemradio`, so the state is `aria-checked` rather than the
10497
+ * `aria-selected` a listbox would use, and the stylesheet draws it off that.
10498
+ */
10499
+ #mark() {
10500
+ const current = this.value;
10501
+ for (const item of this.#items()) {
10502
+ item.setAttribute('aria-checked', String(item.getAttribute('value') === current));
10503
+ }
10181
10504
  }
10182
10505
  /** The host's disabled claim, composed with the pin: lifting one cannot lift the other. */
10183
10506
  set claimed(claimed) {
@@ -10190,7 +10513,9 @@ var fml = (function (exports) {
10190
10513
  const li = document.createElement('li');
10191
10514
  li.setAttribute('role', 'none');
10192
10515
  const a = document.createElement('a');
10193
- a.setAttribute('role', 'menuitem');
10516
+ //one choice among several, which is what a radio item is: the
10517
+ //state belongs on the item, not on the button alone
10518
+ a.setAttribute('role', 'menuitemradio');
10194
10519
  a.setAttribute('tabindex', '-1');
10195
10520
  a.setAttribute('value', choice);
10196
10521
  const word = this.#labelFor(choice);
@@ -10208,6 +10533,7 @@ var fml = (function (exports) {
10208
10533
  return li;
10209
10534
  }),
10210
10535
  );
10536
+ this.#mark();
10211
10537
  }
10212
10538
  #sync() {
10213
10539
  const pinned = this.pinned;
@@ -10228,7 +10554,7 @@ var fml = (function (exports) {
10228
10554
  #wire() {
10229
10555
  const button = this.#button;
10230
10556
  const menu = this.#menu;
10231
- wireAnchoredPopover(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
10557
+ Anchors.wire(button, menu, { prefix: 'ful-filter-menu', invoke: true, expanded: true });
10232
10558
  menu.addEventListener('toggle', (/** @type any */ evt) => {
10233
10559
  if (evt.newState !== 'open') {
10234
10560
  //give the invoker back the focus the menu had borrowed, without
@@ -10315,6 +10641,19 @@ var fml = (function (exports) {
10315
10641
  const sensitivityLabel = (sensitivity) => t(`filters.sensitivity.${sensitivity}`);
10316
10642
  const booleanValueLabel = (token) => t(token === '' ? 'filters.boolean.any' : `filters.boolean.${token}`);
10317
10643
 
10644
+ /**
10645
+ * The operands are the words the reader chose, not the keys the wire carries.
10646
+ * @typedef {{ label: string|null, operator: string|null, operands: string[] }} FilterCriterion
10647
+ */
10648
+
10649
+ const labelTextOf = (filter) => filter.querySelector(':scope > label')?.textContent.trim() || null;
10650
+
10651
+ /** @returns {FilterCriterion|null} */
10652
+ const asCriterion = (label, operator, operands) => {
10653
+ const shown = operands.filter((o) => o !== null && o !== undefined && `${o}` !== '');
10654
+ return shown.length === 0 ? null : { label, operator, operands: shown.map((o) => `${o}`) };
10655
+ };
10656
+
10318
10657
  /**
10319
10658
  * The shared shape of every operator-and-operands filter: an operator menu, one
10320
10659
  * or two operands of the type the subclass declares, and a tuple that mirrors
@@ -10454,6 +10793,18 @@ var fml = (function (exports) {
10454
10793
  _syncBetween() {
10455
10794
  this._value2.toggleAttribute('hidden', this._operator.value !== 'BETWEEN');
10456
10795
  }
10796
+ /**
10797
+ * The operands as shown rather than as serialized: a date filter describes
10798
+ * what is in its control, not the iso string it sends.
10799
+ * @returns {FilterCriterion|null}
10800
+ */
10801
+ get criterion() {
10802
+ const operands =
10803
+ this._operator.value === 'BETWEEN'
10804
+ ? [this._value1.value, this._value2.value]
10805
+ : [this._value1.value];
10806
+ return asCriterion(labelTextOf(this), this._operator.value, operands);
10807
+ }
10457
10808
  get disabled() {
10458
10809
  return super.disabled;
10459
10810
  }
@@ -10689,6 +11040,13 @@ var fml = (function (exports) {
10689
11040
  this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
10690
11041
  this._value.value = v[1] ?? '';
10691
11042
  }
11043
+ /** @returns {FilterCriterion|null} */
11044
+ get criterion() {
11045
+ const token = this._value.value;
11046
+ return asCriterion(labelTextOf(this), this._operator.value, [
11047
+ token === '' ? '' : booleanValueLabel(token),
11048
+ ]);
11049
+ }
10692
11050
  get disabled() {
10693
11051
  return super.disabled;
10694
11052
  }
@@ -10701,6 +11059,37 @@ var fml = (function (exports) {
10701
11059
  }
10702
11060
  }
10703
11061
 
11062
+ /**
11063
+ * Set membership over a select's vocabulary, answering for both of data-jpa's
11064
+ * set annotations: InEnum and InList take the same bare array of values and no
11065
+ * operator, differing only in what the server converts them to.
11066
+ */
11067
+ class InFilter extends Select {
11068
+ async upgrade() {
11069
+ //the snapshot is taken inside super.upgrade(), so an attribute this
11070
+ //element does not let an author turn off has to be written before it
11071
+ this.toggleAttribute('multiple', true);
11072
+ await super.upgrade();
11073
+ }
11074
+ get value() {
11075
+ const keys = super.value;
11076
+ //an empty set is no filter: the table drops a falsy value, where an
11077
+ //empty array would travel and match nothing
11078
+ return keys.length === 0 ? null : keys;
11079
+ }
11080
+ set value(v) {
11081
+ super.value = v ?? [];
11082
+ }
11083
+ /** @returns {FilterCriterion|null} */
11084
+ get criterion() {
11085
+ return asCriterion(
11086
+ labelTextOf(this),
11087
+ null,
11088
+ (super.entry ?? []).map((e) => e.label),
11089
+ );
11090
+ }
11091
+ }
11092
+
10704
11093
  /**
10705
11094
  * The async section machinery shared by ful-tabs, ful-wizard, ful-dialog and
10706
11095
  * ful-drawer: every activation of a section fires the section:requested family
@@ -10809,64 +11198,137 @@ var fml = (function (exports) {
10809
11198
  });
10810
11199
  };
10811
11200
 
10812
- /** An info icon button toggling a popover with a short explanation. */
11201
+ /**
11202
+ * An info icon button toggling a popover with a short explanation.
11203
+ *
11204
+ * The marker is the page's `config.icon`, and the `icon` attribute names a
11205
+ * `ful-icon` for the tooltip that means something other than plain information:
11206
+ * a caveat, a warning, a setting. A name the library does not paint is the
11207
+ * page's own, declared as `ful-icon[name='...'] { mask-image: ... }`.
11208
+ *
11209
+ * `describes` is for the tooltip standing in a field: the note becomes part of
11210
+ * the accessible description of that field's control, so it is announced on
11211
+ * reaching the field rather than only on opening the marker, and the marker
11212
+ * leaves the tab order, so a form of hinted fields costs no extra keystrokes to
11213
+ * walk. The marker stays clickable, and stays a tab stop wherever the note was
11214
+ * not taken, a tooltip claiming `describes` outside a field among them: the
11215
+ * stop only goes where something else delivers the content.
11216
+ */
10813
11217
  class Tooltip extends ParsedElement {
10814
11218
  static slots = true;
10815
- static attributes = ['placement'];
11219
+ static attributes = ['placement', 'icon', 'describes:presence'];
10816
11220
  static config = {
10817
11221
  icon: 'info-circle-fill',
10818
11222
  };
10819
11223
  static template = `
10820
- <button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="config.icon" aria-hidden="true"></ful-icon></button>
11224
+ <button type="button" class="ful-tip" data-ref="trigger" data-tpl-aria-label="#l10n:t('info.tooltip')"><ful-icon data-tpl-name="icon ?? config.icon" aria-hidden="true"></ful-icon></button>
10821
11225
  <ful-note popover data-ref="content">{{{{ slots.default }}}}</ful-note>
10822
11226
  `;
10823
11227
  render({ slots }) {
10824
- const fragment = this.template().withOverlay({ slots }).render();
11228
+ const fragment = this.template().withOverlay({ slots, icon: this.declared('icon') }).render();
10825
11229
  const trigger = fragment.querySelector('[data-ref=trigger]');
10826
11230
  const content = fragment.querySelector('[data-ref=content]');
10827
11231
  //placed here rather than by the anchor css: the note draws a callout that
10828
11232
  //has to point at the trigger wherever the viewport left room for the note,
10829
11233
  //which is a measurement the stylesheet cannot make for a pseudo-element
10830
- wireAnchoredPopover(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
10831
- const placement = this.declared('placement');
10832
- if (placement) {
10833
- content.setAttribute('placement', placement);
10834
- }
11234
+ Anchors.wire(trigger, content, { prefix: 'ful-tooltip', invoke: true, expanded: true, handPlace: true });
11235
+ //above the marker by default: a note opening downwards covers the control
11236
+ //the marker explains, the marker riding the field's label
11237
+ content.setAttribute('placement', this.declared('placement') ?? 'top');
10835
11238
  this.replaceChildren(fragment);
11239
+ if (this.declared('describes')) {
11240
+ Tooltip.#describe(this, trigger, content);
11241
+ }
11242
+ }
11243
+ /**
11244
+ * Offers the note to the field the tooltip stands in, and takes the trigger
11245
+ * out of the tab order only where the offer was accepted: a note nothing
11246
+ * carries is reachable by the keyboard through the marker alone, so
11247
+ * dropping the stop there would leave it reachable by nothing at all.
11248
+ *
11249
+ * The offer goes through the description protocol rather than naming a
11250
+ * field, the library's own arrow running from the forms to the disclosures.
11251
+ */
11252
+ static #describe(tooltip, trigger, content) {
11253
+ if (!describable(tooltip)?.describedBy(content)) {
11254
+ console.warn('a ful-tooltip declares describes but stands in nothing that takes a description', tooltip);
11255
+ return;
11256
+ }
11257
+ trigger.tabIndex = -1;
10836
11258
  }
10837
11259
  }
10838
11260
 
10839
- /** A modal dialog on the native platform, open()/ask() resolving with the closer's data-result. */
11261
+ /**
11262
+ * A modal dialog on the native platform, open()/ask() resolving with the
11263
+ * closer's data-result.
11264
+ *
11265
+ * The header carries a close button, as the drawer's does: Escape dismisses a
11266
+ * modal on its own, but nothing says so, and a dialog whose only exit is a key
11267
+ * you have to know about leaves a pointer with nowhere to go. It answers the way
11268
+ * Escape does, with null.
11269
+ *
11270
+ * `requires-answer` is for the dialog that must be answered: the close button is not
11271
+ * rendered and Escape is refused, so the only way out is a button that carries a
11272
+ * result. It has to be both, a close button withheld while Escape still worked
11273
+ * being decoration rather than a rule.
11274
+ *
11275
+ * The chrome is reachable by class as well as by tag, so a plain `<dialog
11276
+ * class="ful-dialog">` written by a page gets the same look whatever its
11277
+ * structure: the tag form matches a direct child, and `ful-dialog-header`,
11278
+ * `ful-dialog-body` and `ful-dialog-footer` match at any depth, which is what a
11279
+ * dialog whose content is wrapped in a form needs.
11280
+ */
11281
+ /**
11282
+ * How a dialog ended: `dismissed` tells a cancel from an answer, `result` carries
11283
+ * the `data-result` of the button that closed it and `response` what a submit
11284
+ * answered with, the one that did not happen being null.
11285
+ * @typedef {{ dismissed: boolean, result: string|null, response: any }} DialogOutcome
11286
+ */
11287
+
10840
11288
  class Dialog extends ParsedElement {
10841
- static attributes = ['header'];
11289
+ static attributes = ['header', 'requires-answer:presence', 'close-on-submit:presence'];
10842
11290
  static slots = true;
10843
11291
  static template = `
10844
11292
  <dialog data-ref="dialog" class="ful-dialog">
10845
- <header data-tpl-if="header"><h2>{{ header }}</h2></header>
10846
- <div data-ref="body">{{{{ slots.default }}}}</div>
10847
- <footer>
10848
- <button type="button" data-ref="acknowledge" data-result="acknowledged" data-tpl-if="!slots.buttons" data-tpl-aria-label="#l10n:t('dialog.acknowledge')">{{ #l10n:t('dialog.acknowledge') }}</button>
11293
+ <header data-tpl-if="header || slots.header || !requiresAnswer" class="ful-dialog-header">
11294
+ {{{{ slots.header }}}}
11295
+ <h2 data-tpl-if="header">{{ header }}</h2>
11296
+ <button data-tpl-if="!requiresAnswer" type="button" data-ref="close" data-tpl-aria-label="#l10n:t('dialog.close')"><ful-icon name="x-lg" aria-hidden="true"></ful-icon></button>
11297
+ </header>
11298
+ <section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
11299
+ <section data-ref="error" role="alert" hidden></section>
11300
+ <div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
11301
+ <footer class="ful-dialog-footer">
11302
+ <button type="button" data-ref="acknowledge" data-result="acknowledged" data-tpl-if="!slots.buttons && !closeOnSubmit" data-tpl-aria-label="#l10n:t('dialog.acknowledge')">{{ #l10n:t('dialog.acknowledge') }}</button>
10849
11303
  {{{{ slots.buttons }}}}
10850
11304
  </footer>
10851
11305
  </dialog>
10852
11306
  `;
10853
11307
  #dialog;
10854
11308
  #body;
11309
+ #loading;
11310
+ #error;
10855
11311
  #requests = new SectionRequests();
11312
+ #updates = new Claims();
10856
11313
  #resolvers = [];
11314
+ //the answer a submit closed the dialog with, which the return value cannot
11315
+ //carry: it is a string, and a response is whatever the server sent
11316
+ /** @type {DialogOutcome|null} */
11317
+ #answer = null;
10857
11318
  render({ slots }) {
11319
+ const requiresAnswer = this.declared('requires-answer');
11320
+ const closeOnSubmit = this.declared('close-on-submit');
10858
11321
  const fragment = this.template()
10859
- .withOverlay({ slots, header: this.declared('header') ?? '' })
11322
+ .withOverlay({ slots, header: this.declared('header') ?? '', requiresAnswer, closeOnSubmit })
10860
11323
  .render();
10861
11324
  this.#dialog = fragment.querySelector('[data-ref=dialog]');
10862
11325
  this.#body = fragment.querySelector('[data-ref=body]');
11326
+ this.#loading = fragment.querySelector('[data-ref=loading]');
11327
+ this.#error = fragment.querySelector('[data-ref=error]');
10863
11328
  this.#dialog.addEventListener('close', () => {
10864
- this.dispatchEvent(
10865
- new CustomEvent('close', {
10866
- detail: { result: this.#dialog.returnValue === '' ? null : this.#dialog.returnValue },
10867
- }),
10868
- );
10869
- this.#settle();
11329
+ const outcome = this.#outcome();
11330
+ this.dispatchEvent(new CustomEvent('close', { detail: outcome }));
11331
+ this.#settle(outcome);
10870
11332
  });
10871
11333
  this.#dialog.addEventListener('click', (/** @type any */ e) => {
10872
11334
  const result = e.target.closest('button[data-result]')?.dataset.result;
@@ -10874,42 +11336,128 @@ var fml = (function (exports) {
10874
11336
  this.#dialog.close(result);
10875
11337
  }
10876
11338
  });
11339
+ //dismissal, not an answer: the waiters are settled with a dismissal, as
11340
+ //Escape does. Optional because a subclass overriding the template owns
11341
+ //what it renders
11342
+ fragment
11343
+ .querySelector('[data-ref=close]')
11344
+ ?.addEventListener('click', () => this.#dialog.close(''));
11345
+ if (closeOnSubmit) {
11346
+ //delegated on the body rather than bound to the form, so a body
11347
+ //delivered later by update() is covered by the same listener. The
11348
+ //form must be the body's own: a ful-table wraps its filters in a
11349
+ //ful-form of its own, and a search in a table the dialog holds is
11350
+ //not the dialog being answered
11351
+ this.#body.addEventListener('submit:success', (/** @type any */ e) => {
11352
+ if (e.target !== Nodes.queryChildren(this.#body, 'ful-form')) {
11353
+ return;
11354
+ }
11355
+ this.#answer = { dismissed: false, result: null, response: e.detail.response };
11356
+ this.#dialog.close('submitted');
11357
+ });
11358
+ }
11359
+ if (requiresAnswer) {
11360
+ //the platform's own dismissal, refused where the dialog must be
11361
+ //answered: cancel fires for Escape and for a close request the
11362
+ //browser makes on its own, and preventing it leaves the dialog open
11363
+ this.#dialog.addEventListener('cancel', (/** @type any */ e) => e.preventDefault());
11364
+ }
10877
11365
  this.replaceChildren(fragment);
10878
11366
  wireTargets();
10879
11367
  }
10880
- //answers every waiter with the dialog's own answer: null while still open
10881
- //or closed without a result, which is also the unanswered answer a dialog
10882
- //leaving the document owes its waiters instead of hanging them
10883
- #settle() {
11368
+ /**
11369
+ * How the dialog ended, in one shape for every way it can end: `dismissed`
11370
+ * alone tells a cancel from an answer, so a submit answering with no body at
11371
+ * all (a 204) is still an answer, where a bare `null` could not say which it
11372
+ * was. `result` carries the `data-result` of the button that closed it and
11373
+ * `response` what a submit answered with; the one that did not happen is null.
11374
+ */
11375
+ #outcome() {
11376
+ if (this.#answer) {
11377
+ return this.#answer;
11378
+ }
11379
+ //a render that threw adopted no dialog, and a removal still owes its
11380
+ //waiters an answer: reading through it would raise a second, unrelated
11381
+ //failure over the one already reported
11382
+ const result = this.#dialog?.returnValue ?? '';
11383
+ return result === ''
11384
+ ? { dismissed: true, result: null, response: null }
11385
+ : { dismissed: false, result, response: null };
11386
+ }
11387
+ //answers every waiter with the dialog's own answer: a dismissal while still
11388
+ //open or closed without a result, which is also the unanswered answer a
11389
+ //dialog leaving the document owes its waiters instead of hanging them
11390
+ #settle(outcome) {
10884
11391
  const resolvers = this.#resolvers;
10885
11392
  this.#resolvers = [];
10886
11393
  for (const resolve of resolvers) {
10887
- resolve(this.#dialog.returnValue === '' ? null : this.#dialog.returnValue);
11394
+ resolve(outcome);
10888
11395
  }
10889
11396
  }
10890
11397
  disconnectedCallback() {
10891
- this.#settle();
11398
+ this.#settle(this.#outcome());
10892
11399
  }
10893
11400
  open() {
10894
11401
  return this.ask();
10895
11402
  }
10896
11403
  ask() {
10897
- if (!this.#dialog.open) {
10898
- this.#dialog.returnValue = '';
10899
- this.#dialog.showModal();
11404
+ if (this.#show()) {
11405
+ this.#restChrome();
10900
11406
  this.#request();
10901
11407
  }
10902
11408
  return new Promise((resolve) => {
10903
11409
  this.#resolvers.push(resolve);
10904
11410
  });
10905
11411
  }
11412
+ /**
11413
+ * Opens the dialog and waits for the callback, as `ful-drawer`'s does: a
11414
+ * resolved value paints the body (which is returned), a rejection paints the
11415
+ * problems and travels to the caller, and an update superseded by a newer one
11416
+ * paints nothing. The title is the `header` attribute, configuration like the
11417
+ * rest of the dialog's chrome, so what update() owns is the body alone.
11418
+ */
11419
+ async update(cb) {
11420
+ //the claim detaches any update still in flight: its outcome belongs to
11421
+ //an abandoned opening and must neither be painted nor own the dialog
11422
+ const claim = this.#updates.take();
11423
+ this.#body.replaceChildren();
11424
+ this.#restChrome();
11425
+ this.#loading?.removeAttribute('hidden');
11426
+ this.#body.setAttribute('hidden', '');
11427
+ //update owns its own open-answer-deliver cycle, so it shows the dialog
11428
+ //without going through ask(): a user reopen during the wait is a real
11429
+ //open and goes through ask()
11430
+ this.#show();
11431
+ try {
11432
+ const delivered = await cb();
11433
+ if (claim.stale) {
11434
+ return this.#body;
11435
+ }
11436
+ this.#body.replaceChildren(delivered);
11437
+ this.#loading?.setAttribute('hidden', '');
11438
+ this.#body.removeAttribute('hidden');
11439
+ return this.#body;
11440
+ } catch (/** @type any */ e) {
11441
+ if (!claim.stale) {
11442
+ //revealed before it is filled, so the live region announces the
11443
+ //change rather than being revealed already holding it
11444
+ this.#error?.removeAttribute('hidden');
11445
+ if (this.#error) {
11446
+ this.#error.textContent = Failure.problemsText(e);
11447
+ }
11448
+ this.#loading?.setAttribute('hidden', '');
11449
+ this.#body.setAttribute('hidden', '');
11450
+ }
11451
+ throw e;
11452
+ }
11453
+ }
10906
11454
  #request() {
10907
11455
  this.#requests.request(this, this.#body, null, null)?.catch(() => undefined);
10908
11456
  }
10909
11457
  /**
10910
11458
  * Re-fires section:requested on the body, open or closed: the explicit
10911
11459
  * request for a body that wants refreshing. A failed refresh paints its
10912
- * problems, nothing rejects: there is no caller to reject towards.
11460
+ * problems, nothing rejects: update() stays the rejecting call.
10913
11461
  */
10914
11462
  refresh() {
10915
11463
  return this.#requests.request(this, this.#body, null, null)?.then(undefined, () => undefined);
@@ -10917,15 +11465,43 @@ var fml = (function (exports) {
10917
11465
  close(result) {
10918
11466
  this.#dialog.close(result ?? '');
10919
11467
  }
11468
+ /** Shows the modal, answering whether this call is the one that opened it. */
11469
+ #show() {
11470
+ if (this.#dialog.open) {
11471
+ return false;
11472
+ }
11473
+ //an opening owes nothing to the one before it: the platform keeps
11474
+ //returnValue across a close with no result, and the answer a submit
11475
+ //left is just as stale
11476
+ this.#dialog.returnValue = '';
11477
+ this.#answer = null;
11478
+ this.#dialog.showModal();
11479
+ return true;
11480
+ }
11481
+ #restChrome() {
11482
+ this.#error?.replaceChildren();
11483
+ this.#error?.setAttribute('hidden', '');
11484
+ this.#loading?.setAttribute('hidden', '');
11485
+ this.#body?.removeAttribute('hidden');
11486
+ }
10920
11487
  }
10921
11488
 
10922
- /** A side panel drawer on the native dialog platform, update() owning its open-deliver cycle. */
11489
+ /**
11490
+ * A side panel drawer on the native dialog platform, update() owning its
11491
+ * open-deliver cycle.
11492
+ *
11493
+ * The `header` slot is content beside the title, before it: an icon, a badge, a
11494
+ * status. It sits outside the heading rather than in it because `update()` sets
11495
+ * the title through `textContent`, which would take anything nested there with
11496
+ * it.
11497
+ */
10923
11498
  class Drawer extends ParsedElement {
10924
- static attributes = ['title', 'placement'];
11499
+ static attributes = ['title', 'placement', 'close-on-submit:presence'];
10925
11500
  static slots = true;
10926
11501
  static template = `
10927
11502
  <dialog data-ref="dialog" class="ful-drawer">
10928
11503
  <header>
11504
+ {{{{ slots.header }}}}
10929
11505
  <h2 data-ref="title">{{ title }}</h2>
10930
11506
  <button type="button" data-ref="close" data-tpl-aria-label="#l10n:t('drawer.close')"><ful-icon name="x-lg" aria-hidden="true"></ful-icon></button>
10931
11507
  </header>
@@ -10941,6 +11517,10 @@ var fml = (function (exports) {
10941
11517
  #content;
10942
11518
  #requests = new SectionRequests();
10943
11519
  #updates = new Claims();
11520
+ //what a submit closed the drawer with, told from a close of any other kind:
11521
+ //a save answering with no body at all is still a save
11522
+ /** @type {{ dismissed: boolean, response: any }|null} */
11523
+ #answer = null;
10944
11524
  render({ slots }) {
10945
11525
  const fragment = this.template()
10946
11526
  .withOverlay({ slots, title: this.declared('title') ?? '' })
@@ -10956,8 +11536,25 @@ var fml = (function (exports) {
10956
11536
  }
10957
11537
  fragment.querySelector('[data-ref=close]').addEventListener('click', () => this.close());
10958
11538
  this.#dialog.addEventListener('close', () => {
10959
- this.dispatchEvent(new CustomEvent('close'));
11539
+ this.dispatchEvent(
11540
+ new CustomEvent('close', { detail: this.#answer ?? { dismissed: true, response: null } }),
11541
+ );
10960
11542
  });
11543
+ if (this.declared('close-on-submit')) {
11544
+ //delegated on the content section rather than bound to the form: a
11545
+ //drawer's form usually arrives with an update() rather than with the
11546
+ //page, and the section outlives every delivery. The form must be the
11547
+ //content's own, a ful-table wrapping its filters in a ful-form of its
11548
+ //own and a search in a table the drawer holds not being the drawer
11549
+ //finishing
11550
+ this.#content.addEventListener('submit:success', (/** @type any */ e) => {
11551
+ if (e.target !== Nodes.queryChildren(this.#content, 'ful-form')) {
11552
+ return;
11553
+ }
11554
+ this.#answer = { dismissed: false, response: e.detail.response };
11555
+ this.close();
11556
+ });
11557
+ }
10961
11558
  this.replaceChildren(fragment);
10962
11559
  wireTargets();
10963
11560
  }
@@ -11030,6 +11627,9 @@ var fml = (function (exports) {
11030
11627
  if (this.#dialog.open) {
11031
11628
  return false;
11032
11629
  }
11630
+ //an opening owes nothing to the one before it: the answer a submit left
11631
+ //belongs to the drawer that closed on it
11632
+ this.#answer = null;
11033
11633
  this.#dialog.showModal();
11034
11634
  return true;
11035
11635
  }
@@ -11453,6 +12053,7 @@ var fml = (function (exports) {
11453
12053
  'filters.boolean.false': 'No',
11454
12054
  'info.tooltip': 'More information',
11455
12055
  'dialog.acknowledge': 'Got it',
12056
+ 'dialog.close': 'Close',
11456
12057
  'drawer.close': 'Close',
11457
12058
  'spinner.loading': 'Loading…',
11458
12059
  'toast.region': 'Notifications',
@@ -11494,6 +12095,7 @@ var fml = (function (exports) {
11494
12095
  'filters.boolean.false': 'No',
11495
12096
  'info.tooltip': 'Maggiori informazioni',
11496
12097
  'dialog.acknowledge': 'Ho capito',
12098
+ 'dialog.close': 'Chiudi',
11497
12099
  'drawer.close': 'Chiudi',
11498
12100
  'spinner.loading': 'Caricamento…',
11499
12101
  'toast.region': 'Notifiche',
@@ -11535,6 +12137,7 @@ var fml = (function (exports) {
11535
12137
  'filters.boolean.false': 'No',
11536
12138
  'info.tooltip': 'Más información',
11537
12139
  'dialog.acknowledge': 'Entendido',
12140
+ 'dialog.close': 'Cerrar',
11538
12141
  'drawer.close': 'Cerrar',
11539
12142
  'spinner.loading': 'Cargando…',
11540
12143
  'toast.region': 'Notificaciones',
@@ -11579,6 +12182,7 @@ var fml = (function (exports) {
11579
12182
  'filters.boolean.false': 'Non',
11580
12183
  'info.tooltip': 'Plus d’informations',
11581
12184
  'dialog.acknowledge': 'J’ai compris',
12185
+ 'dialog.close': 'Fermer',
11582
12186
  'drawer.close': 'Fermer',
11583
12187
  'spinner.loading': 'Chargement…',
11584
12188
  'toast.region': 'Notifications',
@@ -11649,6 +12253,7 @@ var fml = (function (exports) {
11649
12253
  .defineElement('ful-filter-number', NumberFilter)
11650
12254
  .defineElement('ful-filter-boolean', BooleanFilter)
11651
12255
  .defineElement('ful-filter-text', TextFilter)
12256
+ .defineElement('ful-filter-in', InFilter)
11652
12257
  .defineElement('ful-select', Select)
11653
12258
  .defineElement('ful-dropdown', Dropdown)
11654
12259
  .defineComponent('loaders:select', SelectLoader)
@@ -11668,6 +12273,7 @@ var fml = (function (exports) {
11668
12273
  var ful = /*#__PURE__*/Object.freeze({
11669
12274
  __proto__: null,
11670
12275
  Accordion: Accordion,
12276
+ Anchors: Anchors,
11671
12277
  AsyncEvents: AsyncEvents,
11672
12278
  Bindings: Bindings,
11673
12279
  BooleanFilter: BooleanFilter,
@@ -11680,6 +12286,7 @@ var fml = (function (exports) {
11680
12286
  Field: Field,
11681
12287
  Form: Form,
11682
12288
  FormLoader: FormLoader,
12289
+ InFilter: InFilter,
11683
12290
  Input: Input,
11684
12291
  InputFile: InputFile,
11685
12292
  InputInstant: InputInstant,
@@ -11708,7 +12315,8 @@ var fml = (function (exports) {
11708
12315
  Tooltip: Tooltip,
11709
12316
  VersionedLocalStorage: VersionedLocalStorage,
11710
12317
  VersionedSessionStorage: VersionedSessionStorage,
11711
- Wizard: Wizard
12318
+ Wizard: Wizard,
12319
+ describable: describable
11712
12320
  });
11713
12321
 
11714
12322
  if (typeof window !== 'undefined') {
@@ -11720,6 +12328,7 @@ var fml = (function (exports) {
11720
12328
  }
11721
12329
 
11722
12330
  exports.Accordion = Accordion;
12331
+ exports.Anchors = Anchors;
11723
12332
  exports.AsyncEvents = AsyncEvents;
11724
12333
  exports.Attributes = Attributes;
11725
12334
  exports.Base64 = Base64;
@@ -11746,6 +12355,7 @@ var fml = (function (exports) {
11746
12355
  exports.HttpInterceptorChain = HttpInterceptorChain;
11747
12356
  exports.HttpMultipartRequestCustomizer = HttpMultipartRequestCustomizer;
11748
12357
  exports.HttpRequestBuilder = HttpRequestBuilder;
12358
+ exports.InFilter = InFilter;
11749
12359
  exports.Input = Input;
11750
12360
  exports.InputFile = InputFile;
11751
12361
  exports.InputInstant = InputInstant;
@@ -11785,6 +12395,7 @@ var fml = (function (exports) {
11785
12395
  exports.VersionedLocalStorage = VersionedLocalStorage;
11786
12396
  exports.VersionedSessionStorage = VersionedSessionStorage;
11787
12397
  exports.Wizard = Wizard;
12398
+ exports.describable = describable;
11788
12399
  exports.registry = registry;
11789
12400
 
11790
12401
  return exports;