@optionfactory/fml 9.0.0-rc27 → 9.0.0-rc29

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.
Files changed (43) hide show
  1. package/dist/client-errors.iife.js +21 -16
  2. package/dist/client-errors.iife.js.map +1 -1
  3. package/dist/client-errors.iife.min.js +1 -1
  4. package/dist/client-errors.iife.min.js.map +1 -1
  5. package/dist/custom-elements.json +16 -16
  6. package/dist/fml.css +13 -13
  7. package/dist/fml.css.map +1 -1
  8. package/dist/fml.iife.js +871 -883
  9. package/dist/fml.iife.js.map +1 -1
  10. package/dist/fml.iife.min.js +1 -1
  11. package/dist/fml.iife.min.js.map +1 -1
  12. package/dist/ftl.d.mts +3 -3
  13. package/dist/ftl.iife.js +275 -679
  14. package/dist/ftl.iife.js.map +1 -1
  15. package/dist/ftl.iife.min.js +1 -1
  16. package/dist/ftl.iife.min.js.map +1 -1
  17. package/dist/ftl.min.mjs +1 -1
  18. package/dist/ftl.min.mjs.map +1 -1
  19. package/dist/ftl.mjs +275 -679
  20. package/dist/ftl.mjs.map +1 -1
  21. package/dist/ful.css +13 -13
  22. package/dist/ful.css.map +1 -1
  23. package/dist/ful.d.mts +195 -71
  24. package/dist/ful.iife.js +564 -189
  25. package/dist/ful.iife.js.map +1 -1
  26. package/dist/ful.iife.min.js +1 -1
  27. package/dist/ful.iife.min.js.map +1 -1
  28. package/dist/ful.min.mjs +1 -1
  29. package/dist/ful.min.mjs.map +1 -1
  30. package/dist/ful.mjs +564 -189
  31. package/dist/ful.mjs.map +1 -1
  32. package/dist/httpc.d.mts +9 -7
  33. package/dist/httpc.iife.js +32 -15
  34. package/dist/httpc.iife.js.map +1 -1
  35. package/dist/httpc.iife.min.js +1 -1
  36. package/dist/httpc.iife.min.js.map +1 -1
  37. package/dist/httpc.min.mjs +1 -1
  38. package/dist/httpc.min.mjs.map +1 -1
  39. package/dist/httpc.mjs +32 -15
  40. package/dist/httpc.mjs.map +1 -1
  41. package/dist/vscode.html-custom-data.json +13 -13
  42. package/dist/web-types.json +17 -17
  43. package/package.json +2 -2
package/dist/ful.iife.js CHANGED
@@ -691,7 +691,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
691
691
  * alone and a composite field named for a prefix catches the problems of its
692
692
  * subtree. Each control of that name gets `setCustomValidity(reason, rest)`,
693
693
  * `rest` being the remainder of the path (`''` on an exact match) that a
694
- * composite can use to route the problem to an inner control.
694
+ * composite can use to route the problem to an inner control. Several
695
+ * problems reaching one control with the same `rest` are joined one per
696
+ * line into a single call.
695
697
  *
696
698
  * A control counts only when it can show a problem, through a
697
699
  * `setCustomValidity` of its own: a form-associated element that has none is
@@ -706,7 +708,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
706
708
  * Each `ful-field-error` of the form gets `aria-live="off"` when
707
709
  * `scrollOnError` is set, since focusing the field announces its error, and
708
710
  * `aria-live="polite"` otherwise. With `scrollOnError` and at least one
709
- * problem, the topmost `:invalid` control is focused.
711
+ * problem, the topmost `:invalid` control is focused; a `<fieldset>`
712
+ * matching `:invalid` for the controls it holds is not a candidate.
710
713
  *
711
714
  * A banner or `ful-field-error` belongs to the nearest enclosing form, so
712
715
  * those of a nested form are left alone.
@@ -731,6 +734,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
731
734
  });
732
735
  }
733
736
  const unmatched = [];
737
+ /** @type {Map<any, Map<string, string[]>>} */
738
+ const pins = new Map();
734
739
  fieldErrors.forEach((e) => {
735
740
  const name = /** @type {string} */ (e.context).replace(/\[/g, '.').replace(/\]\./g, '.').replace(/\]/g, '');
736
741
  const parts = name.split('.');
@@ -742,12 +747,19 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
742
747
  }
743
748
  const context = parts.slice(i).join('.');
744
749
  targets.forEach((input) => {
745
- input.setCustomValidity?.(e.reason, context);
750
+ const contexts = pins.get(input) ?? new Map();
751
+ contexts.set(context, [...(contexts.get(context) ?? []), e.reason]);
752
+ pins.set(input, contexts);
746
753
  });
747
754
  return;
748
755
  }
749
756
  unmatched.push(e);
750
757
  });
758
+ for (const [input, contexts] of pins) {
759
+ for (const [context, reasons] of contexts) {
760
+ input.setCustomValidity(reasons.join('\n'), context);
761
+ }
762
+ }
751
763
  const bannered = [...globalErrors, ...unmatched];
752
764
  form.querySelectorAll('ful-errors').forEach((el) => {
753
765
  if (!ofForm(el)) {
@@ -767,7 +779,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
767
779
  return;
768
780
  }
769
781
  /** @type {HTMLElement[]} */ (Array.from(form.elements))
770
- .filter((el) => el.matches(':invalid'))
782
+ .filter((el) => el.localName !== 'fieldset' && el.matches(':invalid'))
771
783
  .sort((a, b) => a.getBoundingClientRect().y - b.getBoundingClientRect().y)[0]
772
784
  ?.focus();
773
785
  }
@@ -786,9 +798,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
786
798
  *
787
799
  * The claim setters, the validity protocol and the aria wiring all act on the
788
800
  * pieces, so a field with no native control returns a focusable piece of its
789
- * own chrome as the control. The claim getters and `focus()` are the only
790
- * members that tolerate a not yet rendered element; the other members need the
791
- * pieces and so work only after the render.
801
+ * own chrome as the control. The claims, `value` and `focus()` are the
802
+ * members that tolerate a not yet rendered element: before the render the
803
+ * `value` getter answers what the rendered field would answer from its current
804
+ * declared state, and a value written then is held and applied by the render
805
+ * (see `_holdValue`). The other members need the pieces and so work only after
806
+ * the render.
792
807
  *
793
808
  * The inner controls carry `form=""`, so the host is the only element the form
794
809
  * sees. Enter pressed in an inner `<input>` of any type but `file`, `button`,
@@ -824,6 +839,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
824
839
  #claims;
825
840
  #announces;
826
841
  #also = [];
842
+ /** @type {{ value: any } | null} the value written before the render, which the render applies */
843
+ #held = null;
827
844
  /** Sets the internals' role from the subclass's `static ROLE`. */
828
845
  constructor() {
829
846
  super();
@@ -1057,11 +1074,47 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1057
1074
  _interactive() {
1058
1075
  return !this.matches(':disabled') && !this.readonly;
1059
1076
  }
1077
+ /**
1078
+ * Upgrades as `ParsedElement.upgrade` does, then writes the value held by
1079
+ * `_holdValue`, if any, through the `value` setter: the value written
1080
+ * before the render wins over the declared one.
1081
+ * @returns {Promise<void>} rejecting with what the render threw
1082
+ */
1083
+ async upgrade() {
1084
+ await super.upgrade();
1085
+ if (this.#held === null) {
1086
+ return;
1087
+ }
1088
+ const { value } = this.#held;
1089
+ this.#held = null;
1090
+ this.value = value;
1091
+ }
1092
+ /**
1093
+ * Keeps a value written before the field has its control, for a subclass's
1094
+ * `value` setter to call in place of writing it: the upgrade writes it
1095
+ * through the setter once the field has rendered, over the declared one.
1096
+ * The `value` attribute is left alone, so a reset still restores it.
1097
+ * @param {any} value
1098
+ */
1099
+ _holdValue(value) {
1100
+ this.#held = { value };
1101
+ }
1102
+ /**
1103
+ * The value a subclass's `value` getter shapes before the field has its
1104
+ * control: the one `_holdValue` kept, else the declared `value` attribute
1105
+ * read through its mapper.
1106
+ * @returns {any}
1107
+ */
1108
+ _heldValue() {
1109
+ return this.#held === null ? this.declared('value') : this.#held.value;
1110
+ }
1060
1111
  /**
1061
1112
  * The field's value, `null` when empty. Every concrete field overrides the
1062
1113
  * pair with its own semantics; the form extraction, `values =` assignment,
1063
1114
  * the `change` detail and `formResetCallback` all go through it. The base
1064
- * pair reads `undefined` and ignores writes.
1115
+ * pair reads `undefined` and ignores writes. A concrete field answers and
1116
+ * takes its value before the render too, through `_heldValue` and
1117
+ * `_holdValue`.
1065
1118
  * @type {any}
1066
1119
  */
1067
1120
  get value() {
@@ -1430,6 +1483,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1430
1483
  this.addEventListener('input', (evt) => this.#touch(evt.target));
1431
1484
  this.addEventListener('change', (evt) => this.#touch(evt.target));
1432
1485
  this.addEventListener('focusin', (evt) => this.#focus(evt.target));
1486
+ this.form.addEventListener('reset', (evt) => {
1487
+ if (evt.target !== this.form) {
1488
+ return;
1489
+ }
1490
+ for (const name of new Set([...this.#saved.keys(), ...this.#snapshot().keys()])) {
1491
+ this.#edits.set(name, ++this.#sequence);
1492
+ }
1493
+ // the controls are restored only after the event, so the state is read at the next frame
1494
+ if (this.#frame === 0) {
1495
+ this.#frame = requestAnimationFrame(() => this.#refresh());
1496
+ }
1497
+ });
1433
1498
  index_mjs.Rendering.waitForChildren(this)
1434
1499
  .catch(() => undefined)
1435
1500
  .then(() => this.commit());
@@ -1447,7 +1512,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1447
1512
  }
1448
1513
  #submitting = false;
1449
1514
  /**
1450
- * Runs the submit pipeline, with the spinner up for its whole length:
1515
+ * Runs the submit pipeline, with the spinner up until its outcome:
1451
1516
  *
1452
1517
  * 1. builds the loader: the component the `loader` attribute names
1453
1518
  * (`loaders:form` by default), called as `create(form)`
@@ -1464,8 +1529,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1464
1529
  * 6. has the loader `submit` the request and `transform` the response
1465
1530
  * 7. under `track-changes`, commits the values extracted at step 2 (see
1466
1531
  * `commit`): an edit made while the submit was in flight stays unsaved
1467
- * 8. fires `submit:success` with `{ submitter, values, request, response }`,
1468
- * `response` being the transformed one
1532
+ * 8. releases the spinner and fires `submit:success` with `{ submitter,
1533
+ * values, request, response }`, `response` being the transformed one
1469
1534
  *
1470
1535
  * Anything thrown from step 1 on, a missing loader or a throwing mapper
1471
1536
  * included, fires `submit:failure` with `{ submitter, values, request,
@@ -1474,6 +1539,11 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1474
1539
  * `console.warn`. The `values` of both outcome events are the extracted
1475
1540
  * ones, not a replacement a `submit` listener put on its detail.
1476
1541
  *
1542
+ * The form settles before either outcome event (0016): its listeners see
1543
+ * the spinner released and the buttons no longer held off, the problems of
1544
+ * a `Failure` already shown, and the submit no longer in flight, so one
1545
+ * of them may submit again.
1546
+ *
1477
1547
  * A call while a submit is in flight returns at once, doing nothing.
1478
1548
  * @param {HTMLElement} [submitter] the button that submitted, if any; it is
1479
1549
  * the only button whose name and value are submitted
@@ -1489,6 +1559,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1489
1559
  this.spinner(true);
1490
1560
  let values;
1491
1561
  let request;
1562
+ let outcome;
1492
1563
  try {
1493
1564
  const loader = this.component(this.declared('loader') ?? 'loaders:form').create(this);
1494
1565
  values = Bindings.extractFrom(this.form, submitter);
@@ -1516,32 +1587,28 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1516
1587
  if (submitted) {
1517
1588
  this.#commitFrom(submitted.saved, submitted.upTo);
1518
1589
  }
1519
- this.dispatchEvent(
1520
- new CustomEvent('submit:success', {
1521
- bubbles: true,
1522
- cancelable: false,
1523
- detail: { submitter, values, request, response: mapped },
1524
- }),
1525
- );
1526
- return true;
1590
+ outcome = new CustomEvent('submit:success', {
1591
+ bubbles: true,
1592
+ cancelable: false,
1593
+ detail: { submitter, values, request, response: mapped },
1594
+ });
1527
1595
  } catch (e) {
1528
- this.dispatchEvent(
1529
- new CustomEvent('submit:failure', {
1530
- bubbles: true,
1531
- cancelable: false,
1532
- detail: { submitter, values, request, exception: e },
1533
- }),
1534
- );
1596
+ outcome = new CustomEvent('submit:failure', {
1597
+ bubbles: true,
1598
+ cancelable: false,
1599
+ detail: { submitter, values, request, exception: e },
1600
+ });
1535
1601
  if (e instanceof index_mjs$1.Failure) {
1536
1602
  this.errors = e.problems;
1537
1603
  } else {
1538
1604
  console.warn('failed to submit form', this, 'reason:', e);
1539
1605
  }
1540
- return false;
1541
1606
  } finally {
1542
1607
  this.#submitting = false;
1543
1608
  this.spinner(false);
1544
1609
  }
1610
+ this.dispatchEvent(outcome);
1611
+ return outcome.type === 'submit:success';
1545
1612
  }
1546
1613
  /**
1547
1614
  * Saves the unsaved edits, for a page action that would lose them, such as
@@ -1560,18 +1627,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1560
1627
  }
1561
1628
  /**
1562
1629
  * Resets the native form, each field returning to its default value. Under
1563
- * `track-changes` every control counts as edited: the form is dirty if the
1564
- * defaults differ from what was last committed.
1630
+ * `track-changes` a reset, this one or a native reset button's, counts
1631
+ * every control as edited: the form is dirty if the defaults differ from
1632
+ * what was last committed.
1565
1633
  */
1566
1634
  reset() {
1567
1635
  this.form.reset();
1568
- if (!this.#tracking) {
1569
- return;
1570
- }
1571
- for (const name of new Set([...this.#saved.keys(), ...this.#snapshot().keys()])) {
1572
- this.#edits.set(name, ++this.#sequence);
1636
+ if (this.#tracking) {
1637
+ this.#refresh();
1573
1638
  }
1574
- this.#refresh();
1575
1639
  }
1576
1640
  /** name → the serialized values the last commit recorded */
1577
1641
  #saved = new Map();
@@ -1996,6 +2060,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
1996
2060
  _input;
1997
2061
  /** @type {(HTMLInputElement|HTMLTextAreaElement)[]} */
1998
2062
  #controls = [];
2063
+ /**
2064
+ * The serialized value a date or time control last reported, or held when
2065
+ * focused or written.
2066
+ * @type {string|null}
2067
+ */
2068
+ #committed = null;
1999
2069
  /**
2000
2070
  * The type the field renders, which a subclass overrides to wrap another
2001
2071
  * native type. The base answers the declared `type`, else `number` under
@@ -2075,20 +2145,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2075
2145
  });
2076
2146
  return;
2077
2147
  }
2078
- /** @type {string|null} */
2079
- let committed = null;
2080
2148
  let typed = false;
2081
2149
  const commit = () => {
2082
2150
  typed = false;
2083
2151
  const now = JSON.stringify(this.value);
2084
- if (now === committed) {
2152
+ if (now === this.#committed) {
2085
2153
  return;
2086
2154
  }
2087
- committed = now;
2155
+ this.#committed = now;
2088
2156
  this._notifyChange();
2089
2157
  };
2090
2158
  control.addEventListener('focus', () => {
2091
- committed = JSON.stringify(this.value);
2159
+ this.#committed = JSON.stringify(this.value);
2092
2160
  });
2093
2161
  control.addEventListener('keydown', (/** @type any */ evt) => {
2094
2162
  if (evt.key === 'Enter' && !evt.isComposing) {
@@ -2112,14 +2180,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2112
2180
  * `v-type="number"`, where text that does not decode is answered as it is.
2113
2181
  * Writing `null`, `undefined` or `''` empties the control; anything else is
2114
2182
  * written as its string, replacing whatever was being typed, as a native
2115
- * control's value does. Answers a `string`, a `number` or `null`; typed
2116
- * `any` because subclasses answer other values.
2183
+ * control's value does, and is not an edit: a date or time control that
2184
+ * has the focus reports no `change` for it when it is left. Answers a
2185
+ * `string`, a `number` or `null`; typed `any` because subclasses answer
2186
+ * other values. Before the render the same shaping reads the value last
2187
+ * written, or the `value` attribute, as its text, and a write is held for
2188
+ * the render to apply.
2117
2189
  * @type {any}
2118
2190
  */
2119
2191
  get value() {
2120
2192
  const uppercase = this.declared('uppercase');
2121
2193
  const trim = this.declared('trim');
2122
- const v = this._input.value;
2194
+ const v = this._input ? this._input.value : `${this._heldValue() ?? ''}`;
2123
2195
  const uppercased = uppercase ? v.toUpperCase() : v;
2124
2196
  const trimmed = trim ? uppercased.trim() : uppercased;
2125
2197
  if (trimmed === '') {
@@ -2133,7 +2205,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2133
2205
  return normalized;
2134
2206
  }
2135
2207
  set value(value) {
2208
+ if (!this._input) {
2209
+ this._holdValue(value);
2210
+ return;
2211
+ }
2136
2212
  this._input.value = value === '' || value === undefined || value === null ? '' : `${value}`;
2213
+ this.#committed = JSON.stringify(this.value);
2137
2214
  }
2138
2215
  /**
2139
2216
  * The control's placeholder, `null` when there is none. The control always
@@ -2156,6 +2233,20 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2156
2233
 
2157
2234
  const blankAsNull = (v) => (v === '' ? null : v);
2158
2235
 
2236
+ /**
2237
+ * The local midnight of the yyyy-mm-dd date a text names, taking a year below
2238
+ * 100 as it is and refusing a day its month does not have.
2239
+ * @param {string} text
2240
+ * @returns {Date} an invalid date when the text names no date
2241
+ */
2242
+ const localMidnight = (text) => {
2243
+ const [y, m, d] = text.split('-').map(Number);
2244
+ const date = new Date(0);
2245
+ date.setFullYear(y, m - 1, d);
2246
+ date.setHours(0, 0, 0, 0);
2247
+ return date.getFullYear() === y && date.getMonth() === m - 1 && date.getDate() === d ? date : new Date(Number.NaN);
2248
+ };
2249
+
2159
2250
  /**
2160
2251
  * Formats the yyyy-mm-dd date in its content as a numeric date, in the locale
2161
2252
  * its `locale` attribute names, else the page's, else the platform default.
@@ -2165,10 +2256,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2165
2256
  class LocalDate extends index_mjs.ParsedElement {
2166
2257
  static attributes = ['locale', 'default'];
2167
2258
  render() {
2168
- const content = this.textContent.trim();
2169
- const [y, m, d] = content.split('-').map(Number);
2170
- const parsed = content === '' ? null : new Date(y, m - 1, d);
2171
- if (parsed === null || Number.isNaN(parsed.getTime())) {
2259
+ const parsed = localMidnight(this.textContent.trim());
2260
+ if (Number.isNaN(parsed.getTime())) {
2172
2261
  this.replaceChildren(this.declared('default') ?? '');
2173
2262
  return;
2174
2263
  }
@@ -2207,7 +2296,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2207
2296
  );
2208
2297
  }
2209
2298
  static #parse(v) {
2210
- return /^\d{4}-\d{2}-\d{2}$/.test(v) ? new Date(`${v}T00:00:00`) : new Date(v);
2299
+ return /^\d{4}-\d{2}-\d{2}$/.test(v) ? localMidnight(v) : new Date(v);
2211
2300
  }
2212
2301
  /**
2213
2302
  * Converts an ISO instant to the `yyyy-mm-ddThh:mm:ss.sss` wall clock time
@@ -2394,14 +2483,24 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2394
2483
  return 'datetime-local';
2395
2484
  }
2396
2485
  /**
2397
- * The entered time as a UTC ISO instant.
2486
+ * The entered time as a UTC ISO instant. Before the render it answers the
2487
+ * value last written, or the `value` attribute, as the rendered field
2488
+ * would, and a write is held for the render to apply.
2398
2489
  * @returns {string|null} null when empty or when the input holds text that does not parse
2399
2490
  */
2400
2491
  get value() {
2492
+ if (!this._input) {
2493
+ const held = this._heldValue();
2494
+ return Instant.localToIso(held ? Instant.isoToLocal(held) : '');
2495
+ }
2401
2496
  return Instant.localToIso(this._input.value);
2402
2497
  }
2403
2498
  /** @param {string|null|undefined} v an ISO instant; a falsy value empties the input */
2404
2499
  set value(v) {
2500
+ if (!this._input) {
2501
+ this._holdValue(v);
2502
+ return;
2503
+ }
2405
2504
  super.value = v ? Instant.isoToLocal(v) : '';
2406
2505
  }
2407
2506
  /**
@@ -2581,9 +2680,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2581
2680
  this.files = InputFile.list(files);
2582
2681
  this._notifyChange();
2583
2682
  });
2584
- this._input.addEventListener('change', (e) => {
2585
- this.#update();
2586
- });
2683
+ // capture runs before the base listener notifying change, so the event carries the constrained files
2684
+ this._input.addEventListener(
2685
+ 'change',
2686
+ () => {
2687
+ this.#update();
2688
+ },
2689
+ { capture: true },
2690
+ );
2587
2691
  return { ...pieces, freeze: this.#group };
2588
2692
  }
2589
2693
  #update() {
@@ -2690,7 +2794,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2690
2794
  * @returns {boolean}
2691
2795
  */
2692
2796
  get multiple() {
2693
- return this._input.multiple;
2797
+ return this._input ? this._input.multiple : this.declared('multiple');
2694
2798
  }
2695
2799
  /** @param {boolean} v */
2696
2800
  set multiple(v) {
@@ -2698,11 +2802,11 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2698
2802
  this.reflectTo('multiple', v);
2699
2803
  }
2700
2804
  /**
2701
- * The selected files, after the constraints.
2805
+ * The selected files, after the constraints; none before the render.
2702
2806
  * @returns {FileList}
2703
2807
  */
2704
2808
  get files() {
2705
- return this._input.files;
2809
+ return this._input ? this._input.files : InputFile.list();
2706
2810
  }
2707
2811
  /**
2708
2812
  * Replaces the selection and runs the constraints over it, refreshing the
@@ -2729,7 +2833,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2729
2833
  }
2730
2834
  /**
2731
2835
  * The names of the selected files: an array when `multiple`, otherwise the
2732
- * one name or null.
2836
+ * one name or null. Before the render no file is selected yet.
2733
2837
  * @returns {string[]|string|null}
2734
2838
  */
2735
2839
  get value() {
@@ -2738,11 +2842,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
2738
2842
  }
2739
2843
  /**
2740
2844
  * Empties the selection for a falsy value and ignores any other, since a
2741
- * file name cannot select a file.
2845
+ * file name cannot select a file. Before the render there is nothing to
2846
+ * empty, so it does nothing.
2742
2847
  * @param {any} v
2743
2848
  */
2744
2849
  set value(v) {
2745
- if (v) {
2850
+ if (v || !this._input) {
2746
2851
  return;
2747
2852
  }
2748
2853
  this.files = InputFile.list();
@@ -3026,7 +3131,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3026
3131
  * which light dismiss then closes with no script of your own. The popover
3027
3132
  * is given the generated id when it has none. A `button` or `input`
3028
3133
  * invoker gets a `popovertarget` naming it; any other element toggles it
3029
- * on click and on the Enter and Space keys
3134
+ * on click and on the Enter and Space keys; a click on it while the
3135
+ * popover is open closes the popover rather than opening it again
3030
3136
  * @param {boolean} [options.expanded] sets the invoker's `aria-expanded` to
3031
3137
  * `false` at once and keeps it in step with the popover's `toggle` events
3032
3138
  * @param {boolean} [options.stretch] in the script placement, sizes the
@@ -3050,7 +3156,21 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3050
3156
  if (invoker.localName === 'button' || invoker.localName === 'input') {
3051
3157
  invoker.setAttribute('popovertarget', popover.id);
3052
3158
  } else {
3053
- invoker.addEventListener('click', () => popover.togglePopover());
3159
+ let pressedOpen = false;
3160
+ invoker.addEventListener(
3161
+ 'pointerdown',
3162
+ () => {
3163
+ pressedOpen = popover.matches(':popover-open');
3164
+ },
3165
+ true,
3166
+ );
3167
+ invoker.addEventListener('click', () => {
3168
+ const dismissed = pressedOpen && !popover.matches(':popover-open');
3169
+ pressedOpen = false;
3170
+ if (!dismissed) {
3171
+ popover.togglePopover();
3172
+ }
3173
+ });
3054
3174
  invoker.addEventListener('keydown', (/** @type any */ evt) => {
3055
3175
  if (evt.key !== 'Enter' && evt.key !== ' ') {
3056
3176
  return;
@@ -3152,7 +3272,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3152
3272
  * @typedef {{ request(method: string, url: string): any }} SelectHttpClient
3153
3273
  */
3154
3274
 
3155
- const byKeys = (entries, keys) => entries.filter(({ key }) => keys.some((r) => r == key));
3275
+ const byKeys = (entries, keys) => entries.filter(({ key }) => keys.some((r) => String(r) === String(key)));
3156
3276
  const byLabel = (entries, needle) =>
3157
3277
  entries.filter(({ label }) => (label ?? '').toLowerCase().includes(needle?.toLowerCase() ?? ''));
3158
3278
 
@@ -3197,7 +3317,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3197
3317
  await this.#ensureFetched();
3198
3318
  }
3199
3319
  /**
3200
- * The entries whose key loosely equals one of the keys, in vocabulary order.
3320
+ * The entries whose key reads as the same text as one of the keys, in
3321
+ * vocabulary order.
3201
3322
  * @param {...any} keys
3202
3323
  * @returns {Promise<SelectEntry[]>}
3203
3324
  * @throws when the fetch fails, or when an invalidation supersedes it
@@ -3339,7 +3460,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3339
3460
  */
3340
3461
  async invalidate() {}
3341
3462
  /**
3342
- * The entries whose key loosely equals one of the keys, in vocabulary order.
3463
+ * The entries whose key reads as the same text as one of the keys, in
3464
+ * vocabulary order.
3343
3465
  * @param {...any} keys
3344
3466
  * @returns {SelectEntry[]}
3345
3467
  */
@@ -3558,11 +3680,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3558
3680
  this.replaceChildren(fragment);
3559
3681
  }
3560
3682
  #selected() {
3561
- return (
3562
- this.#menu?.querySelector('[selected]:not([aria-disabled="true"])') ??
3563
- this.#menu?.querySelector('li:not([aria-disabled="true"])') ??
3564
- null
3565
- );
3683
+ if (!this.#menu || this.#menu.hidden) {
3684
+ return null;
3685
+ }
3686
+ return this.#menu.querySelector('li[selected]') ?? this.#menu.querySelector('li:not([aria-disabled="true"])');
3566
3687
  }
3567
3688
  #highlight(li) {
3568
3689
  if (!li) {
@@ -3581,11 +3702,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3581
3702
  }
3582
3703
  /**
3583
3704
  * Picks the highlighted row, or the first enabled one when none is: hides
3584
- * the dropdown and dispatches `change`. Does nothing when no row is enabled.
3705
+ * the dropdown and dispatches `change`. Does nothing when the highlighted
3706
+ * row is disabled, when no row is enabled, or while a search is loading
3707
+ * and the rows are hidden.
3585
3708
  */
3586
3709
  acceptSelection() {
3587
3710
  const selected = this.#selected();
3588
- if (!selected) {
3711
+ if (!selected || selected.matches('[aria-disabled="true"]')) {
3589
3712
  return;
3590
3713
  }
3591
3714
  this.#change(selected);
@@ -3594,7 +3717,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3594
3717
  * Renders the entries as rows and highlights the first one whose key is
3595
3718
  * picked, else the row the template marked `selected`, else the first
3596
3719
  * enabled row. A row is marked `picked` and
3597
- * `aria-selected="true"` when its key loosely equals one of the keys, and
3720
+ * `aria-selected="true"` when its key reads as the same text as one of the keys, and
3598
3721
  * `aria-disabled="true"` when its `metadata.disabled` is truthy, titled with
3599
3722
  * `metadata.reason`. No entries shows the empty state instead of the menu.
3600
3723
  * @param {SelectEntry[]} values
@@ -3610,7 +3733,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3610
3733
  this.#optionstemplate.withOverlay(data).renderTo(this.#menu);
3611
3734
  for (const [index, li] of [...this.#menu.children].entries()) {
3612
3735
  const entry = values[index];
3613
- const picked = keys.some((r) => r == entry?.key);
3736
+ const picked = keys.some((r) => String(r) === String(entry?.key));
3614
3737
  li.toggleAttribute('picked', picked);
3615
3738
  li.setAttribute('aria-selected', picked ? 'true' : 'false');
3616
3739
  const disabled = !!entry?.metadata?.disabled;
@@ -3627,8 +3750,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3627
3750
  }
3628
3751
  this.#empty.toggleAttribute('hidden', values.length !== 0);
3629
3752
  this.#menu.toggleAttribute('hidden', values.length === 0);
3630
- const current = values.findIndex(({ key }) => keys.some((r) => r == key));
3631
- this.#highlight(current >= 0 ? this.#menu.children[current] : this.#selected());
3753
+ const current = values.findIndex(({ key }) => keys.some((r) => String(r) === String(key)));
3754
+ this.#highlight(
3755
+ current >= 0
3756
+ ? this.#menu.children[current]
3757
+ : (this.#menu.querySelector('li[selected]:not([aria-disabled="true"])') ??
3758
+ this.#menu.querySelector('li:not([aria-disabled="true"])')),
3759
+ );
3632
3760
  }
3633
3761
  #change(target) {
3634
3762
  const index = target.getAttribute('value');
@@ -3702,7 +3830,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3702
3830
  }
3703
3831
  /**
3704
3832
  * Moves the highlight to the next or previous enabled row when the dropdown
3705
- * is shown, staying put at the edge, and shows it otherwise.
3833
+ * is shown, staying put at the edge and while a search is loading, and
3834
+ * shows it otherwise.
3706
3835
  * @param {boolean} forward
3707
3836
  * @param {() => SelectEntry[]|Promise<SelectEntry[]>} loader used only to show
3708
3837
  * @param {any[]} [keys] the keys currently selected, used only to show
@@ -3730,10 +3859,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3730
3859
  return null;
3731
3860
  }
3732
3861
  /**
3733
- * Highlights the first enabled row, or the last one.
3862
+ * Highlights the first enabled row, or the last one. Does nothing while a
3863
+ * search is loading and the rows are hidden.
3734
3864
  * @param {boolean} first
3735
3865
  */
3736
3866
  jump(first) {
3867
+ if (this.#menu.hidden) {
3868
+ return;
3869
+ }
3737
3870
  const edge = first ? this.#menu.firstElementChild : this.#menu.lastElementChild;
3738
3871
  const target = this.#walk(edge, first);
3739
3872
  if (target) {
@@ -3743,6 +3876,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3743
3876
  /**
3744
3877
  * Moves the highlight by as many enabled rows as the menu shows at once,
3745
3878
  * one when that cannot be measured, stopping at the last enabled row.
3879
+ * Does nothing while a search is loading.
3746
3880
  * @param {boolean} forward
3747
3881
  */
3748
3882
  page(forward) {
@@ -3867,6 +4001,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3867
4001
  #control;
3868
4002
  #ddmenu;
3869
4003
  #input;
4004
+ #label;
3870
4005
  #items;
3871
4006
  #itemstemplate;
3872
4007
  #multiple;
@@ -3891,6 +4026,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3891
4026
  });
3892
4027
  const fragment = this.template().withOverlay({ slots, name }).render();
3893
4028
  this.#input = fragment.querySelector('ful-control > input');
4029
+ this.#label = fragment.querySelector('label');
3894
4030
  this.#items = fragment.querySelector('ful-item-list');
3895
4031
  this.#itemstemplate =
3896
4032
  slots.items && !index_mjs.Fragments.isBlank(slots.items) ? index_mjs.Templates.fromFragment(slots.items) : null;
@@ -3922,16 +4058,18 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3922
4058
  fragment,
3923
4059
  control: this.#input,
3924
4060
  error: fragment.querySelector('ful-field-error'),
3925
- label: fragment.querySelector('label'),
4061
+ label: this.#label,
3926
4062
  };
3927
4063
  }
3928
4064
  /**
3929
4065
  * Pointer interaction: the element toggles the dropdown, the item list's
3930
- * remove buttons and the control's badges drop their entry.
4066
+ * remove buttons and the control's badges drop their entry. A click on the
4067
+ * label is left to the click the browser forwards to the input, which
4068
+ * toggles once.
3931
4069
  */
3932
4070
  #wireChrome() {
3933
4071
  this.addEventListener('click', (/** @type any */ e) => {
3934
- if (!this._interactive()) {
4072
+ if (!this._interactive() || this.#label.contains(e.target)) {
3935
4073
  return;
3936
4074
  }
3937
4075
  const elsewhere = e.target.closest('button, [role="button"], a[href], input, select, textarea');
@@ -3939,6 +4077,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
3939
4077
  return;
3940
4078
  }
3941
4079
  if (this.#ddmenu.shown) {
4080
+ this.#abortdload();
3942
4081
  this.#close();
3943
4082
  return;
3944
4083
  }
@@ -4021,6 +4160,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4021
4160
  #wireSelection() {
4022
4161
  this.#ddmenu.addEventListener('change', (e) => {
4023
4162
  e.stopPropagation();
4163
+ this.#abortdload();
4024
4164
  if (!this._interactive()) {
4025
4165
  this.#close();
4026
4166
  return;
@@ -4055,21 +4195,25 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4055
4195
  * another control needs when that control changes. A key the loader no
4056
4196
  * longer knows is dropped from the selection, so a value invalidated by the
4057
4197
  * change does not survive it, and one it still knows keeps its place with a
4058
- * fresh label. The badges and items follow, and no `change` is dispatched.
4198
+ * fresh label. The badges and items follow, an open dropdown runs its
4199
+ * search again, and no `change` is dispatched.
4059
4200
  *
4060
4201
  * Where the vocabulary lives at a different address, write `src` or call
4061
4202
  * `reconfigure`, which build the loader for the new url and reload.
4062
4203
  * @returns {Promise<void>}
4063
- * @throws what the loader's invalidate, prefetch or key lookup throws
4204
+ * @throws what the loader's invalidate, prefetch, key lookup or search
4205
+ * throws
4064
4206
  */
4065
4207
  async reload() {
4066
4208
  await this.#loader.invalidate?.();
4067
4209
  await this.#loader.prefetch?.();
4068
4210
  const keys = [...this.#values.keys()];
4069
- if (keys.length === 0) {
4070
- return;
4211
+ if (keys.length !== 0) {
4212
+ await this.#resolve(keys, this.#assignments.take());
4213
+ }
4214
+ if (this.#ddmenu.shown) {
4215
+ await this.#open();
4071
4216
  }
4072
- await this.#resolve(keys, this.#assignments.take());
4073
4217
  }
4074
4218
  /**
4075
4219
  * The url the loader asks for the vocabulary, reflected as the `src`
@@ -4260,6 +4404,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4260
4404
  return;
4261
4405
  }
4262
4406
  e.preventDefault();
4407
+ this.#abortdload();
4263
4408
  this.#editing = false;
4264
4409
  this.#display();
4265
4410
  this.#ddmenu.acceptSelection();
@@ -4284,6 +4429,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4284
4429
  if (forward && !this.#ddmenu.shown) {
4285
4430
  this.#open();
4286
4431
  } else if (!forward && this.#ddmenu.shown) {
4432
+ this.#abortdload();
4287
4433
  this.#close();
4288
4434
  }
4289
4435
  return;
@@ -4380,27 +4526,30 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4380
4526
  }
4381
4527
  /**
4382
4528
  * Selects the keys, coerced by `k-type`: a key, an array of keys, or null or
4383
- * undefined for none. The empty string is a key like any other. The keys
4384
- * are applied at once, each labeled by itself until the loader's `exact`
4529
+ * undefined for none. The empty string is a key like any other, and a
4530
+ * single select keeps only the first key. The keys are applied at once, each labeled by itself until the loader's `exact`
4385
4531
  * answers; a key it does not answer is then dropped, a key removed in the
4386
4532
  * meantime stays removed, and a later assignment wins over the late answer.
4387
4533
  * A failed lookup keeps the keys and surfaces as an unhandled rejection.
4388
4534
  * No `change` is dispatched. A key containing a
4389
4535
  * comma, which the value attribute cannot carry, is kept and warned about
4390
- * once per element.
4536
+ * once per element. Before the render the write is held for the render to
4537
+ * apply, over the `value` attribute, which it leaves alone.
4391
4538
  * @param {any} vs
4392
4539
  */
4393
4540
  set value(vs) {
4394
- const keys = (vs == null ? [] : Array.isArray(vs) ? vs : [vs]).map((k) => this.#coerceKey(k));
4541
+ if (!this.#control) {
4542
+ this._holdValue(vs);
4543
+ return;
4544
+ }
4545
+ const given = (vs == null ? [] : Array.isArray(vs) ? vs : [vs]).map((k) => this.#coerceKey(k));
4546
+ const keys = this.#multiple ? given : given.slice(0, 1);
4395
4547
  if (!this.#warnedComma && keys.some((k) => typeof k === 'string' && k.includes(','))) {
4396
4548
  this.#warnedComma = true;
4397
4549
  console.warn('a ful-select key cannot contain a comma: it is unexpressible in the value attribute', this);
4398
4550
  }
4399
4551
  this.#values = new Map(keys.map((k) => [k, { key: k, label: k, metadata: undefined }]));
4400
4552
  const claim = this.#assignments.take();
4401
- if (!this.#control) {
4402
- return;
4403
- }
4404
4553
  this.#syncBadges();
4405
4554
  if (keys.length === 0) {
4406
4555
  return;
@@ -4427,10 +4576,17 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4427
4576
  }
4428
4577
  /**
4429
4578
  * The selected keys, in selection order, for a multiple select; the one
4430
- * key, or null, for a single one.
4579
+ * key, or null, for a single one. Before the render it answers the keys
4580
+ * last written, or the `value` attribute, coerced and cut to one key as the
4581
+ * rendered select would.
4431
4582
  * @returns {any}
4432
4583
  */
4433
4584
  get value() {
4585
+ if (!this.#control) {
4586
+ const held = this._heldValue();
4587
+ const keys = [held ?? []].flat().map((k) => this.#coerceKey(k));
4588
+ return this.multiple ? keys : (keys[0] ?? null);
4589
+ }
4434
4590
  if (this.#multiple) {
4435
4591
  return [...this.#values.keys()];
4436
4592
  }
@@ -4449,20 +4605,60 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4449
4605
  }
4450
4606
  return selection[0] ?? null;
4451
4607
  }
4608
+ /**
4609
+ * The field's disabled claim; turning it on while the dropdown is open
4610
+ * closes it and drops a pending search.
4611
+ * @type {boolean}
4612
+ */
4613
+ get disabled() {
4614
+ return super.disabled;
4615
+ }
4616
+ set disabled(v) {
4617
+ super.disabled = v;
4618
+ if (v && this.#ddmenu?.shown) {
4619
+ this.#abortdload();
4620
+ this.#close();
4621
+ }
4622
+ }
4623
+ /**
4624
+ * The field's readonly claim; turning it on while the dropdown is open
4625
+ * closes it and drops a pending search.
4626
+ * @type {boolean}
4627
+ */
4628
+ get readonly() {
4629
+ return super.readonly;
4630
+ }
4631
+ set readonly(v) {
4632
+ super.readonly = v;
4633
+ if (v && this.#ddmenu?.shown) {
4634
+ this.#abortdload();
4635
+ this.#close();
4636
+ }
4637
+ }
4452
4638
  #useItemList;
4453
4639
  /**
4454
4640
  * Whether the select holds several keys: `value` and `entry` answer arrays,
4455
4641
  * the input stays empty and the selection shows as removable badges in the
4456
- * control.
4642
+ * control. Before the render it answers the `multiple` attribute.
4457
4643
  * @returns {boolean}
4458
4644
  */
4459
4645
  get multiple() {
4460
- return this.#multiple;
4646
+ return this.#multiple ?? this.declared('multiple');
4461
4647
  }
4462
- /** @param {boolean} v */
4648
+ /**
4649
+ * Turning it off keeps only the first selected key; either way the badges,
4650
+ * the items and the input follow at once.
4651
+ * @param {boolean} v
4652
+ */
4463
4653
  set multiple(v) {
4464
4654
  this.#multiple = v;
4465
4655
  this.reflectTo('multiple', v);
4656
+ if (!v) {
4657
+ this.#values = new Map([...this.#values].slice(0, 1));
4658
+ }
4659
+ if (this.#control) {
4660
+ this.#syncBadges();
4661
+ }
4466
4662
  }
4467
4663
  /**
4468
4664
  * Whether the selection shows as the `items` list below the control instead
@@ -4484,10 +4680,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4484
4680
  * a radio input carrying its attributes, labelled by its content; every
4485
4681
  * `input-*` attribute on the group is forwarded onto each radio as well. The
4486
4682
  * default slot is the `<legend>`, and `header` and `footer` slots frame the
4487
- * list. The radios share a name of their own (derived from `name`, or minted
4488
- * when there is none) and carry `form=""`, so the group is the only element
4489
- * the form sees. A radio's native `change` is stopped and republished as the
4490
- * group's own.
4683
+ * list. The radios share a name of their own, minted afresh for each group
4684
+ * (from `name` when there is one) so that same-named groups stay apart, and
4685
+ * carry `form=""`, so the group is the only element the form sees. A radio's
4686
+ * native `change` is stopped and republished as the group's own.
4491
4687
  *
4492
4688
  * The host carries the `radiogroup` role, `aria-readonly`, `aria-required`,
4493
4689
  * `aria-invalid` and the description. The disabled and readonly claims act on
@@ -4532,7 +4728,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4532
4728
  * `announces` pieces
4533
4729
  */
4534
4730
  _build({ slots }) {
4535
- const name = this.declared('name') ?? index_mjs.Attributes.uid('ful-radiogroup');
4731
+ const name = index_mjs.Attributes.uid(this.declared('name') ?? 'ful-radiogroup');
4536
4732
  const radioEls = Array.from(slots.default.querySelectorAll('ful-radio'));
4537
4733
  const inputsAndLabels = radioEls.map((el) => {
4538
4734
  const input = document.createElement('input');
@@ -4570,15 +4766,31 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4570
4766
  * The value of the checked radio, `null` when none is. Under
4571
4767
  * `type="boolean"` it answers `true` for a radio whose value is `true` and
4572
4768
  * `false` for any other. Writing a value checks the radio carrying it, and
4573
- * writing `null` or a value no radio carries clears the selection.
4769
+ * writing `null` or a value no radio carries clears the selection. Before
4770
+ * the render it answers the value last written, or the `value` attribute,
4771
+ * when a `ful-radio` carries it, and a write is held for the render to
4772
+ * apply.
4574
4773
  * @type {string | boolean | null}
4575
4774
  */
4576
4775
  get value() {
4776
+ if (!this.#fieldset) {
4777
+ const held = this._heldValue();
4778
+ const radio = held === null ? null : this.querySelector(`ful-radio[value="${CSS.escape(String(held))}"]`);
4779
+ const key = radio?.getAttribute('value') ?? null;
4780
+ if (key === null || this.declared('type') !== 'boolean') {
4781
+ return key;
4782
+ }
4783
+ return key === 'true';
4784
+ }
4577
4785
  /** @type {HTMLInputElement|null} */
4578
4786
  const checked = this.querySelector('input[type=radio]:checked');
4579
4787
  return checked ? (this.#booleanType ? checked.value === 'true' : checked.value) : null;
4580
4788
  }
4581
4789
  set value(value) {
4790
+ if (!this.#fieldset) {
4791
+ this._holdValue(value);
4792
+ return;
4793
+ }
4582
4794
  const radios = this.querySelectorAll(`input[type=radio]`);
4583
4795
  const clear = () => {
4584
4796
  radios.forEach((el) => {
@@ -4648,13 +4860,19 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
4648
4860
  }
4649
4861
  /**
4650
4862
  * Whether the box is checked. The `value` attribute is read with the `bool`
4651
- * mapper, where only the text `true` means checked.
4863
+ * mapper, where only the text `true` means checked. Before the render it
4864
+ * answers the value last written, or the `value` attribute, and a write is
4865
+ * held for the render to apply.
4652
4866
  * @type {boolean}
4653
4867
  */
4654
4868
  get value() {
4655
- return this.#input.checked;
4869
+ return this.#input ? this.#input.checked : Boolean(this._heldValue());
4656
4870
  }
4657
4871
  set value(value) {
4872
+ if (!this.#input) {
4873
+ this._holdValue(value);
4874
+ return;
4875
+ }
4658
4876
  this.#input.checked = value;
4659
4877
  }
4660
4878
  }
@@ -5004,8 +5222,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5004
5222
  */
5005
5223
  async load(pageRequest, sortRequest, filterRequest) {
5006
5224
  const rows = this.#sorted(sortRequest);
5007
- const begin = pageRequest.page * pageRequest.size;
5008
- const end = begin + pageRequest.size;
5225
+ const size = Number(pageRequest.size);
5226
+ const begin = pageRequest.page * size;
5227
+ const end = begin + size;
5009
5228
  const page = rows.slice(begin, end);
5010
5229
  const totalElements = rows.length;
5011
5230
  return {
@@ -5138,12 +5357,17 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5138
5357
  * for it. Without it the table shows an initial panel until `load`,
5139
5358
  * `reload` or `resetWithFilter` is called.
5140
5359
  *
5141
- * Inside the table a `page:requested` loads that page with the current size,
5360
+ * Inside the table, and not inside a `ful-table` nested in it, a
5361
+ * `page:requested` loads that page with the current size,
5142
5362
  * sort and filters; a `sort:requested` loads the current page with the new
5143
5363
  * sort (none for a `null` order) and, once that load is the one showing, sets
5144
5364
  * the order of the sorter that asked and clears the others; a `submit:success`
5145
5365
  * of the filter form loads the first page with the submitted request as the
5146
- * filters. The loads these start, and the autoload, are not awaited by
5366
+ * filters. The first load goes through the filter form too: `reload`, and so
5367
+ * `autoload`, submits it (see `reload`), so a `submit` listener rewriting
5368
+ * `detail.request` shapes every load alike. The filters a page, a sort or a size change carries are those of
5369
+ * the last load asked for, whether it is still running, loaded or failed, so
5370
+ * they are the ones the filter form shows. The loads these start, and the autoload, are not awaited by
5147
5371
  * anything: a failure shows the error panel and its rejection is unhandled.
5148
5372
  */
5149
5373
  class Table extends index_mjs.ParsedElement {
@@ -5230,6 +5454,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5230
5454
  #latestRequest = { pageRequest: { page: 0, size: 10 }, sortRequest: null, filterRequest: {} };
5231
5455
  /** whether a load has been asked for, by autoload or by a caller */
5232
5456
  #loadRequested = false;
5457
+ /** the filters of the last load asked for, which page, sort and size changes carry */
5458
+ #requestedFilters = {};
5459
+ /** @type {any} the `ful-form` wrapping the `filters` slot, if any */
5460
+ #filters = null;
5461
+ /** whether a load has shown its rows, after which `reload` repeats it */
5462
+ #loaded = false;
5463
+ /** @type {Promise<void>|null} the load the last `submit:success` of the filter form started */
5464
+ #filterLoad = null;
5233
5465
  #loads = new Claims();
5234
5466
  /** @type {{ src: string|null, method: string|null }} */
5235
5467
  #builtFrom = { src: null, method: null };
@@ -5251,19 +5483,21 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5251
5483
  *
5252
5484
  * The reload is not awaited: a failure shows the error panel and its
5253
5485
  * rejection is unhandled.
5254
- * @param {number|null|undefined} value absent or null is the default of
5255
- * ten, so removing the attribute restores it
5486
+ * @param {number|string|null|undefined} value a number or a string of
5487
+ * one, such as a select's value; absent or null is the default of ten,
5488
+ * so removing the attribute restores it, and a value that is not a
5489
+ * number is ignored
5256
5490
  */
5257
5491
  set pageSize(value) {
5258
- const size = value ?? 10;
5259
- if (size === this.#latestRequest.pageRequest.size) {
5492
+ const size = Number(value ?? 10);
5493
+ if (Number.isNaN(size) || size === this.#latestRequest.pageRequest.size) {
5260
5494
  return;
5261
5495
  }
5262
5496
  this.#latestRequest = { ...this.#latestRequest, pageRequest: { page: 0, size } };
5263
5497
  if (!this.#loadRequested) {
5264
5498
  return;
5265
5499
  }
5266
- this.reload();
5500
+ this.load(this.#latestRequest.pageRequest, this.#latestRequest.sortRequest, this.#requestedFilters);
5267
5501
  }
5268
5502
  /**
5269
5503
  * The url the loader asks for rows, reflected as the `src` attribute.
@@ -5383,15 +5617,24 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5383
5617
  sortRequest: schema.sort,
5384
5618
  filterRequest: maybeForm?.values ?? {},
5385
5619
  };
5620
+ this.#requestedFilters = this.#latestRequest.filterRequest;
5621
+ this.#filters = maybeForm;
5386
5622
  maybeForm?.addEventListener('submit:success', async (evt) => {
5387
- await this.#loadPage(0, evt.detail.request);
5623
+ this.#filterLoad = this.#loadPage(0, evt.detail.request);
5624
+ await this.#filterLoad;
5388
5625
  });
5389
5626
  this.addEventListener('page:requested', async (/** @type any */ e) => {
5390
- await this.#loadPage(e.detail.value, this.#latestRequest.filterRequest);
5627
+ if (e.target.closest('ful-table') !== this) {
5628
+ return;
5629
+ }
5630
+ await this.#loadPage(e.detail.value, this.#requestedFilters);
5391
5631
  });
5392
5632
  this.addEventListener('sort:requested', async (/** @type any */ e) => {
5633
+ if (e.target.closest('ful-table') !== this) {
5634
+ return;
5635
+ }
5393
5636
  const sortRequest = e.detail.value.order ? e.detail.value : null;
5394
- await this.load(this.#latestRequest.pageRequest, sortRequest, this.#latestRequest.filterRequest);
5637
+ await this.load(this.#latestRequest.pageRequest, sortRequest, this.#requestedFilters);
5395
5638
  if (this.#latestRequest.sortRequest !== sortRequest) {
5396
5639
  return;
5397
5640
  }
@@ -5407,12 +5650,23 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5407
5650
 
5408
5651
  /**
5409
5652
  * Loads the last request that loaded, or, before any has, the first page
5410
- * with the declared size and sort and the values of the filters slot. A
5411
- * size written through `pageSize` since then replaces the page with the
5412
- * first one at that size.
5653
+ * with the declared size and sort. A size written through `pageSize` since
5654
+ * then replaces the page with the first one at that size.
5655
+ *
5656
+ * Before any load has loaded, a table with a `filters` slot submits its
5657
+ * filter form instead, so the first request goes through the same
5658
+ * `submit` listeners as the later ones, and the load its `submit:success`
5659
+ * starts is the one awaited. A filter submit that is cancelled, fails or
5660
+ * is dropped for one in flight loads nothing, and the promise resolves.
5413
5661
  * @returns {Promise<void>} as `load`
5414
5662
  */
5415
5663
  async reload() {
5664
+ if (this.#filters && !this.#loaded) {
5665
+ if (await this.#filters.submit()) {
5666
+ await this.#filterLoad;
5667
+ }
5668
+ return;
5669
+ }
5416
5670
  return await this.load(
5417
5671
  this.#latestRequest.pageRequest,
5418
5672
  this.#latestRequest.sortRequest,
@@ -5420,7 +5674,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5420
5674
  );
5421
5675
  }
5422
5676
  /**
5423
- * Asks the loader for a page and shows it.
5677
+ * Asks the loader for a page and shows it. Its filters are, from now on,
5678
+ * the ones a page, a sort or a size change carries, even while it runs
5679
+ * or once it failed.
5424
5680
  *
5425
5681
  * While the load runs the table carries `aria-busy="true"`, and the rows
5426
5682
  * already shown stay; the spinner panel shows only when there are none.
@@ -5428,7 +5684,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5428
5684
  * panel shows for an answer with no rows (the `empty` slot rendered with
5429
5685
  * `schema`, `pageRequest`, `filterRequest` and `pageResponse` in scope),
5430
5686
  * the request becomes the one
5431
- * `reload` repeats, `aria-busy` is removed and `load:success` is
5687
+ * `reload` repeats; once the elements of the rows and of the empty panel
5688
+ * have rendered, `aria-busy` is removed and `load:success` is
5432
5689
  * dispatched (bubbling, not cancelable) with `detail` set to
5433
5690
  * `{ pageRequest, sortRequest, filterRequest, response }`. A page beyond
5434
5691
  * the last one the answer reports is not shown: the last page is loaded
@@ -5442,7 +5699,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5442
5699
  * returned promise rejects with the same error.
5443
5700
  *
5444
5701
  * A load started after this one supersedes it: when this one's answer or
5445
- * failure arrives it changes nothing, dispatches nothing and resolves.
5702
+ * failure arrives it changes nothing, dispatches nothing and resolves;
5703
+ * superseded while its rows render, it leaves them to the newer load and
5704
+ * dispatches nothing.
5446
5705
  * @param {TablePageRequest} pageRequest
5447
5706
  * @param {TableSortRequest|null} sortRequest
5448
5707
  * @param {Record<string, any>} filterRequest
@@ -5451,6 +5710,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5451
5710
  */
5452
5711
  async load(pageRequest, sortRequest, filterRequest) {
5453
5712
  this.#loadRequested = true;
5713
+ this.#requestedFilters = filterRequest;
5454
5714
  const claim = this.#loads.take();
5455
5715
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
5456
5716
  this.#feedback.setAttribute('hidden', '');
@@ -5466,7 +5726,16 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
5466
5726
  lastPage = Math.max(0, Math.ceil(pageResponse.size / pageRequest.size) - 1);
5467
5727
  if (pageRequest.page <= lastPage) {
5468
5728
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
5729
+ this.#loaded = true;
5469
5730
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
5731
+ // a row element whose render fails leaves the rows shown: it does not fail the load
5732
+ await Promise.all([
5733
+ index_mjs.Rendering.waitForChildren(this.#body),
5734
+ index_mjs.Rendering.waitForChildren(this.#empty),
5735
+ ]).catch(() => undefined);
5736
+ if (claim.stale) {
5737
+ return;
5738
+ }
5470
5739
  this.removeAttribute('aria-busy');
5471
5740
  this.dispatchEvent(
5472
5741
  new CustomEvent('load:success', {
@@ -6066,7 +6335,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6066
6335
  * The operators the menu offers, narrowed to the vocabulary: an empty list,
6067
6336
  * or one where no name is known, offers all of it. A single operator pins
6068
6337
  * the button, and an assigned tuple then carries that operator whatever it
6069
- * names. An assignment made before the upgrade is held narrowed and
6338
+ * names. When the operator shown leaves the list, the first listed one is
6339
+ * shown. An assignment made before the upgrade is held narrowed and
6070
6340
  * answered back until the render, when the `operators` attribute is applied
6071
6341
  * over it, an absent attribute meaning the whole vocabulary. Assignments are
6072
6342
  * ignored while the `operator` attribute fixes the operator.
@@ -6085,27 +6355,61 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6085
6355
  return;
6086
6356
  }
6087
6357
  this._operator.allowed = declared;
6088
- this._syncBetween();
6358
+ if (this._operator.value !== null) {
6359
+ this._showOperator(this._operator.preferring(this._operator.value));
6360
+ }
6089
6361
  }
6090
6362
  /**
6091
6363
  * The filter's tuple, `[operator, operand]` or `[operator, from, to]` for
6092
6364
  * BETWEEN, each operand converted by `_serialize` (an ISO instant for the
6093
6365
  * instant filter). Null while any operand the operator uses is empty.
6366
+ * Before the render it answers the tuple last written, or the `value`
6367
+ * attribute, as the rendered filter would show it.
6094
6368
  * @returns {any[]|null}
6095
6369
  */
6096
6370
  get value() {
6371
+ if (!this._operator) {
6372
+ return this._shownTuple(this._heldValue());
6373
+ }
6097
6374
  return this._tuple();
6098
6375
  }
6099
6376
  /**
6100
- * Shows a tuple: the operator it names, unless a pinned or fixed operator
6101
- * replaces it, and its operands, an operand missing from a shorter tuple
6377
+ * Shows a tuple: the operator it names, or the first allowed one where the
6378
+ * whitelist, a pin or a fixed operator does not allow it, and its operands, an operand missing from a shorter tuple
6102
6379
  * leaving its input empty. Null or undefined empties both operands and
6103
- * keeps the operator.
6380
+ * keeps the operator. Before the render the tuple is held for the render
6381
+ * to apply, over the `value` attribute, which it leaves alone.
6104
6382
  * @param {any[]|null|undefined} v
6105
6383
  */
6106
6384
  set value(v) {
6385
+ if (!this._operator) {
6386
+ this._holdValue(v);
6387
+ return;
6388
+ }
6107
6389
  this._applyTuple(v);
6108
6390
  }
6391
+ /**
6392
+ * The tuple `_tuple` answers once `_applyTuple(v)` has shown `v`, read
6393
+ * from the declared `operator` and `operators` in place of the menu.
6394
+ * @param {any[]|null|undefined} v
6395
+ * @returns {any[]|null}
6396
+ */
6397
+ _shownTuple(v) {
6398
+ if (v == null) {
6399
+ return null;
6400
+ }
6401
+ const [declared, ...values] = v;
6402
+ const fixed = this.declared('operator');
6403
+ const known = this._vocabulary().includes(fixed);
6404
+ const allowed =
6405
+ fixed === null
6406
+ ? ChoiceButton.narrow(this.declared('operators'), this._vocabulary())
6407
+ : [known ? fixed : this._defaultOperator()];
6408
+ const operator = allowed.includes(declared) ? declared : allowed[0];
6409
+ const shown = [values[0], values[1]].map((o) => `${o ? this._deserialize(o) : (o ?? '')}`);
6410
+ const operands = operator === 'BETWEEN' ? shown : shown.slice(0, 1);
6411
+ return operands.some((o) => o === '') ? null : [operator, ...operands.map((o) => this._serialize(o))];
6412
+ }
6109
6413
  _tuple() {
6110
6414
  const operator = this._operator.value;
6111
6415
  const values = this.#operands();
@@ -6118,8 +6422,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6118
6422
  return;
6119
6423
  }
6120
6424
  const [declared, ...values] = v;
6121
- const operator = this._operator.pinned ? this._operator.allowed[0] : declared;
6122
- this._showOperator(operator);
6425
+ this._showOperator(this._operator.preferring(declared));
6123
6426
  this._value1.value = values[0] ? this._deserialize(values[0]) : (values[0] ?? '');
6124
6427
  this._value2.value = values[1] ? this._deserialize(values[1]) : (values[1] ?? '');
6125
6428
  }
@@ -6136,10 +6439,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6136
6439
  * them rather than as serialized: a date filter describes what is in its
6137
6440
  * control, not the ISO string it sends. Null while every one of those
6138
6441
  * operands is empty, so a BETWEEN with one bound answers that bound while
6139
- * its value is still null.
6442
+ * its value is still null, and before the filter has rendered.
6140
6443
  * @returns {FilterCriterion|null}
6141
6444
  */
6142
6445
  get criterion() {
6446
+ if (!this._operator) {
6447
+ return null;
6448
+ }
6143
6449
  return asCriterion(labelTextOf(this), this._operator.value, this.#operands());
6144
6450
  }
6145
6451
  #operands() {
@@ -6299,10 +6605,26 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6299
6605
  }
6300
6606
  /**
6301
6607
  * The tuple with the sensitivity after the operator, `[operator,
6302
- * sensitivity, ...operands]`. Null while any operand the operator uses is empty.
6608
+ * sensitivity, ...operands]`. Null while any operand the operator uses is
6609
+ * empty. Before the render it answers the tuple last written, or the
6610
+ * `value` attribute, as the rendered filter would show it.
6303
6611
  * @returns {any[]|null}
6304
6612
  */
6305
6613
  get value() {
6614
+ if (!this._sensitivityButton) {
6615
+ const held = this._heldValue();
6616
+ const tuple = held == null ? null : this._shownTuple([held[0], ...held.slice(2)]);
6617
+ if (tuple === null) {
6618
+ return null;
6619
+ }
6620
+ const fixed = this.declared('sensitivity');
6621
+ const allowed =
6622
+ fixed === null
6623
+ ? ChoiceButton.narrow(this.declared('sensitivities'), SENSITIVITIES)
6624
+ : [SENSITIVITIES.includes(fixed) ? fixed : 'IGNORE_CASE'];
6625
+ const kept = allowed.includes(held[1]) ? held[1] : 'IGNORE_CASE';
6626
+ return [tuple[0], allowed.includes(kept) ? kept : allowed[0], ...tuple.slice(1)];
6627
+ }
6306
6628
  const tuple = this._tuple();
6307
6629
  return tuple == null ? null : [tuple[0], this._sensitivity, ...tuple.slice(1)];
6308
6630
  }
@@ -6310,10 +6632,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6310
6632
  * Shows a tuple as the compare filter does, reading the sensitivity at
6311
6633
  * index 1. A sensitivity the whitelist does not allow leaves the current
6312
6634
  * one in place. Null or undefined empties the operands and keeps the
6313
- * operator and the sensitivity.
6635
+ * operator and the sensitivity. Before the render the tuple is held, as
6636
+ * on the compare filters.
6314
6637
  * @param {any[]|null|undefined} v
6315
6638
  */
6316
6639
  set value(v) {
6640
+ if (!this._sensitivityButton) {
6641
+ this._holdValue(v);
6642
+ return;
6643
+ }
6317
6644
  if (v == null) {
6318
6645
  this._applyTuple(v);
6319
6646
  return;
@@ -6418,7 +6745,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6418
6745
  /**
6419
6746
  * The operators the menu offers, narrowed to EQ and NEQ: an empty list, or
6420
6747
  * one where no name is known, offers both, and a single one pins the
6421
- * button. Held before the upgrade and replaced by the `operators` attribute
6748
+ * button. When the operator shown leaves the list, the first listed one is
6749
+ * shown. Held before the upgrade and replaced by the `operators` attribute
6422
6750
  * at the render, as on the compare filters.
6423
6751
  * @returns {string[]|undefined} undefined before the upgrade when nothing was assigned
6424
6752
  */
@@ -6432,30 +6760,49 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6432
6760
  return;
6433
6761
  }
6434
6762
  this._operator.allowed = declared;
6763
+ if (this._operator.value !== null) {
6764
+ this._operator.value = this._operator.preferring(this._operator.value);
6765
+ }
6435
6766
  }
6436
6767
  /** @returns {string[]} */
6437
6768
  _vocabulary() {
6438
6769
  return BooleanFilter.OPERATORS;
6439
6770
  }
6440
6771
  /**
6441
- * `[operator, token]`, the token being 'true' or 'false'; null while the value menu is on any.
6772
+ * `[operator, token]`, the token being 'true' or 'false'; null while the
6773
+ * value menu is on any. Before the render it answers the tuple last
6774
+ * written, or the `value` attribute, as the rendered filter would show it.
6442
6775
  * @returns {string[]|null}
6443
6776
  */
6444
6777
  get value() {
6778
+ if (!this._value) {
6779
+ const held = this._heldValue();
6780
+ const token = `${held?.[1] ?? ''}`;
6781
+ if (token === '') {
6782
+ return null;
6783
+ }
6784
+ const allowed = ChoiceButton.narrow(this.declared('operators'), this._vocabulary());
6785
+ return [allowed.includes(held[0]) ? held[0] : allowed[0], token];
6786
+ }
6445
6787
  return this._value.value === '' ? null : [this._operator.value, this._value.value];
6446
6788
  }
6447
6789
  /**
6448
- * Shows a tuple: its operator, unless a pinned operator replaces it, and its
6449
- * token, a missing token meaning any. Null or undefined sets the value menu
6450
- * back to any and keeps the operator.
6790
+ * Shows a tuple: its operator, or the first allowed one where the whitelist
6791
+ * does not allow it, and its token, a missing token meaning any. Null or undefined sets the value menu
6792
+ * back to any and keeps the operator. Before the render the tuple is held,
6793
+ * as on the compare filters.
6451
6794
  * @param {any[]|null|undefined} v
6452
6795
  */
6453
6796
  set value(v) {
6797
+ if (!this._value) {
6798
+ this._holdValue(v);
6799
+ return;
6800
+ }
6454
6801
  if (v == null) {
6455
6802
  this._value.value = '';
6456
6803
  return;
6457
6804
  }
6458
- this._operator.value = this._operator.pinned ? this._operator.allowed[0] : v[0];
6805
+ this._operator.value = this._operator.preferring(v[0]);
6459
6806
  this._value.value = v[1] ?? '';
6460
6807
  }
6461
6808
  /**
@@ -6470,10 +6817,13 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6470
6817
  }
6471
6818
  /**
6472
6819
  * The operator and the localized word the value menu shows, not the token.
6473
- * Null while the value menu is on any.
6820
+ * Null while the value menu is on any, and before the filter has rendered.
6474
6821
  * @returns {FilterCriterion|null}
6475
6822
  */
6476
6823
  get criterion() {
6824
+ if (!this._value) {
6825
+ return null;
6826
+ }
6477
6827
  const token = this._value.value;
6478
6828
  return asCriterion(labelTextOf(this), this._operator.value, [token === '' ? '' : booleanValueLabel(token)]);
6479
6829
  }
@@ -6515,7 +6865,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6515
6865
  * The chosen keys as an array, a single choice included, led by the
6516
6866
  * declared `operator` when there is one. Null while nothing is chosen, so
6517
6867
  * that a table drops the filter instead of sending an empty set that
6518
- * matches nothing.
6868
+ * matches nothing. Before the render it answers the keys the select answers
6869
+ * then.
6519
6870
  * @returns {any[]|null}
6520
6871
  */
6521
6872
  get value() {
@@ -6530,7 +6881,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6530
6881
  * Selects the keys of an array or a single bare key; a leading element equal
6531
6882
  * to the declared `operator` is dropped first, so the filter takes back what
6532
6883
  * it answered. A single select takes the first key. Null or undefined clears
6533
- * the selection.
6884
+ * the selection. Before the render the keys are held, as the select holds
6885
+ * them.
6534
6886
  * @param {any} v
6535
6887
  */
6536
6888
  set value(v) {
@@ -6543,7 +6895,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6543
6895
  }
6544
6896
  /**
6545
6897
  * The declared operator, or null, and the labels of the chosen entries. Null
6546
- * while nothing is chosen.
6898
+ * while nothing is chosen, as before the filter has rendered.
6547
6899
  * @returns {FilterCriterion|null}
6548
6900
  */
6549
6901
  get criterion() {
@@ -6839,7 +7191,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
6839
7191
  */
6840
7192
  onSubmitted(answer) {
6841
7193
  this.#section.addEventListener('submit:success', (/** @type any */ e) => {
6842
- if (e.target !== index_mjs.Nodes.queryChildren(this.#section, 'ful-form')) {
7194
+ if (e.target.localName !== 'ful-form' || e.target.parentElement !== this.#section) {
6843
7195
  return;
6844
7196
  }
6845
7197
  answer(e.detail.response);
@@ -7002,10 +7354,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7002
7354
  <section data-ref="loading" hidden><ful-spinner class="centered" role="status"><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner></section>
7003
7355
  <section data-ref="error" role="alert" hidden></section>
7004
7356
  <div data-ref="body" class="ful-dialog-body">{{{{ slots.default }}}}</div>
7005
- <footer class="ful-dialog-footer">
7006
- <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>
7007
- {{{{ slots.buttons }}}}
7008
- </footer>
7357
+ <footer class="ful-dialog-footer"><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>{{{{ slots.buttons }}}}</footer>
7009
7358
  </dialog>
7010
7359
  `;
7011
7360
  #dialog;
@@ -7035,9 +7384,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7035
7384
  this.#settle(outcome);
7036
7385
  });
7037
7386
  this.#dialog.addEventListener('click', (/** @type any */ e) => {
7038
- const result = e.target.closest('button[data-result]')?.dataset.result;
7039
- if (result !== undefined) {
7040
- this.#dialog.close(result);
7387
+ const button = e.target.closest('button[data-result]');
7388
+ if (button && button.closest('dialog') === this.#dialog) {
7389
+ this.#dialog.close(button.dataset.result);
7041
7390
  }
7042
7391
  });
7043
7392
  if (!requiresAnswer) {
@@ -7108,7 +7457,8 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7108
7457
  * the body and `first` true only on the first answered request), so a
7109
7458
  * listener can fill the body. A failed request is painted into the body
7110
7459
  * and does not reject. On a dialog already open nothing is shown or
7111
- * requested, and the call waits for the same ending as the calls before it.
7460
+ * requested, and the call waits for the same ending as the calls before it;
7461
+ * one the page moved while open, which is no longer modal, opens anew.
7112
7462
  * @returns {Promise<DialogOutcome>} resolving on close, or on removal from
7113
7463
  * the document with a dismissal
7114
7464
  */
@@ -7190,6 +7540,10 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7190
7540
  }
7191
7541
  /** @returns {boolean} whether this call is the one that opened the modal */
7192
7542
  #show() {
7543
+ //a move in the page takes an open modal out of the top layer; close() would queue a close event answering the next opening
7544
+ if (this.#dialog.open && !this.#dialog.matches(':modal')) {
7545
+ this.#dialog.removeAttribute('open');
7546
+ }
7193
7547
  if (this.#dialog.open) {
7194
7548
  return false;
7195
7549
  }
@@ -7387,9 +7741,9 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7387
7741
  this.dismiss('escape');
7388
7742
  });
7389
7743
  this.#dialog.addEventListener('click', (/** @type any */ e) => {
7390
- const result = e.target.closest('button[data-result]')?.dataset.result;
7391
- if (result !== undefined) {
7392
- this.close(result);
7744
+ const button = e.target.closest('button[data-result]');
7745
+ if (button && button.closest('dialog') === this.#dialog) {
7746
+ this.close(button.dataset.result);
7393
7747
  }
7394
7748
  });
7395
7749
  this.#dialog.addEventListener('close', () => {
@@ -7486,9 +7840,11 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7486
7840
  * the content section and `first` true only on the first answered request),
7487
7841
  * so a listener can fill the content. A failed request is painted into the
7488
7842
  * content section and does not reject. On a drawer already open nothing is
7489
- * shown or requested, except that one sliding out stops and stays open, and
7490
- * the call waits for the same ending as the calls before it, which is how
7491
- * a caller learns the outcome of the opening an `update()` made.
7843
+ * shown or requested, except that one sliding out stops and stays open,
7844
+ * forgetting the result it was closing with, and the call waits for the
7845
+ * same ending as the calls before it, which is how a caller learns the
7846
+ * outcome of the opening an `update()` made. One the page moved while open,
7847
+ * which is no longer modal, opens anew.
7492
7848
  * @returns {Promise<{ dismissed: boolean, result: string|null, response: any }>} resolving on
7493
7849
  * close, or on removal from the document with a dismissal
7494
7850
  */
@@ -7569,7 +7925,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7569
7925
  }
7570
7926
  /** @returns {boolean} whether this call is the one that opened the modal */
7571
7927
  #show() {
7928
+ if (this.#closing) {
7929
+ this.#answer = null;
7930
+ }
7572
7931
  this.#stopSlidingOut();
7932
+ //a move in the page takes an open modal out of the top layer; close() would queue a close event answering the next opening
7933
+ if (this.#dialog.open && !this.#dialog.matches(':modal')) {
7934
+ this.#dialog.removeAttribute('open');
7935
+ }
7573
7936
  if (this.#dialog.open) {
7574
7937
  return false;
7575
7938
  }
@@ -7608,6 +7971,21 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7608
7971
  #invoker = null;
7609
7972
  /** @type {HTMLElement|null} */
7610
7973
  #pressed = null;
7974
+ #notePress = (/** @type PointerEvent */ evt) => {
7975
+ const target = /** @type Node */ (evt.target);
7976
+ this.#pressed = this.matches(':popover-open') && this.#invoker?.contains(target) ? this.#invoker : null;
7977
+ };
7978
+ /** Upgrades the menu, and has a shared one that came back follow the presses on its invoker again. */
7979
+ connectedCallback() {
7980
+ super.connectedCallback();
7981
+ if (this.rendered && !this.declared('for')) {
7982
+ document.addEventListener('pointerdown', this.#notePress, true);
7983
+ }
7984
+ }
7985
+ /** Stops a shared menu following the presses while it is out of the document. */
7986
+ disconnectedCallback() {
7987
+ document.removeEventListener('pointerdown', this.#notePress, true);
7988
+ }
7611
7989
  /**
7612
7990
  * Gives the menu the `menu` role and an empty `popover` attribute unless
7613
7991
  * it already has one, and marks the items. With `for`, read here once, it
@@ -7693,14 +8071,7 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
7693
8071
  return this.#mark().filter((item) => !item.hasAttribute('disabled') && !item.hasAttribute('hidden'));
7694
8072
  }
7695
8073
  #wireShared() {
7696
- document.addEventListener(
7697
- 'pointerdown',
7698
- (evt) => {
7699
- const target = /** @type Node */ (evt.target);
7700
- this.#pressed = this.matches(':popover-open') && this.#invoker?.contains(target) ? this.#invoker : null;
7701
- },
7702
- true,
7703
- );
8074
+ document.addEventListener('pointerdown', this.#notePress, true);
7704
8075
  this.addEventListener('toggle', (/** @type any */ evt) => {
7705
8076
  if (evt.newState === 'closed') {
7706
8077
  this.#invoker?.setAttribute('aria-expanded', 'false');
@@ -8028,17 +8399,21 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8028
8399
  #showScrollButtons() {
8029
8400
  const list = this.#tablist;
8030
8401
  const overflow = list.scrollWidth - list.clientWidth;
8031
- this.#scrollStart.hidden = list.scrollLeft <= 1;
8032
- this.#scrollEnd.hidden = list.scrollLeft >= overflow - 1;
8402
+ const scrolled = Math.abs(list.scrollLeft);
8403
+ this.#scrollStart.hidden = scrolled <= 1;
8404
+ this.#scrollEnd.hidden = scrolled >= overflow - 1;
8033
8405
  this.#scrollStart.style.blockSize = this.#scrollEnd.style.blockSize = `${list.clientHeight}px`;
8034
8406
  }
8035
8407
  /**
8036
- * The part of the tablist that no scroll button covers.
8037
- * @returns {{ start: number, end: number }}
8408
+ * The part of the tablist that no scroll button covers, in viewport
8409
+ * coordinates, the start button being on the right under rtl.
8410
+ * @returns {{ left: number, right: number }}
8038
8411
  */
8039
8412
  #visibleRange() {
8040
8413
  const view = this.#tablist.getBoundingClientRect();
8041
- return { start: view.left + this.#scrollStart.offsetWidth, end: view.right - this.#scrollEnd.offsetWidth };
8414
+ const rtl = getComputedStyle(this.#tablist).direction === 'rtl';
8415
+ const [onLeft, onRight] = rtl ? [this.#scrollEnd, this.#scrollStart] : [this.#scrollStart, this.#scrollEnd];
8416
+ return { left: view.left + onLeft.offsetWidth, right: view.right - onRight.offsetWidth };
8042
8417
  }
8043
8418
  /**
8044
8419
  * Scrolls the tablist so that the first tab cut by the side the direction
@@ -8047,11 +8422,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8047
8422
  * @returns {void}
8048
8423
  */
8049
8424
  #scrollByOneTab(direction) {
8050
- const { start, end } = this.#visibleRange();
8051
- const target =
8052
- direction > 0
8053
- ? this.#tabs.find((tab) => tab.getBoundingClientRect().right > end + 1)
8054
- : this.#tabs.findLast((tab) => tab.getBoundingClientRect().left < start - 1);
8425
+ const { left, right } = this.#visibleRange();
8426
+ const rightwards = direction > 0 !== (getComputedStyle(this.#tablist).direction === 'rtl');
8427
+ const cut = rightwards
8428
+ ? (/** @type HTMLElement */ tab) => tab.getBoundingClientRect().right > right + 1
8429
+ : (/** @type HTMLElement */ tab) => tab.getBoundingClientRect().left < left - 1;
8430
+ const target = direction > 0 ? this.#tabs.find(cut) : this.#tabs.findLast(cut);
8055
8431
  this.#bringIntoView(target, matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth');
8056
8432
  }
8057
8433
  /**
@@ -8065,11 +8441,12 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8065
8441
  if (!tab) {
8066
8442
  return;
8067
8443
  }
8068
- const { start, end } = this.#visibleRange();
8444
+ const { left, right } = this.#visibleRange();
8069
8445
  const box = tab.getBoundingClientRect();
8070
- const delta = box.right > end ? Math.ceil(box.right - end) : box.left < start ? Math.floor(box.left - start) : 0;
8071
- if (delta !== 0) {
8072
- this.#tablist.scrollBy({ left: delta, behavior });
8446
+ if (box.right > right) {
8447
+ this.#tablist.scrollBy({ left: Math.ceil(box.right - right), behavior });
8448
+ } else if (box.left < left) {
8449
+ this.#tablist.scrollBy({ left: Math.floor(box.left - left), behavior });
8073
8450
  }
8074
8451
  }
8075
8452
  /**
@@ -8296,14 +8673,15 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8296
8673
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
8297
8674
  * the answers once they are delivered, or to undefined when nobody
8298
8675
  * listened; undefined when the section is already current, and undefined
8299
- * with a warning when no section carries `ref`
8676
+ * with a warning when no section carries `ref` or the one carrying it has
8677
+ * no step paired with it
8300
8678
  * @throws what the entered section's delivery rejected with, as the rejection
8301
8679
  * of the returned promise, after its error is painted in the section
8302
8680
  */
8303
8681
  move(ref) {
8304
8682
  const index = this.#sections.findIndex((s) => s.getAttribute('data-step') === ref);
8305
- if (index === -1) {
8306
- console.warn(`ful-wizard: no section carries data-step="${ref}"`);
8683
+ if (index === -1 || index >= this.#steps.length) {
8684
+ console.warn(`ful-wizard: no section paired with a step carries data-step="${ref}"`);
8307
8685
  return undefined;
8308
8686
  }
8309
8687
  return this.#move(index);
@@ -8316,17 +8694,14 @@ var ful = (function (exports, index_mjs, index_mjs$1) {
8316
8694
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
8317
8695
  * the answers, or to undefined when nobody listened or the delivery failed
8318
8696
  * (its error painted in the section); undefined, with a warning, when `ref`
8319
- * names no section
8697
+ * names no section or one with no step paired with it
8320
8698
  */
8321
8699
  refresh(ref) {
8322
- const section =
8700
+ const index =
8323
8701
  ref instanceof Element
8324
- ? ref
8325
- : typeof ref === 'string'
8326
- ? this.#sections.find((s) => s.getAttribute('data-step') === ref)
8327
- : undefined;
8328
- const index = this.#sections.indexOf(section);
8329
- if (index === -1) {
8702
+ ? this.#sections.indexOf(ref)
8703
+ : this.#sections.findIndex((s) => s.getAttribute('data-step') === ref);
8704
+ if (index === -1 || index >= this.#steps.length) {
8330
8705
  console.warn(`ful-wizard: no section answers to "${ref}"`);
8331
8706
  return undefined;
8332
8707
  }