@optionfactory/fml 9.0.0-rc30 → 9.0.0-rc32

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
@@ -2850,6 +2850,8 @@ var fml = (function (exports) {
2850
2850
  return undefined;
2851
2851
  };
2852
2852
 
2853
+ const BOUND = Symbol('BOUND');
2854
+
2853
2855
  /**
2854
2856
  * Evaluates a parsed expression against a scope. One instance per evaluation:
2855
2857
  * it holds the modules and the data stack that names resolve against, and keeps
@@ -2858,9 +2860,11 @@ var fml = (function (exports) {
2858
2860
  class EvaluatingVisitor {
2859
2861
  #modules;
2860
2862
  #dataStack;
2861
- constructor(modules, dataStack) {
2863
+ #bound;
2864
+ constructor(modules, dataStack, bound) {
2862
2865
  this.#modules = modules;
2863
2866
  this.#dataStack = dataStack;
2867
+ this.#bound = bound ?? dataStack.length;
2864
2868
  }
2865
2869
  #resolve(prop) {
2866
2870
  return resolveInStack(this.#dataStack, prop);
@@ -2884,8 +2888,9 @@ var fml = (function (exports) {
2884
2888
  #cached_resolve_proxy;
2885
2889
  #resolve_proxy() {
2886
2890
  if (!this.#cached_resolve_proxy) {
2891
+ const bound = (name) => resolveInStack(this.#dataStack.slice(0, this.#bound), name);
2887
2892
  this.#cached_resolve_proxy = new Proxy(this.#dataStack, {
2888
- get: (target, prop) => this.#resolve(prop),
2893
+ get: (target, prop) => (prop === BOUND ? bound : this.#resolve(prop)),
2889
2894
  });
2890
2895
  }
2891
2896
  return this.#cached_resolve_proxy;
@@ -3038,6 +3043,16 @@ var fml = (function (exports) {
3038
3043
  * `{{{ }}}` (html) or `{{{{ }}}}` (node) interpolation, in order.
3039
3044
  */
3040
3045
  static MODE_TEMPLATED = Symbol('MODE_TEMPLATED');
3046
+ /**
3047
+ * The key under which the receiver of a module function answers a lookup
3048
+ * against the bound data alone: `this[Expressions.BOUND](name)` resolves
3049
+ * the name in the layers the scope was created with (a registry's data,
3050
+ * the data handed to a Template factory) and never in an overlay pushed
3051
+ * afterwards, by `data-tpl-each`, `data-tpl-with` or `withOverlay`. It is
3052
+ * how a module reads what the page configured without the rendered data
3053
+ * being able to supply or replace it. No name on the data stack reaches it.
3054
+ */
3055
+ static BOUND = BOUND;
3041
3056
 
3042
3057
  static #astCache = new BoundedCache(1000);
3043
3058
 
@@ -3062,10 +3077,11 @@ var fml = (function (exports) {
3062
3077
  * @param {any[]} dataStack innermost overlay last
3063
3078
  * @param {any} ast what `parse` answered
3064
3079
  * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode] the mode it was parsed in
3080
+ * @param {number} [bound] how many layers, from the outermost, are the bound data; all of them when omitted
3065
3081
  * @returns {any} the value, or the list of parts in MODE_TEMPLATED
3066
3082
  */
3067
- static evaluate(modules, dataStack, ast, mode) {
3068
- return new EvaluatingVisitor(modules, dataStack).visitRoot(ast, mode === Expressions.MODE_TEMPLATED);
3083
+ static evaluate(modules, dataStack, ast, mode, bound) {
3084
+ return new EvaluatingVisitor(modules, dataStack, bound).visitRoot(ast, mode === Expressions.MODE_TEMPLATED);
3069
3085
  }
3070
3086
  /**
3071
3087
  * Parses and evaluates an expression.
@@ -3073,10 +3089,11 @@ var fml = (function (exports) {
3073
3089
  * @param {any[]} dataStack innermost overlay last
3074
3090
  * @param {string} expression
3075
3091
  * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode] MODE_EXPRESSION when omitted
3092
+ * @param {number} [bound] how many layers, from the outermost, are the bound data; all of them when omitted
3076
3093
  * @returns {any} the value, or the list of parts in MODE_TEMPLATED
3077
3094
  */
3078
- static interpret(modules, dataStack, expression, mode) {
3079
- return Expressions.evaluate(modules, dataStack, Expressions.parse(expression, mode), mode);
3095
+ static interpret(modules, dataStack, expression, mode, bound) {
3096
+ return Expressions.evaluate(modules, dataStack, Expressions.parse(expression, mode), mode, bound);
3080
3097
  }
3081
3098
  }
3082
3099
 
@@ -3085,17 +3102,24 @@ var fml = (function (exports) {
3085
3102
  * that a bare identifier resolves against, as a single value. Adding an overlay
3086
3103
  * returns a new evaluator instead of modifying this one, so a template can add
3087
3104
  * data for one subtree without affecting the rest of the render.
3105
+ *
3106
+ * The layers an evaluator is created with are its bound data; the overlays
3107
+ * pushed afterwards are not. A bare identifier resolves against both, while a
3108
+ * module function can ask for the bound data alone through `Expressions.BOUND`.
3088
3109
  */
3089
3110
  class ExpressionEvaluator {
3090
3111
  #modules;
3091
3112
  #dataStack;
3113
+ #bound;
3092
3114
  /**
3093
3115
  * @param {{ [k: string]: any } | null | undefined} modules
3094
3116
  * @param {any[]} dataStack innermost overlay last
3117
+ * @param {number} [bound] how many layers, from the outermost, are the bound data; all of them when omitted
3095
3118
  */
3096
- constructor(modules, dataStack) {
3119
+ constructor(modules, dataStack, bound) {
3097
3120
  this.#modules = modules;
3098
3121
  this.#dataStack = dataStack;
3122
+ this.#bound = bound ?? dataStack.length;
3099
3123
  }
3100
3124
  /**
3101
3125
  * A new evaluator with one more module: `#name:fn()` resolves into it, and an
@@ -3106,11 +3130,11 @@ var fml = (function (exports) {
3106
3130
  */
3107
3131
  withModule(name, value) {
3108
3132
  const module = name ? { [name]: value } : value;
3109
- return new ExpressionEvaluator({ ...this.#modules, ...module }, this.#dataStack);
3133
+ return new ExpressionEvaluator({ ...this.#modules, ...module }, this.#dataStack, this.#bound);
3110
3134
  }
3111
3135
  /**
3112
3136
  * A new evaluator with the data pushed as the innermost overlays, the last
3113
- * one winning a shared name.
3137
+ * one winning a shared name. The bound data stays what it was.
3114
3138
  * @param {...any} data
3115
3139
  * @returns {ExpressionEvaluator}
3116
3140
  */
@@ -3118,6 +3142,7 @@ var fml = (function (exports) {
3118
3142
  return new ExpressionEvaluator(
3119
3143
  this.#modules,
3120
3144
  data.length === 0 ? this.#dataStack : [...this.#dataStack, ...data],
3145
+ this.#bound,
3121
3146
  );
3122
3147
  }
3123
3148
  /**
@@ -3135,7 +3160,13 @@ var fml = (function (exports) {
3135
3160
  * @returns {any}
3136
3161
  */
3137
3162
  evaluateExpression(expression) {
3138
- return Expressions.interpret(this.#modules, this.#dataStack, expression, Expressions.MODE_EXPRESSION);
3163
+ return Expressions.interpret(
3164
+ this.#modules,
3165
+ this.#dataStack,
3166
+ expression,
3167
+ Expressions.MODE_EXPRESSION,
3168
+ this.#bound,
3169
+ );
3139
3170
  }
3140
3171
  /**
3141
3172
  * Evaluates a templated text, the `{{ }}` form, against this scope.
@@ -3143,7 +3174,7 @@ var fml = (function (exports) {
3143
3174
  * @returns {{ type: symbol, value: any }[]} its parts, as MODE_TEMPLATED describes
3144
3175
  */
3145
3176
  evaluateTemplated(text) {
3146
- return Expressions.interpret(this.#modules, this.#dataStack, text, Expressions.MODE_TEMPLATED);
3177
+ return Expressions.interpret(this.#modules, this.#dataStack, text, Expressions.MODE_TEMPLATED, this.#bound);
3147
3178
  }
3148
3179
  }
3149
3180
 
@@ -4039,14 +4070,22 @@ var fml = (function (exports) {
4039
4070
  #ready = new Promise((resolve) => {
4040
4071
  this.#readyResolve = resolve;
4041
4072
  });
4073
+ #configured = /** @type {PromiseWithResolvers<void>} */ (Promise.withResolvers());
4042
4074
  constructor() {
4043
- Nodes.waitDomContentLoaded(document).then(() => {
4075
+ Promise.all([Nodes.waitDomContentLoaded(document), this.#configured.promise]).then(() => {
4044
4076
  this.#start();
4045
4077
  });
4046
4078
  }
4047
4079
  ready() {
4048
4080
  return this.#ready;
4049
4081
  }
4082
+ /**
4083
+ * Marks the registry as configured: until then nothing is defined, so an
4084
+ * empty queue says nothing about the page and readiness is held back.
4085
+ */
4086
+ configured() {
4087
+ this.#configured.resolve();
4088
+ }
4050
4089
  async #start() {
4051
4090
  await this.settled();
4052
4091
  document.dispatchEvent(
@@ -4089,7 +4128,10 @@ var fml = (function (exports) {
4089
4128
  * @returns {Promise<void>}
4090
4129
  */
4091
4130
  settled() {
4092
- return this.#drain(() => true, (entry) => entry.finished);
4131
+ return this.#drain(
4132
+ () => true,
4133
+ (entry) => entry.finished,
4134
+ );
4093
4135
  }
4094
4136
  /**
4095
4137
  * Waits for the accepted upgrades, the ones enqueued meanwhile included,
@@ -4343,7 +4385,9 @@ var fml = (function (exports) {
4343
4385
  }
4344
4386
  /**
4345
4387
  * Defines every element deferred so far; from then on, defineElement takes
4346
- * effect immediately.
4388
+ * effect immediately. Readiness waits for the first call: a page that
4389
+ * configures late (translations fetched first) gets its ftl:ready once the
4390
+ * elements this defines have upgraded, not at DOMContentLoaded.
4347
4391
  * @returns {Registry} this registry
4348
4392
  */
4349
4393
  configure() {
@@ -4352,6 +4396,7 @@ var fml = (function (exports) {
4352
4396
  delete this.#tagToClass[tag];
4353
4397
  }
4354
4398
  this.#configured = true;
4399
+ this.#upgradeQueue.configured();
4355
4400
  return this;
4356
4401
  }
4357
4402
  /**
@@ -4383,9 +4428,11 @@ var fml = (function (exports) {
4383
4428
  /**
4384
4429
  * Waits for this registry's readiness: resolves right after its ftl:ready
4385
4430
  * event is dispatched on the document, and immediately when that already
4386
- * happened. Never rejects: a failed upgrade counts as settled, and its
4387
- * rejection is left unhandled so it still reaches the console and the
4388
- * page's error reporting.
4431
+ * happened. The event is dispatched once DOMContentLoaded has passed,
4432
+ * configure() has been called and the queue has drained, so a registry
4433
+ * that is never configured is never ready. Never rejects: a failed
4434
+ * upgrade counts as settled, and its rejection is left unhandled so it
4435
+ * still reaches the console and the page's error reporting.
4389
4436
  * @returns {Promise<void>}
4390
4437
  */
4391
4438
  ready() {
@@ -4729,12 +4776,27 @@ var fml = (function (exports) {
4729
4776
  */
4730
4777
 
4731
4778
  /**
4732
- * The receiver contract of the module functions: bound to a proxy over the
4733
- * data stack when called from a template, or to a plain object by Localization.of().
4779
+ * The receiver contract of the module functions: a plain object carrying the
4780
+ * translations and the locale, as Localization.of() binds them, or the proxy
4781
+ * over the data stack a template calls them with.
4734
4782
  *
4735
4783
  * @typedef {{ l10n?: Messages, locale?: string }} Receiver
4736
4784
  */
4737
4785
 
4786
+ /**
4787
+ * The translations or the locale of a receiver. From a template they are read
4788
+ * from the bound data alone (the registry's overlays, or the data handed to
4789
+ * the Template factory): the rows and responses a render pushes on top are
4790
+ * what arrives later, and must not supply or replace the page's own messages.
4791
+ * @param {any} receiver
4792
+ * @param {'l10n' | 'locale'} name
4793
+ * @returns {any}
4794
+ */
4795
+ const configured = (receiver, name) => {
4796
+ const bound = receiver[Expressions.BOUND];
4797
+ return typeof bound === 'function' ? bound(name) : receiver[name];
4798
+ };
4799
+
4738
4800
  const PLACEHOLDER = /\{(\w+)\}/g;
4739
4801
  const POSITIONAL = /\{(\d+)\}/g;
4740
4802
  /** @param {any} v @returns {v is Record<string, string>} */
@@ -4769,7 +4831,9 @@ var fml = (function (exports) {
4769
4831
  * The translations as a template module: `#l10n:t()` for messages, plus date,
4770
4832
  * number and bytes formatting in the page's locale. The template form and the
4771
4833
  * imperative `of()` facade both resolve through the registry's scope, so they
4772
- * always produce the same result for the same key.
4834
+ * always produce the same result for the same key. A template reads `l10n`
4835
+ * and `locale` from its bound data only, the most recent layer winning: an
4836
+ * overlay pushed while rendering (a row, a response) never reaches them.
4773
4837
  */
4774
4838
  class Localization {
4775
4839
  /**
@@ -4788,7 +4852,7 @@ var fml = (function (exports) {
4788
4852
  * @returns {string} the message, or the key itself when the translations do not carry it
4789
4853
  */
4790
4854
  static t(key, ...args) {
4791
- const messages = this.l10n ?? {};
4855
+ const messages = configured(this, 'l10n') ?? {};
4792
4856
  let message = Object.hasOwn(messages, key) ? messages[key] : undefined;
4793
4857
  if (message === undefined) {
4794
4858
  warnOnce(`missing message "${key}"`);
@@ -4800,7 +4864,7 @@ var fml = (function (exports) {
4800
4864
  warnOnce(`plural message "${key}" needs a numeric {count}`);
4801
4865
  message = message.other;
4802
4866
  } else {
4803
- const locale = this.locale ?? navigator?.language ?? 'en';
4867
+ const locale = configured(this, 'locale') ?? navigator?.language ?? 'en';
4804
4868
  const category = formatter(Intl.PluralRules, locale).select(named.count);
4805
4869
  message = message[category] ?? message.other;
4806
4870
  }
@@ -4840,7 +4904,7 @@ var fml = (function (exports) {
4840
4904
  * @returns {string}
4841
4905
  */
4842
4906
  static date(value, options) {
4843
- return formatter(Intl.DateTimeFormat, this.locale, options).format(value);
4907
+ return formatter(Intl.DateTimeFormat, configured(this, 'locale'), options).format(value);
4844
4908
  }
4845
4909
 
4846
4910
  /**
@@ -4852,7 +4916,7 @@ var fml = (function (exports) {
4852
4916
  * @returns {string}
4853
4917
  */
4854
4918
  static number(value, options) {
4855
- return formatter(Intl.NumberFormat, this.locale, options).format(value);
4919
+ return formatter(Intl.NumberFormat, configured(this, 'locale'), options).format(value);
4856
4920
  }
4857
4921
 
4858
4922
  /**
@@ -4865,7 +4929,7 @@ var fml = (function (exports) {
4865
4929
  * @returns {string}
4866
4930
  */
4867
4931
  static bytes(value) {
4868
- const format = formatter(Intl.NumberFormat, this.locale, { maximumFractionDigits: 2 }).format;
4932
+ const format = formatter(Intl.NumberFormat, configured(this, 'locale'), { maximumFractionDigits: 2 }).format;
4869
4933
  if (value >= 1024 * 1024 * 1024) {
4870
4934
  return `${format(value / 1024 / 1024 / 1024)}GiB`;
4871
4935
  }
@@ -6681,12 +6745,13 @@ var fml = (function (exports) {
6681
6745
  *
6682
6746
  * The claim setters, the validity protocol and the aria wiring all act on the
6683
6747
  * pieces, so a field with no native control returns a focusable piece of its
6684
- * own chrome as the control. The claims, `value` and `focus()` are the
6685
- * members that tolerate a not yet rendered element: before the render the
6686
- * `value` getter answers what the rendered field would answer from its current
6687
- * declared state, and a value written then is held and applied by the render
6688
- * (see `_holdValue`). The other members need the pieces and so work only after
6689
- * the render.
6748
+ * own chrome as the control. The claims, `value`, `focus()` and
6749
+ * `setCustomValidity` are the members that tolerate a not yet rendered
6750
+ * element: before the render the `value` getter answers what the rendered
6751
+ * field would answer from its current declared state, a value written then is
6752
+ * held and applied by the render (see `_holdValue`), and a problem reported
6753
+ * then is shown by it. The other members need the pieces and so work only
6754
+ * after the render.
6690
6755
  *
6691
6756
  * The inner controls carry `form=""`, so the host is the only element the form
6692
6757
  * sees. Enter pressed in an inner `<input>` of any type but `file`, `button`,
@@ -6724,6 +6789,8 @@ var fml = (function (exports) {
6724
6789
  #also = [];
6725
6790
  /** @type {{ value: any } | null} the value written before the render, which the render applies */
6726
6791
  #held = null;
6792
+ /** @type {{ error: string, context?: string } | null} the problem reported before the render, which the render shows */
6793
+ #reported = null;
6727
6794
  /** Sets the internals' role from the subclass's `static ROLE`. */
6728
6795
  constructor() {
6729
6796
  super();
@@ -6788,6 +6855,11 @@ var fml = (function (exports) {
6788
6855
  this.#pendingFocus = null;
6789
6856
  control.focus(options);
6790
6857
  }
6858
+ if (this.#reported !== null) {
6859
+ const { error, context } = this.#reported;
6860
+ this.#reported = null;
6861
+ this.setCustomValidity(error, context);
6862
+ }
6791
6863
  }
6792
6864
  /**
6793
6865
  * @param {EventTarget} el
@@ -6867,11 +6939,17 @@ var fml = (function (exports) {
6867
6939
  * field owning a whole subtree overrides this to route the message to the
6868
6940
  * inner control the path names.
6869
6941
  *
6870
- * Needs the render: before it there is no error region to write to.
6942
+ * Before the render there is no error region to write to: a message is
6943
+ * kept (the last one wins) and shown once the field has rendered, and a
6944
+ * clear drops the one kept. A field whose render failed shows nothing.
6871
6945
  * @param {string} [error] the message, empty or missing to clear
6872
6946
  * @param {string} [context] the path below this field's name, '' on an exact match
6873
6947
  */
6874
6948
  setCustomValidity(error, context) {
6949
+ if (!this.#fieldError) {
6950
+ this.#reported = error ? { error, context } : null;
6951
+ return;
6952
+ }
6875
6953
  Attributes.set(this.#announces ?? this.#control, 'aria-invalid', error ? 'true' : null);
6876
6954
  if (!error) {
6877
6955
  this.internals.setValidity({});
@@ -7082,11 +7160,16 @@ var fml = (function (exports) {
7082
7160
  /**
7083
7161
  * Calls `_build(conf)` and wires the pieces it answers. When `_build`
7084
7162
  * returns a promise the pieces are wired once it settles; a field that
7085
- * builds synchronously renders synchronously.
7163
+ * builds synchronously renders synchronously. A default slot holding
7164
+ * nothing but whitespace is emptied first, so the label it becomes is
7165
+ * `:empty` and takes no row.
7086
7166
  * @param {{ slots: Record<string, DocumentFragment> | undefined }} conf
7087
7167
  * @returns {void | Promise<void>}
7088
7168
  */
7089
7169
  render(conf) {
7170
+ if (conf.slots?.default && Fragments.isBlank(conf.slots.default)) {
7171
+ conf.slots.default.replaceChildren();
7172
+ }
7090
7173
  const built = /** @type {any} */ (this._build(conf));
7091
7174
  if (built instanceof Promise) {
7092
7175
  return built.then((pieces) => this.#wire(pieces));
@@ -8426,7 +8509,8 @@ var fml = (function (exports) {
8426
8509
  * size and count limits it declares.
8427
8510
  *
8428
8511
  * The `dropzone` and `item-list` attributes show the dropzone and the list of
8429
- * chosen files. A `dropzone` slot replaces the default dropzone's content, and
8512
+ * chosen files, and `no-input` takes the picker control out of sight, leaving
8513
+ * it to the keyboard and to assistive technology. A `dropzone` slot replaces the default dropzone's content, and
8430
8514
  * an `items` slot holding a template replaces the stock item, rendered with the
8431
8515
  * same `files` overlay so that it reads the File objects it lists. Clicking the
8432
8516
  * dropzone opens the picker, and the host carries a `dragover` attribute while
@@ -8469,6 +8553,7 @@ var fml = (function (exports) {
8469
8553
  'multiple:presence',
8470
8554
  'item-list:presence',
8471
8555
  'dropzone:presence',
8556
+ 'no-input:presence',
8472
8557
  'max-files:number',
8473
8558
  'max-file-size:number',
8474
8559
  'max-total-size:number',
@@ -8720,7 +8805,7 @@ var fml = (function (exports) {
8720
8805
  * @returns {string[]|string|null}
8721
8806
  */
8722
8807
  get value() {
8723
- const names = Array.from(this._input.files).map((f) => f.name);
8808
+ const names = Array.from(this.files).map((f) => f.name);
8724
8809
  return this.multiple ? names : (names[0] ?? null);
8725
8810
  }
8726
8811
  /**
@@ -8743,7 +8828,7 @@ var fml = (function (exports) {
8743
8828
  * The size of the selected files together, in bytes.
8744
8829
  * @returns {number}
8745
8830
  */
8746
- get totalsize() {
8831
+ get totalSize() {
8747
8832
  return Array.from(this.files).reduce((a, f) => a + f.size, 0);
8748
8833
  }
8749
8834
  #maxFiles;
@@ -8818,6 +8903,23 @@ var fml = (function (exports) {
8818
8903
  this.#useDropzone = v;
8819
8904
  this.reflectTo('dropzone', v);
8820
8905
  }
8906
+ #noInput;
8907
+ /**
8908
+ * Whether the picker control is taken out of sight, the dropzone and the
8909
+ * item list carrying the field alone; the stylesheet hides it from the
8910
+ * reflected `no-input` attribute. The control stays in the tab order and
8911
+ * keeps the label, the description and the error, and the dropzone shows
8912
+ * the focus ring while it holds the focus.
8913
+ * @returns {boolean}
8914
+ */
8915
+ get noInput() {
8916
+ return this.#noInput;
8917
+ }
8918
+ /** @param {boolean} v */
8919
+ set noInput(v) {
8920
+ this.#noInput = v;
8921
+ this.reflectTo('no-input', v);
8922
+ }
8821
8923
  }
8822
8924
 
8823
8925
  /** the viewport's breathing room when clamping, in pixels */
@@ -10581,9 +10683,7 @@ var fml = (function (exports) {
10581
10683
  static ROLE = 'radiogroup';
10582
10684
  static template = `
10583
10685
  <fieldset>
10584
- <legend>
10585
- {{{{ slots.default }}}}
10586
- </legend>
10686
+ <legend>{{{{ slots.default }}}}</legend>
10587
10687
  <header data-tpl-if="slots.header">
10588
10688
  {{{{ slots.header }}}}
10589
10689
  </header>
@@ -10631,6 +10731,9 @@ var fml = (function (exports) {
10631
10731
  radioEls.forEach((el) => {
10632
10732
  el.remove();
10633
10733
  });
10734
+ if (Fragments.isBlank(slots.default)) {
10735
+ slots.default.replaceChildren();
10736
+ }
10634
10737
  const fragment = this.template().withOverlay({ name, slots, inputsAndLabels }).render();
10635
10738
  this.#fieldset = /** @type HTMLElement */ (fragment.firstElementChild);
10636
10739
  this.#firstRadio = fragment.querySelector('input[type=radio]');
@@ -10772,6 +10875,10 @@ var fml = (function (exports) {
10772
10875
  * @typedef {{ data: any[], size: number }} TablePageResponse the rows of the
10773
10876
  * requested page and the total number of rows across every page
10774
10877
  */
10878
+ /**
10879
+ * @typedef {{ count: Element, appending: Element, failure: Element, error: Element }} TableStatus the pieces
10880
+ * of the status bar `paging="infinite"` and `paging="virtual"` show in place of the pager
10881
+ */
10775
10882
  /**
10776
10883
  * @typedef {{ load(pageRequest: TablePageRequest, sortRequest: TableSortRequest|null, filterRequest: Record<string, any>): Promise<TablePageResponse> }} TableRowLoader
10777
10884
  */
@@ -10786,6 +10893,10 @@ var fml = (function (exports) {
10786
10893
  * current `order` in the cycle `asc`, `desc`, `null`. The button does not
10787
10894
  * change its own `order`: whoever handles the event sets it, which `ful-table`
10788
10895
  * does once the sorted page has loaded.
10896
+ *
10897
+ * The stylesheet draws an up arrow for `asc` and a down arrow for `desc`. An
10898
+ * unsorted button shows the up arrow dimmed, `asc` being the order its first
10899
+ * activation asks for.
10789
10900
  */
10790
10901
  class SortButton extends ParsedElement {
10791
10902
  static attributes = ['sorter'];
@@ -11240,6 +11351,21 @@ var fml = (function (exports) {
11240
11351
  * for it. Without it the table shows an initial panel until `load`,
11241
11352
  * `reload` or `resetWithFilter` is called.
11242
11353
  *
11354
+ * `paging="infinite"`, read once at the upgrade, replaces the pager with a
11355
+ * status bar saying how many rows of how many are shown, with a reload
11356
+ * button. A load still replaces the rows with one page, scrolling the rows
11357
+ * back to the top; from then on, whenever a row of the last half page
11358
+ * comes into view, the next page is asked with the same size, sort and
11359
+ * filters and appended (see `load`). `reload`, a sort, a filter submit and a
11360
+ * size change start over from the first page.
11361
+ *
11362
+ * `paging="virtual"` shows the same status bar, saying which rows of how many
11363
+ * are in view, and keeps in the page only the page in view and one on either
11364
+ * side of it, the others standing in as empty space sized on the height of
11365
+ * the rows. The rows must scroll inside the table, as `ful-fill` makes them:
11366
+ * a table whose rows the page scrolls appends as `paging="infinite"` does,
11367
+ * and warns. See `load` and `reload`.
11368
+ *
11243
11369
  * Inside the table, and not inside a `ful-table` nested in it, a
11244
11370
  * `page:requested` loads that page with the current size,
11245
11371
  * sort and filters; a `sort:requested` loads the current page with the new
@@ -11255,13 +11381,14 @@ var fml = (function (exports) {
11255
11381
  */
11256
11382
  class Table extends ParsedElement {
11257
11383
  /** Read once at the upgrade. */
11258
- static attributes = ['loader', 'autoload:presence'];
11384
+ static attributes = ['loader', 'autoload:presence', 'paging'];
11259
11385
  /** These stay live after the upgrade: see `pageSize`, `src` and `method`. */
11260
11386
  static observed = ['page-size:number', 'src', 'method'];
11261
11387
  static slots = true;
11262
11388
  static config = {
11263
11389
  searchIcon: 'search',
11264
11390
  emptyIcon: 'inbox',
11391
+ reloadIcon: 'arrow-clockwise',
11265
11392
  };
11266
11393
  static template = `
11267
11394
  <ful-form data-tpl-if="slots.filters">
@@ -11271,7 +11398,9 @@ var fml = (function (exports) {
11271
11398
  <table>
11272
11399
  <caption data-tpl-if="slots.caption">{{{{ slots.caption }}}}</caption>
11273
11400
  <thead></thead>
11401
+ <tbody data-tpl-if="virtual" data-ref="before" aria-hidden="true"><tr><td data-tpl-colspan="schema.length"></td></tr></tbody>
11274
11402
  <tbody></tbody>
11403
+ <tbody data-tpl-if="virtual" data-ref="after" aria-hidden="true"><tr><td data-tpl-colspan="schema.length"></td></tr></tbody>
11275
11404
  <tbody data-ref="initial">
11276
11405
  <tr>
11277
11406
  <td data-tpl-colspan="schema.length">
@@ -11315,7 +11444,16 @@ var fml = (function (exports) {
11315
11444
  </tfoot>
11316
11445
  </table>
11317
11446
  </ful-table-wrapper>
11318
- <ful-pagination current="0" total="1"></ful-pagination>
11447
+ <ful-pagination data-tpl-if="!scrolled" current="0" total="1"></ful-pagination>
11448
+ <ful-table-status data-tpl-if="scrolled">
11449
+ <span data-ref="count"></span>
11450
+ <ful-spinner data-ref="appending" role="status" hidden><span class="ful-sr-only">{{ #l10n:t('spinner.loading') }}</span></ful-spinner>
11451
+ <div data-ref="append-failure" role="alert" hidden>
11452
+ <span>{{ #l10n:t('table.error') }} <span data-ref="append-error"></span></span>
11453
+ <button type="button" data-ref="retry">{{ #l10n:t('table.retry') }}</button>
11454
+ </div>
11455
+ <button type="button" data-ref="reload" data-tpl-aria-label="#l10n:t('pagination.reload')"><ful-icon data-tpl-name="config.reloadIcon" aria-hidden="true"></ful-icon></button>
11456
+ </ful-table-status>
11319
11457
  `;
11320
11458
  static templates = {
11321
11459
  row: `
@@ -11333,6 +11471,31 @@ var fml = (function (exports) {
11333
11471
  #feedback;
11334
11472
  #paginator;
11335
11473
  #sorters;
11474
+ #wrapper;
11475
+ /** @type {TableStatus|null} null under the pager */
11476
+ #status = null;
11477
+ /** @type {IntersectionObserver|null} */
11478
+ #endWatch = null;
11479
+ /** @type {Element[]} the rows whose coming into view appends the next page, none while no append is awaited */
11480
+ #watched = [];
11481
+ /** the page the next append asks for, the rows shown, the total the last answer reported and the rows it carried */
11482
+ #appended = { next: 1, shown: 0, total: 0, last: 0 };
11483
+ /** whether the table keeps a window of pages, `paging="virtual"` over rows that scroll inside it */
11484
+ #virtual = false;
11485
+ /** @type {{ before: HTMLElement, after: HTMLElement } | null} the cells standing in for the rows above and below the window */
11486
+ #spacers = null;
11487
+ /** @type {{ first: number, last: number, pages: Map<number, Element[]> } | null} the pages in the page, in order, null while no rows show */
11488
+ #window = null;
11489
+ /** the total number of rows the last answer reported */
11490
+ #total = 0;
11491
+ /** the height of a row the spacers are sized on, measured on the first rows a load shows; 0 until measured */
11492
+ #rowHeight = 0;
11493
+ /** @type {{ readonly stale: boolean } | null} the claim of the window shift in flight */
11494
+ #shifting = null;
11495
+ /** whether the last window shift failed: scrolling asks for nothing more until a retry or a load */
11496
+ #windowFailed = false;
11497
+ /** @type {ReturnType<typeof setTimeout>|undefined} */
11498
+ #jump = undefined;
11336
11499
  /** @type {{ pageRequest: { page: number, size: number }, sortRequest: any, filterRequest: any }} */
11337
11500
  #latestRequest = { pageRequest: { page: 0, size: 10 }, sortRequest: null, filterRequest: {} };
11338
11501
  /** whether a load has been asked for, by autoload or by a caller */
@@ -11362,18 +11525,19 @@ var fml = (function (exports) {
11362
11525
  * writing the size is not a request to start loading, which is what
11363
11526
  * `autoload` and `reload()` are for. A write that does not change the size
11364
11527
  * does nothing. A write made before the render is replaced by the declared
11365
- * `page-size` when the table renders.
11528
+ * `page-size` when the table renders, or by ten when that is not a number
11529
+ * above zero.
11366
11530
  *
11367
11531
  * The reload is not awaited: a failure shows the error panel and its
11368
11532
  * rejection is unhandled.
11369
11533
  * @param {number|string|null|undefined} value a number or a string of
11370
11534
  * one, such as a select's value; absent or null is the default of ten,
11371
11535
  * so removing the attribute restores it, and a value that is not a
11372
- * number is ignored
11536
+ * finite number above zero is ignored
11373
11537
  */
11374
11538
  set pageSize(value) {
11375
11539
  const size = Number(value ?? 10);
11376
- if (Number.isNaN(size) || size === this.#latestRequest.pageRequest.size) {
11540
+ if (!Number.isFinite(size) || size <= 0 || size === this.#latestRequest.pageRequest.size) {
11377
11541
  return;
11378
11542
  }
11379
11543
  this.#latestRequest = { ...this.#latestRequest, pageRequest: { page: 0, size } };
@@ -11459,6 +11623,7 @@ var fml = (function (exports) {
11459
11623
  }
11460
11624
  this.#buildLoader();
11461
11625
  this.#latestRequest = { ...this.#latestRequest, pageRequest: { ...this.#latestRequest.pageRequest, page: 0 } };
11626
+ this.#window = null;
11462
11627
  return true;
11463
11628
  }
11464
11629
  /**
@@ -11469,14 +11634,24 @@ var fml = (function (exports) {
11469
11634
  async render({ slots }) {
11470
11635
  const template = this.template();
11471
11636
  const schema = TableSchemaParser.parse(slots.schema, template);
11472
- const fragment = template.withOverlay({ slots, schema }).render();
11637
+ const paging = this.declared('paging');
11638
+ const fragment = template
11639
+ .withOverlay({
11640
+ slots,
11641
+ schema,
11642
+ scrolled: paging === 'infinite' || paging === 'virtual',
11643
+ virtual: paging === 'virtual',
11644
+ })
11645
+ .render();
11473
11646
  const tableWrapper = /** @type HTMLTableElement */ (Nodes.queryChildren(fragment, 'ful-table-wrapper'));
11474
11647
  const table = /** @type HTMLTableElement */ (tableWrapper.querySelector('table'));
11475
11648
  Attributes.forward('table-', this, table);
11476
11649
  this.#buildLoader();
11477
11650
 
11651
+ this.#wrapper = tableWrapper;
11652
+ const status = Nodes.queryChildren(fragment, 'ful-table-status');
11478
11653
  this.#schema = schema;
11479
- this.#body = table.querySelector(':scope > tbody');
11654
+ this.#body = table.querySelector(':scope > tbody:not([data-ref])');
11480
11655
  this.#loading = table.querySelector(':scope > tbody[data-ref=loading]');
11481
11656
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
11482
11657
  this.#empty = table.querySelector(':scope > tbody[data-ref=empty]');
@@ -11486,16 +11661,67 @@ var fml = (function (exports) {
11486
11661
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
11487
11662
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
11488
11663
  this.replaceChildren(fragment);
11664
+ if (paging === 'virtual') {
11665
+ const overflow = getComputedStyle(tableWrapper).overflowY;
11666
+ this.#virtual = overflow === 'auto' || overflow === 'scroll';
11667
+ if (!this.#virtual) {
11668
+ console.warn(
11669
+ 'ful-table: paging="virtual" needs rows that scroll inside the table, as ful-fill makes them; this one appends its pages as paging="infinite" does',
11670
+ );
11671
+ }
11672
+ }
11673
+ if (status) {
11674
+ const piece = (/** @type string */ ref) =>
11675
+ /** @type Element */ (status.querySelector(`[data-ref=${ref}]`));
11676
+ this.#status = {
11677
+ count: piece('count'),
11678
+ appending: piece('appending'),
11679
+ failure: piece('append-failure'),
11680
+ error: piece('append-error'),
11681
+ };
11682
+ piece('reload').addEventListener('click', () => this.reload());
11683
+ piece('retry').addEventListener('click', () => {
11684
+ if (!this.#virtual) {
11685
+ this.#appendNext();
11686
+ return;
11687
+ }
11688
+ this.#windowFailed = false;
11689
+ this.#reconcile(true);
11690
+ });
11691
+ }
11692
+ if (this.#virtual) {
11693
+ this.#spacers = {
11694
+ before: /** @type HTMLElement */ (table.querySelector(':scope > tbody[data-ref=before] td')),
11695
+ after: /** @type HTMLElement */ (table.querySelector(':scope > tbody[data-ref=after] td')),
11696
+ };
11697
+ // scroll fires once per frame at most, so it needs no throttle of its own
11698
+ tableWrapper.addEventListener(
11699
+ 'scroll',
11700
+ () => {
11701
+ this.#countInView();
11702
+ this.#reconcile(false);
11703
+ },
11704
+ { passive: true },
11705
+ );
11706
+ } else if (status) {
11707
+ this.#endWatch = new IntersectionObserver((entries) => {
11708
+ // an entry queued before the watch moved on to another row, or stopped, asks for nothing
11709
+ if (entries.some((e) => e.isIntersecting && this.#watched.includes(e.target))) {
11710
+ this.#appendNext();
11711
+ }
11712
+ });
11713
+ }
11489
11714
  const thead = /** @type HTMLTableSectionElement */ (this.querySelector('thead'));
11490
11715
  schema.headersTemplate.renderTo(thead);
11491
11716
  this.#sorters = thead.querySelectorAll('ful-sorter');
11492
11717
  await Rendering.waitForChildren(this);
11493
11718
 
11494
11719
  const maybeForm = /** @type any */ (Nodes.queryChildren(this, 'ful-form'));
11720
+ const declaredSize = this.declared('page-size');
11495
11721
  this.#latestRequest = {
11496
11722
  pageRequest: {
11497
11723
  page: 0,
11498
- size: this.declared('page-size') ?? 10,
11724
+ size: Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : 10,
11499
11725
  },
11500
11726
  sortRequest: schema.sort,
11501
11727
  filterRequest: maybeForm?.values ?? {},
@@ -11541,6 +11767,14 @@ var fml = (function (exports) {
11541
11767
  * `submit` listeners as the later ones, and the load its `submit:success`
11542
11768
  * starts is the one awaited. A filter submit that is cancelled, fails or
11543
11769
  * is dropped for one in flight loads nothing, and the promise resolves.
11770
+ *
11771
+ * Under `paging="virtual"`, once rows show, it asks again for the pages in
11772
+ * the page, with the same size, sort and filters, and shows them where
11773
+ * they are, the scroll position and the widths of the columns kept; the
11774
+ * table is busy and settles, fails and dispatches as a load does, one
11775
+ * `load:success` per page. When the answers report fewer rows than the
11776
+ * window starts at, it loads the first page instead. A `src` or `method`
11777
+ * write still starts over from the first page.
11544
11778
  * @returns {Promise<void>} as `load`
11545
11779
  */
11546
11780
  async reload() {
@@ -11550,6 +11784,9 @@ var fml = (function (exports) {
11550
11784
  }
11551
11785
  return;
11552
11786
  }
11787
+ if (this.#window) {
11788
+ return await this.#reloadWindow();
11789
+ }
11553
11790
  return await this.load(
11554
11791
  this.#latestRequest.pageRequest,
11555
11792
  this.#latestRequest.sortRequest,
@@ -11579,12 +11816,49 @@ var fml = (function (exports) {
11579
11816
  * `aria-busy` is removed, `load:failure` is dispatched (bubbling, not
11580
11817
  * cancelable) with `detail` set to
11581
11818
  * `{ pageRequest, sortRequest, filterRequest, exception }`, and the
11582
- * returned promise rejects with the same error.
11819
+ * returned promise rejects with the same error. An answer the table cannot
11820
+ * page fails the same way, with an error saying so: one whose `size` is
11821
+ * not a number, as a response of another shape read without a
11822
+ * `response-mapper` is, or a page size of zero.
11583
11823
  *
11584
11824
  * A load started after this one supersedes it: when this one's answer or
11585
11825
  * failure arrives it changes nothing, dispatches nothing and resolves;
11586
11826
  * superseded while its rows render, it leaves them to the newer load and
11587
11827
  * dispatches nothing.
11828
+ *
11829
+ * Under `paging="infinite"` the status bar takes the pager's place, and
11830
+ * once the load has dispatched `load:success` the next page is awaited:
11831
+ * when a row of the last half page comes into view, through
11832
+ * whatever scrolls it, the following page is asked for with the same
11833
+ * size, sort and filters and its rows are appended. An append runs
11834
+ * without `aria-busy`, a spinner in the status bar standing in, and
11835
+ * dispatches `load:success` or `load:failure` as a load does, its
11836
+ * `pageRequest` naming the appended page. A failed append keeps the
11837
+ * rows, shows the error in the status bar with a retry button and
11838
+ * rejects to nobody, nothing awaiting it. Appending stops once the rows
11839
+ * shown reach the total the last answer reported, or an answer carries
11840
+ * no rows. A load supersedes an append in flight; `reload` repeats this
11841
+ * load, not the pages appended to it.
11842
+ *
11843
+ * Under `paging="virtual"` the load shows its page as the window, scrolled
11844
+ * to where it sits among the total, and measures two things on its rows:
11845
+ * the height the empty space standing in for the other rows is sized on,
11846
+ * and the width of the columns, which then stay as they are, in
11847
+ * proportion, until the next load, so that the pages coming and going do
11848
+ * not move them. From then on the window follows the scroll: the page in
11849
+ * view and one on either side stay in the page and the others are dropped,
11850
+ * the page holding the focus kept while it is within two pages of them. A
11851
+ * page next to the window is asked for at once; a scroll landing farther
11852
+ * waits for the scroll to rest. Window pages are asked for without
11853
+ * `aria-busy`, a spinner in the status bar standing in, and dispatch
11854
+ * `load:success` or `load:failure` per page as appends do; a failure keeps
11855
+ * the rows and offers a retry. The row in view stays where it is as pages
11856
+ * come and go above it: the table measures it before and after and
11857
+ * corrects the scroll by whatever the browser's own scroll anchoring has
11858
+ * not kept, which is all of it where the browser anchors no table rows
11859
+ * (Firefox), has no scroll anchoring or the rows' container declares
11860
+ * `overflow-anchor: none`, and nothing where it has. The table carries
11861
+ * `aria-rowcount` and each row its `aria-rowindex`.
11588
11862
  * @param {TablePageRequest} pageRequest
11589
11863
  * @param {TableSortRequest|null} sortRequest
11590
11864
  * @param {Record<string, any>} filterRequest
@@ -11595,6 +11869,13 @@ var fml = (function (exports) {
11595
11869
  this.#loadRequested = true;
11596
11870
  this.#requestedFilters = filterRequest;
11597
11871
  const claim = this.#loads.take();
11872
+ this.#watched = [];
11873
+ this.#endWatch?.disconnect();
11874
+ clearTimeout(this.#jump);
11875
+ this.#shifting = null;
11876
+ this.#windowFailed = false;
11877
+ this.#status?.appending.setAttribute('hidden', '');
11878
+ this.#status?.failure.setAttribute('hidden', '');
11598
11879
  this.#loading.toggleAttribute('hidden', this.#body.childElementCount > 0);
11599
11880
  this.#feedback.setAttribute('hidden', '');
11600
11881
  this.#noAutoload.setAttribute('hidden', '');
@@ -11607,6 +11888,11 @@ var fml = (function (exports) {
11607
11888
  return;
11608
11889
  }
11609
11890
  lastPage = Math.max(0, Math.ceil(pageResponse.size / pageRequest.size) - 1);
11891
+ if (!Number.isFinite(lastPage)) {
11892
+ throw new Error(
11893
+ `ful-table cannot page the loader's answer: it needs { data, size }, size being the total number of rows, and got a size of ${pageResponse.size} for pages of ${pageRequest.size}. A response of another shape needs a response-mapper`,
11894
+ );
11895
+ }
11610
11896
  if (pageRequest.page <= lastPage) {
11611
11897
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
11612
11898
  this.#loaded = true;
@@ -11619,6 +11905,9 @@ var fml = (function (exports) {
11619
11905
  if (claim.stale) {
11620
11906
  return;
11621
11907
  }
11908
+ if (this.#virtual) {
11909
+ this.#settleWindow();
11910
+ }
11622
11911
  this.removeAttribute('aria-busy');
11623
11912
  this.dispatchEvent(
11624
11913
  new CustomEvent('load:success', {
@@ -11627,24 +11916,15 @@ var fml = (function (exports) {
11627
11916
  detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
11628
11917
  }),
11629
11918
  );
11919
+ this.#watchEnd(pageRequest.size);
11920
+ this.#reconcile(false);
11630
11921
  return;
11631
11922
  }
11632
11923
  } catch (/** @type any */ error) {
11633
11924
  if (claim.stale) {
11634
11925
  return;
11635
11926
  }
11636
- this.#loading.setAttribute('hidden', '');
11637
- this.#body.replaceChildren();
11638
- this.#feedback.removeAttribute('hidden');
11639
- this.#feedback.querySelector('[data-ref=feedback-error]').textContent = Localization.of().failure(error);
11640
- this.removeAttribute('aria-busy');
11641
- this.dispatchEvent(
11642
- new CustomEvent('load:failure', {
11643
- bubbles: true,
11644
- cancelable: false,
11645
- detail: { pageRequest, sortRequest, filterRequest, exception: error },
11646
- }),
11647
- );
11927
+ this.#fail(error, { pageRequest, sortRequest, filterRequest });
11648
11928
  throw error;
11649
11929
  }
11650
11930
  return await this.load({ page: lastPage, size: pageRequest.size }, sortRequest, filterRequest);
@@ -11675,19 +11955,471 @@ var fml = (function (exports) {
11675
11955
  filterRequest,
11676
11956
  );
11677
11957
  }
11678
- #update(pageRequest, sortRequest, filterRequest, pageResponse) {
11679
- const pages = Math.ceil(pageResponse.size / pageRequest.size);
11958
+ /**
11959
+ * Watches the last half page of rows, so that any of them coming into
11960
+ * view appends the next page, unless the rows shown already reach the
11961
+ * total or the last answer carried none. A single row would be missed by
11962
+ * a scroll jumping past it, to the end for instance.
11963
+ * @param {number} size the rows a page asks for
11964
+ */
11965
+ #watchEnd(size) {
11966
+ const { shown, total, last } = this.#appended;
11967
+ if (!this.#endWatch || !(shown < total) || last === 0) {
11968
+ return;
11969
+ }
11970
+ const rows = [...this.#body.children];
11971
+ this.#watched = rows.slice(Math.max(0, rows.length - Math.ceil(size / 2)));
11972
+ for (const row of this.#watched) {
11973
+ this.#endWatch.observe(row);
11974
+ }
11975
+ }
11976
+ async #appendNext() {
11977
+ const status = /** @type TableStatus */ (this.#status);
11978
+ this.#watched = [];
11979
+ this.#endWatch?.disconnect();
11980
+ const { sortRequest, filterRequest } = this.#latestRequest;
11981
+ const pageRequest = { page: this.#appended.next, size: this.#latestRequest.pageRequest.size };
11982
+ const claim = this.#loads.take();
11983
+ status.failure.setAttribute('hidden', '');
11984
+ status.appending.removeAttribute('hidden');
11985
+ try {
11986
+ const pageResponse = await this.#loader.load(pageRequest, sortRequest, filterRequest);
11987
+ if (claim.stale) {
11988
+ return;
11989
+ }
11990
+ this.#body.append(
11991
+ this.template('row')
11992
+ .withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
11993
+ .render(),
11994
+ );
11995
+ this.#appended = {
11996
+ next: pageRequest.page + 1,
11997
+ shown: this.#appended.shown + pageResponse.data.length,
11998
+ total: pageResponse.size,
11999
+ last: pageResponse.data.length,
12000
+ };
12001
+ status.count.textContent = Localization.of().t('table.loaded', {
12002
+ loaded: this.#appended.shown,
12003
+ total: this.#appended.total,
12004
+ });
12005
+ await Rendering.waitForChildren(this.#body).catch(() => undefined);
12006
+ if (claim.stale) {
12007
+ return;
12008
+ }
12009
+ status.appending.setAttribute('hidden', '');
12010
+ this.dispatchEvent(
12011
+ new CustomEvent('load:success', {
12012
+ bubbles: true,
12013
+ cancelable: false,
12014
+ detail: { pageRequest, sortRequest, filterRequest, response: pageResponse },
12015
+ }),
12016
+ );
12017
+ this.#watchEnd(pageRequest.size);
12018
+ } catch (/** @type any */ error) {
12019
+ if (claim.stale) {
12020
+ return;
12021
+ }
12022
+ status.appending.setAttribute('hidden', '');
12023
+ status.error.textContent = Localization.of().failure(error);
12024
+ status.failure.removeAttribute('hidden');
12025
+ this.dispatchEvent(
12026
+ new CustomEvent('load:failure', {
12027
+ bubbles: true,
12028
+ cancelable: false,
12029
+ detail: { pageRequest, sortRequest, filterRequest, exception: error },
12030
+ }),
12031
+ );
12032
+ }
12033
+ }
12034
+ /**
12035
+ * Renders the rows of a page, numbering them with `aria-rowindex` under
12036
+ * `paging="virtual"`, the header row being the first.
12037
+ * @param {TablePageRequest} pageRequest
12038
+ * @param {Record<string, any>} filterRequest
12039
+ * @param {TablePageResponse} pageResponse
12040
+ * @returns {Element[]}
12041
+ */
12042
+ #pageRows(pageRequest, filterRequest, pageResponse) {
12043
+ const rows = [
12044
+ ...this.template('row')
12045
+ .withOverlay({ schema: this.#schema, pageRequest, filterRequest, pageResponse })
12046
+ .render().children,
12047
+ ];
12048
+ if (this.#virtual) {
12049
+ rows.forEach((row, i) => {
12050
+ row.setAttribute('aria-rowindex', String(pageRequest.page * pageRequest.size + i + 2));
12051
+ });
12052
+ }
12053
+ return rows;
12054
+ }
12055
+ /**
12056
+ * Shows a failed load: the rows go, the error panel shows and
12057
+ * `load:failure` is dispatched.
12058
+ * @param {any} error
12059
+ * @param {{ pageRequest: TablePageRequest, sortRequest: TableSortRequest|null, filterRequest: Record<string, any> }} request
12060
+ */
12061
+ #fail(error, request) {
11680
12062
  this.#loading.setAttribute('hidden', '');
11681
- this.#body.replaceChildren(
11682
- this.template('row')
11683
- .withOverlay({
11684
- schema: this.#schema,
11685
- pageRequest,
11686
- filterRequest,
11687
- pageResponse,
11688
- })
11689
- .render(),
12063
+ this.#body.replaceChildren();
12064
+ this.#window = null;
12065
+ this.#fitSpacers();
12066
+ this.#status?.count.replaceChildren();
12067
+ this.#feedback.removeAttribute('hidden');
12068
+ this.#feedback.querySelector('[data-ref=feedback-error]').textContent = Localization.of().failure(error);
12069
+ this.removeAttribute('aria-busy');
12070
+ this.dispatchEvent(
12071
+ new CustomEvent('load:failure', {
12072
+ bubbles: true,
12073
+ cancelable: false,
12074
+ detail: { ...request, exception: error },
12075
+ }),
11690
12076
  );
12077
+ }
12078
+ /** Counts the rows of a load just shown and moves the window where its page sits. */
12079
+ #settleWindow() {
12080
+ const table = /** @type HTMLTableElement */ (this.#body.parentElement);
12081
+ table.setAttribute('aria-rowcount', String(this.#total + 1));
12082
+ table.tHead?.rows[0]?.setAttribute('aria-rowindex', '1');
12083
+ const status = /** @type TableStatus */ (this.#status);
12084
+ const window = this.#window;
12085
+ if (!window) {
12086
+ this.#fitSpacers();
12087
+ status.count.textContent = Localization.of().t('table.loaded', { loaded: 0, total: this.#total });
12088
+ return;
12089
+ }
12090
+ const size = this.#latestRequest.pageRequest.size;
12091
+ const from = window.first * size;
12092
+ status.count.textContent = Localization.of().t('table.range', {
12093
+ from: from + 1,
12094
+ to: from + /** @type Element[] */ (window.pages.get(window.first)).length,
12095
+ total: this.#total,
12096
+ });
12097
+ if (this.#measureRows()) {
12098
+ this.#wrapper.scrollTop = from * this.#rowHeight;
12099
+ }
12100
+ }
12101
+ /**
12102
+ * Measures the height of a row and locks the widths of the columns, once
12103
+ * per load, on the rows it shows, then sizes the spacers. A table that is not laid out, inside a
12104
+ * closed disclosure for instance, measures nothing and is measured when
12105
+ * its rows next scroll.
12106
+ * @returns {boolean} whether the rows are measured
12107
+ */
12108
+ #measureRows() {
12109
+ if (this.#rowHeight > 0) {
12110
+ return true;
12111
+ }
12112
+ const count = this.#body.childElementCount;
12113
+ const height = this.#body.getBoundingClientRect().height;
12114
+ if (count === 0 || height === 0) {
12115
+ return false;
12116
+ }
12117
+ this.#rowHeight = height / count;
12118
+ this.#lockColumns();
12119
+ this.#fitSpacers();
12120
+ return true;
12121
+ }
12122
+ /**
12123
+ * Fixes the widths of the columns, in proportion to the table, at those
12124
+ * the rows in the page give them.
12125
+ */
12126
+ #lockColumns() {
12127
+ const table = /** @type HTMLTableElement */ (this.#body.parentElement);
12128
+ const widths = [...(table.tHead?.rows[0]?.cells ?? [])].map((cell) => cell.getBoundingClientRect().width);
12129
+ const sum = widths.reduce((a, b) => a + b, 0);
12130
+ const columns = document.createElement('colgroup');
12131
+ columns.setAttribute('data-ref', 'widths');
12132
+ for (const width of widths) {
12133
+ const column = document.createElement('col');
12134
+ column.style.width = `${(width / sum) * 100}%`;
12135
+ columns.append(column);
12136
+ }
12137
+ table.insertBefore(columns, table.tHead);
12138
+ }
12139
+ #unlockColumns() {
12140
+ this.#body.parentElement?.querySelector(':scope > colgroup[data-ref=widths]')?.remove();
12141
+ }
12142
+ /** Sizes the space standing in for the rows above and below the window. */
12143
+ #fitSpacers() {
12144
+ if (!this.#spacers) {
12145
+ return;
12146
+ }
12147
+ const window = this.#window;
12148
+ const size = this.#latestRequest.pageRequest.size;
12149
+ const above = window ? window.first * size : 0;
12150
+ const below = window ? Math.max(0, this.#total - (window.last + 1) * size) : 0;
12151
+ this.#spacers.before.style.height = `${above * this.#rowHeight}px`;
12152
+ this.#spacers.after.style.height = `${below * this.#rowHeight}px`;
12153
+ }
12154
+ /** Says in the status bar which rows are in view; nothing changes while none is. */
12155
+ #countInView() {
12156
+ const status = /** @type TableStatus */ (this.#status);
12157
+ const size = this.#latestRequest.pageRequest.size;
12158
+ const view = this.#wrapper.getBoundingClientRect();
12159
+ const top = view.top + (this.#body.parentElement?.querySelector(':scope > thead')?.getBoundingClientRect().height ?? 0);
12160
+ let from = 0;
12161
+ let to = 0;
12162
+ for (const [page, rows] of this.#window?.pages ?? []) {
12163
+ rows.forEach((row, i) => {
12164
+ const rect = row.getBoundingClientRect();
12165
+ if (rect.bottom > top && rect.top < view.bottom) {
12166
+ from ||= page * size + i + 1;
12167
+ to = page * size + i + 1;
12168
+ }
12169
+ });
12170
+ }
12171
+ if (from > 0) {
12172
+ status.count.textContent = Localization.of().t('table.range', { from, to, total: this.#total });
12173
+ }
12174
+ }
12175
+ /**
12176
+ * The page at the middle of the view: found among the rows in the page,
12177
+ * or estimated on the height of a row when the middle falls in the space
12178
+ * standing in for others.
12179
+ * @param {{ first: number, last: number, pages: Map<number, Element[]> }} window
12180
+ * @returns {number}
12181
+ */
12182
+ #pageInView(window) {
12183
+ const pageHeight = this.#latestRequest.pageRequest.size * this.#rowHeight;
12184
+ const view = this.#wrapper.getBoundingClientRect();
12185
+ const middle = view.top + view.height / 2;
12186
+ const rows = this.#body.getBoundingClientRect();
12187
+ if (middle < rows.top) {
12188
+ return Math.max(0, window.first - Math.ceil((rows.top - middle) / pageHeight));
12189
+ }
12190
+ if (middle >= rows.bottom) {
12191
+ return window.last + 1 + Math.floor((middle - rows.bottom) / pageHeight);
12192
+ }
12193
+ let found = window.first;
12194
+ for (const [page, pageRows] of window.pages) {
12195
+ if (pageRows.length > 0 && pageRows[0].getBoundingClientRect().top <= middle) {
12196
+ found = page;
12197
+ }
12198
+ }
12199
+ return found;
12200
+ }
12201
+ /**
12202
+ * Moves the window to the page in view and its neighbours. A window next
12203
+ * to the current one moves at once; one farther away waits for the scroll
12204
+ * to rest, unless `settled` says it has.
12205
+ * @param {boolean} settled
12206
+ */
12207
+ #reconcile(settled) {
12208
+ const window = this.#window;
12209
+ const pageCount = Math.ceil(this.#total / this.#latestRequest.pageRequest.size);
12210
+ if (
12211
+ !window ||
12212
+ this.#shifting ||
12213
+ this.#windowFailed ||
12214
+ this.hasAttribute('aria-busy') ||
12215
+ !this.#wrapper.isConnected ||
12216
+ !(pageCount > 0) ||
12217
+ !this.#measureRows()
12218
+ ) {
12219
+ return;
12220
+ }
12221
+ const target = Math.min(pageCount - 1, this.#pageInView(window));
12222
+ let first = Math.max(0, target - 1);
12223
+ let last = Math.min(pageCount - 1, target + 1);
12224
+ const focused = this.#body.contains(document.activeElement)
12225
+ ? /** @type Element */ (document.activeElement).closest('tr')
12226
+ : null;
12227
+ const focusedPage = [...window.pages].find(([, rows]) => focused && rows.includes(focused))?.[0];
12228
+ if (focusedPage !== undefined && Math.max(last, focusedPage) - Math.min(first, focusedPage) <= 4) {
12229
+ first = Math.min(first, focusedPage);
12230
+ last = Math.max(last, focusedPage);
12231
+ }
12232
+ if (first === window.first && last === window.last) {
12233
+ return;
12234
+ }
12235
+ clearTimeout(this.#jump);
12236
+ if (!settled && (last < window.first - 1 || first > window.last + 1)) {
12237
+ this.#jump = setTimeout(() => this.#reconcile(true), 150);
12238
+ return;
12239
+ }
12240
+ this.#shiftWindow(window, first, last);
12241
+ }
12242
+ /**
12243
+ * Asks for the pages of the new window that are not in the page, drops
12244
+ * the ones outside it and keeps the row in view where it is.
12245
+ * @param {{ first: number, last: number, pages: Map<number, Element[]> }} window
12246
+ * @param {number} first
12247
+ * @param {number} last
12248
+ */
12249
+ async #shiftWindow(window, first, last) {
12250
+ const status = /** @type TableStatus */ (this.#status);
12251
+ const claim = this.#loads.hold();
12252
+ this.#shifting = claim;
12253
+ const { sortRequest, filterRequest } = this.#latestRequest;
12254
+ const size = this.#latestRequest.pageRequest.size;
12255
+ const wanted = Array.from({ length: last - first + 1 }, (_, i) => first + i);
12256
+ const missing = wanted.filter((page) => !window.pages.has(page));
12257
+ status.failure.setAttribute('hidden', '');
12258
+ status.appending.removeAttribute('hidden');
12259
+ try {
12260
+ const answers = await Promise.all(
12261
+ missing.map(async (page) => {
12262
+ const pageRequest = { page, size };
12263
+ return { pageRequest, response: await this.#loader.load(pageRequest, sortRequest, filterRequest) };
12264
+ }),
12265
+ );
12266
+ if (claim.stale) {
12267
+ return;
12268
+ }
12269
+ const kept = wanted.filter((page) => window.pages.has(page));
12270
+ const top = this.#wrapper.getBoundingClientRect().top;
12271
+ const anchor = kept
12272
+ .flatMap((page) => window.pages.get(page) ?? [])
12273
+ .find((row) => row.getBoundingClientRect().bottom > top);
12274
+ const anchorTop = anchor?.getBoundingClientRect().top ?? 0;
12275
+ for (const [page, rows] of window.pages) {
12276
+ if (page < first || page > last) {
12277
+ for (const row of rows) {
12278
+ row.remove();
12279
+ }
12280
+ }
12281
+ }
12282
+ /** @type {Map<number, Element[]>} */
12283
+ const pages = new Map();
12284
+ for (const page of wanted) {
12285
+ const answer = answers.find((a) => a.pageRequest.page === page);
12286
+ pages.set(
12287
+ page,
12288
+ window.pages.get(page) ??
12289
+ (answer ? this.#pageRows(answer.pageRequest, filterRequest, answer.response) : []),
12290
+ );
12291
+ }
12292
+ if (kept.length === 0) {
12293
+ this.#body.replaceChildren(...[...pages.values()].flat());
12294
+ } else {
12295
+ this.#body.prepend(...wanted.filter((page) => page < kept[0]).flatMap((page) => pages.get(page) ?? []));
12296
+ this.#body.append(
12297
+ ...wanted.filter((page) => page > /** @type number */ (kept.at(-1))).flatMap((page) => pages.get(page) ?? []),
12298
+ );
12299
+ }
12300
+ this.#window = { first, last, pages };
12301
+ this.#total = answers.at(-1)?.response.size ?? this.#total;
12302
+ this.#body.parentElement?.setAttribute('aria-rowcount', String(this.#total + 1));
12303
+ this.#fitSpacers();
12304
+ if (anchor) {
12305
+ this.#wrapper.scrollTop += anchor.getBoundingClientRect().top - anchorTop;
12306
+ }
12307
+ await Rendering.waitForChildren(this.#body).catch(() => undefined);
12308
+ if (claim.stale) {
12309
+ return;
12310
+ }
12311
+ if (anchor) {
12312
+ // the elements in the new rows have rendered since, which can change their height
12313
+ this.#wrapper.scrollTop += anchor.getBoundingClientRect().top - anchorTop;
12314
+ }
12315
+ status.appending.setAttribute('hidden', '');
12316
+ this.#countInView();
12317
+ for (const { pageRequest, response } of answers) {
12318
+ this.dispatchEvent(
12319
+ new CustomEvent('load:success', {
12320
+ bubbles: true,
12321
+ cancelable: false,
12322
+ detail: { pageRequest, sortRequest, filterRequest, response },
12323
+ }),
12324
+ );
12325
+ }
12326
+ } catch (/** @type any */ error) {
12327
+ if (claim.stale) {
12328
+ return;
12329
+ }
12330
+ this.#windowFailed = true;
12331
+ status.appending.setAttribute('hidden', '');
12332
+ status.error.textContent = Localization.of().failure(error);
12333
+ status.failure.removeAttribute('hidden');
12334
+ this.dispatchEvent(
12335
+ new CustomEvent('load:failure', {
12336
+ bubbles: true,
12337
+ cancelable: false,
12338
+ detail: { pageRequest: { page: missing[0], size }, sortRequest, filterRequest, exception: error },
12339
+ }),
12340
+ );
12341
+ return;
12342
+ } finally {
12343
+ if (this.#shifting === claim) {
12344
+ this.#shifting = null;
12345
+ }
12346
+ }
12347
+ this.#reconcile(false);
12348
+ }
12349
+ /**
12350
+ * Asks again for the pages of the window and shows them where they are.
12351
+ * @returns {Promise<void>}
12352
+ */
12353
+ async #reloadWindow() {
12354
+ const window = /** @type {{ first: number, last: number, pages: Map<number, Element[]> }} */ (this.#window);
12355
+ const status = /** @type TableStatus */ (this.#status);
12356
+ const { sortRequest, filterRequest } = this.#latestRequest;
12357
+ const size = this.#latestRequest.pageRequest.size;
12358
+ this.#requestedFilters = filterRequest;
12359
+ const claim = this.#loads.take();
12360
+ clearTimeout(this.#jump);
12361
+ this.#shifting = null;
12362
+ this.#windowFailed = false;
12363
+ status.appending.setAttribute('hidden', '');
12364
+ status.failure.setAttribute('hidden', '');
12365
+ this.setAttribute('aria-busy', 'true');
12366
+ const wanted = Array.from({ length: window.last - window.first + 1 }, (_, i) => window.first + i);
12367
+ try {
12368
+ const answers = await Promise.all(
12369
+ wanted.map(async (page) => {
12370
+ const pageRequest = { page, size };
12371
+ return { pageRequest, response: await this.#loader.load(pageRequest, sortRequest, filterRequest) };
12372
+ }),
12373
+ );
12374
+ if (claim.stale) {
12375
+ return;
12376
+ }
12377
+ const total = /** @type {{ response: TablePageResponse }} */ (answers.at(-1)).response.size;
12378
+ const pageCount = Math.ceil(total / size);
12379
+ if (!(window.first < pageCount)) {
12380
+ return await this.load({ page: 0, size }, sortRequest, filterRequest);
12381
+ }
12382
+ const shown = answers.filter((a) => a.pageRequest.page < pageCount);
12383
+ const pages = new Map(
12384
+ shown.map((a) => [a.pageRequest.page, this.#pageRows(a.pageRequest, filterRequest, a.response)]),
12385
+ );
12386
+ this.#body.replaceChildren(...[...pages.values()].flat());
12387
+ this.#window = { first: window.first, last: Math.min(window.last, pageCount - 1), pages };
12388
+ this.#total = total;
12389
+ this.#body.parentElement?.setAttribute('aria-rowcount', String(total + 1));
12390
+ this.#fitSpacers();
12391
+ await Rendering.waitForChildren(this.#body).catch(() => undefined);
12392
+ if (claim.stale) {
12393
+ return;
12394
+ }
12395
+ this.removeAttribute('aria-busy');
12396
+ this.#countInView();
12397
+ for (const { pageRequest, response } of shown) {
12398
+ this.dispatchEvent(
12399
+ new CustomEvent('load:success', {
12400
+ bubbles: true,
12401
+ cancelable: false,
12402
+ detail: { pageRequest, sortRequest, filterRequest, response },
12403
+ }),
12404
+ );
12405
+ }
12406
+ this.#reconcile(false);
12407
+ } catch (/** @type any */ error) {
12408
+ if (claim.stale) {
12409
+ return;
12410
+ }
12411
+ this.#fail(error, { pageRequest: { page: window.first, size }, sortRequest, filterRequest });
12412
+ throw error;
12413
+ }
12414
+ }
12415
+ #update(pageRequest, sortRequest, filterRequest, pageResponse) {
12416
+ this.#loading.setAttribute('hidden', '');
12417
+ if (this.#virtual) {
12418
+ this.#rowHeight = 0;
12419
+ this.#unlockColumns();
12420
+ }
12421
+ const rows = this.#pageRows(pageRequest, filterRequest, pageResponse);
12422
+ this.#body.replaceChildren(...rows);
11691
12423
  this.#empty.toggleAttribute('hidden', pageResponse.data.length !== 0);
11692
12424
  if (this.#emptyTemplate && pageResponse.data.length === 0) {
11693
12425
  this.#emptyAuthors.replaceChildren(
@@ -11696,7 +12428,28 @@ var fml = (function (exports) {
11696
12428
  .render(),
11697
12429
  );
11698
12430
  }
11699
- this.#paginator.update({ current: pageRequest.page, total: pages });
12431
+ if (this.#virtual) {
12432
+ this.#wrapper.scrollTop = 0;
12433
+ this.#total = pageResponse.size;
12434
+ this.#window =
12435
+ rows.length > 0 ? { first: pageRequest.page, last: pageRequest.page, pages: new Map([[pageRequest.page, rows]]) } : null;
12436
+ return;
12437
+ }
12438
+ if (this.#status) {
12439
+ this.#wrapper.scrollTop = 0;
12440
+ this.#appended = {
12441
+ next: pageRequest.page + 1,
12442
+ shown: pageResponse.data.length,
12443
+ total: pageResponse.size,
12444
+ last: pageResponse.data.length,
12445
+ };
12446
+ this.#status.count.textContent = Localization.of().t('table.loaded', {
12447
+ loaded: this.#appended.shown,
12448
+ total: this.#appended.total,
12449
+ });
12450
+ return;
12451
+ }
12452
+ this.#paginator.update({ current: pageRequest.page, total: Math.ceil(pageResponse.size / pageRequest.size) });
11700
12453
  }
11701
12454
  }
11702
12455
 
@@ -12872,8 +13625,10 @@ var fml = (function (exports) {
12872
13625
  * failure's problems, one per line, or its message when it carries none.
12873
13626
  */
12874
13627
  class SectionRequests {
12875
- /** @type {WeakSet<Element>} */
13628
+ /** @type {WeakSet<Element>} the sections a first delivery succeeded for */
12876
13629
  #entered = new WeakSet();
13630
+ /** @type {WeakSet<Element>} the sections whose first delivery is in flight */
13631
+ #entering = new WeakSet();
12877
13632
  /** @type {WeakMap<Element, Claims>} */
12878
13633
  #claims = new WeakMap();
12879
13634
 
@@ -12882,9 +13637,12 @@ var fml = (function (exports) {
12882
13637
  * is given, then `section:requested:<name>` when a name is given, each
12883
13638
  * bubbling from `host` with the same detail `{ name, section, index, first }`,
12884
13639
  * and awaits every answer attached to any of them. `first` is true until a
12885
- * request for the section is answered by at least one listener, so a request
12886
- * nobody listened to does not spend it. A request with answers removes the
12887
- * error a previous failure painted before it waits.
13640
+ * delivery of the section succeeds: a request nobody listened to does not
13641
+ * spend it, and neither does one whose answers failed, so the next request
13642
+ * is a first one again. While a first delivery is in flight a newer request
13643
+ * for the same section carries `first` false, so the content is not
13644
+ * delivered twice. A request with answers removes the error a previous
13645
+ * failure painted before it waits.
12888
13646
  * @param {Element} host the component the events are dispatched on
12889
13647
  * @param {Element} section the element the answers deliver into
12890
13648
  * @param {string|null} name the section's `data-step`, when it has one
@@ -12895,7 +13653,7 @@ var fml = (function (exports) {
12895
13653
  * problems in the section unless a newer request superseded this one
12896
13654
  */
12897
13655
  async request(host, section, name, index) {
12898
- const first = !this.#entered.has(section);
13656
+ const first = !this.#entered.has(section) && !this.#entering.has(section);
12899
13657
  const detail = { name, section, index, first };
12900
13658
  const types = [
12901
13659
  'section:requested',
@@ -12913,7 +13671,9 @@ var fml = (function (exports) {
12913
13671
  if (promises.length === 0) {
12914
13672
  return undefined;
12915
13673
  }
12916
- this.#entered.add(section);
13674
+ if (first) {
13675
+ this.#entering.add(section);
13676
+ }
12917
13677
  let claims = this.#claims.get(section);
12918
13678
  if (!claims) {
12919
13679
  claims = new Claims();
@@ -12929,13 +13689,20 @@ var fml = (function (exports) {
12929
13689
  }
12930
13690
  });
12931
13691
  try {
12932
- return await Promise.all(promises);
13692
+ const answers = await Promise.all(promises);
13693
+ if (first) {
13694
+ this.#entered.add(section);
13695
+ }
13696
+ return answers;
12933
13697
  } catch (cause) {
12934
13698
  if (owned()) {
12935
13699
  this.#paintError(section, cause);
12936
13700
  }
12937
13701
  throw cause;
12938
13702
  } finally {
13703
+ if (first) {
13704
+ this.#entering.delete(section);
13705
+ }
12939
13706
  cancelAnimationFrame(frame);
12940
13707
  if (owned()) {
12941
13708
  section.toggleAttribute('loading', false);
@@ -13052,15 +13819,27 @@ var fml = (function (exports) {
13052
13819
  /**
13053
13820
  * Calls `dismiss` when a press and its release both land on the backdrop,
13054
13821
  * so a selection dragged out of the panel and released outside does not.
13822
+ * The backdrop is where the pointer is outside the dialog's rectangle: a
13823
+ * part of the panel no child covers, the room around the error section
13824
+ * for one, is the panel.
13055
13825
  * @param {() => void} dismiss
13056
13826
  */
13057
13827
  onBackdrop(dismiss) {
13828
+ const onBackdrop = (/** @type MouseEvent */ e) => {
13829
+ if (e.target !== this.#dialog) {
13830
+ return false;
13831
+ }
13832
+ const panel = this.#dialog.getBoundingClientRect();
13833
+ return (
13834
+ e.clientX < panel.left || e.clientX > panel.right || e.clientY < panel.top || e.clientY > panel.bottom
13835
+ );
13836
+ };
13058
13837
  let pressedOutside = false;
13059
13838
  this.#dialog.addEventListener('mousedown', (e) => {
13060
- pressedOutside = e.target === this.#dialog;
13839
+ pressedOutside = onBackdrop(e);
13061
13840
  });
13062
13841
  this.#dialog.addEventListener('click', (e) => {
13063
- if (pressedOutside && e.target === this.#dialog) {
13842
+ if (pressedOutside && onBackdrop(e)) {
13064
13843
  dismiss();
13065
13844
  }
13066
13845
  });
@@ -13337,7 +14116,7 @@ var fml = (function (exports) {
13337
14116
  * opening, hides the error and loading sections, and dispatches
13338
14117
  * `section:requested` on the element (bubbling, with
13339
14118
  * `detail: { section, first, name: null, index: null }`, `section` being
13340
- * the body and `first` true only on the first answered request), so a
14119
+ * the body and `first` true until a delivery succeeds), so a
13341
14120
  * listener can fill the body. A failed request is painted into the body
13342
14121
  * and does not reject. On a dialog already open nothing is shown or
13343
14122
  * requested, and the call waits for the same ending as the calls before it;
@@ -13589,11 +14368,7 @@ var fml = (function (exports) {
13589
14368
  #answer = null;
13590
14369
  #resolvers = [];
13591
14370
  #closing = false;
13592
- #slideOutEnded = (/** @type AnimationEvent */ e) => {
13593
- if (e.target === this.#dialog) {
13594
- this.#closeNow();
13595
- }
13596
- };
14371
+ #slides = new Claims();
13597
14372
  /**
13598
14373
  * @param {{ slots: Record<string, DocumentFragment> }} c
13599
14374
  */
@@ -13720,7 +14495,7 @@ var fml = (function (exports) {
13720
14495
  * opening, hides the error and loading sections, and dispatches
13721
14496
  * `section:requested` on the element (bubbling, with
13722
14497
  * `detail: { section, first, name: null, index: null }`, `section` being
13723
- * the content section and `first` true only on the first answered request),
14498
+ * the content section and `first` true until a delivery succeeds),
13724
14499
  * so a listener can fill the content. A failed request is painted into the
13725
14500
  * content section and does not reject. On a drawer already open nothing is
13726
14501
  * shown or requested, except that one sliding out stops and stays open,
@@ -13771,11 +14546,13 @@ var fml = (function (exports) {
13771
14546
  /**
13772
14547
  * Slides the drawer out and closes it when the animation ends, setting the
13773
14548
  * `closing` attribute on the native dialog meanwhile; the `close` event is
13774
- * dispatched then, after this call has returned. Where the user prefers
13775
- * reduced motion, or no animation runs, it closes at once. A non-empty
13776
- * result answers as the button carrying that `data-result` would; no
13777
- * result, or an empty one, is a dismissal. Does nothing on a drawer that is
13778
- * closed or already sliding out.
14549
+ * dispatched then, after this call has returned. An animation the page
14550
+ * cancels, by hiding the drawer or an ancestor of it, closes it as one
14551
+ * that ended does. Where the user prefers reduced motion, or no animation
14552
+ * runs, it closes at once. A non-empty result answers as the button
14553
+ * carrying that `data-result` would; no result, or an empty one, is a
14554
+ * dismissal. Does nothing on a drawer that is closed or already sliding
14555
+ * out.
13779
14556
  * @param {string} [result]
13780
14557
  */
13781
14558
  close(result) {
@@ -13790,12 +14567,18 @@ var fml = (function (exports) {
13790
14567
  return;
13791
14568
  }
13792
14569
  this.#dialog.setAttribute('closing', '');
13793
- if (this.#dialog.getAnimations().length === 0) {
14570
+ const animations = this.#dialog.getAnimations();
14571
+ if (animations.length === 0) {
13794
14572
  this.#closeNow();
13795
14573
  return;
13796
14574
  }
13797
14575
  this.#closing = true;
13798
- this.#dialog.addEventListener('animationend', this.#slideOutEnded);
14576
+ const slide = this.#slides.take();
14577
+ Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
14578
+ if (!slide.stale) {
14579
+ this.#closeNow();
14580
+ }
14581
+ });
13799
14582
  }
13800
14583
  #closeNow() {
13801
14584
  this.#stopSlidingOut();
@@ -13803,7 +14586,7 @@ var fml = (function (exports) {
13803
14586
  }
13804
14587
  #stopSlidingOut() {
13805
14588
  this.#closing = false;
13806
- this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
14589
+ this.#slides.invalidate();
13807
14590
  this.#dialog.removeAttribute('closing');
13808
14591
  }
13809
14592
  /** @returns {boolean} whether this call is the one that opened the modal */
@@ -14348,7 +15131,7 @@ var fml = (function (exports) {
14348
15131
  }
14349
15132
  /**
14350
15133
  * Fires the section requests again for a panel, whether it is visible or
14351
- * not, with `first` false once an earlier request was answered.
15134
+ * not, with `first` false once an earlier delivery succeeded.
14352
15135
  * @param {number | Element} ref the panel's index, or the panel itself
14353
15136
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
14354
15137
  * the answers, or to undefined when nobody listened or the delivery
@@ -14571,7 +15354,7 @@ var fml = (function (exports) {
14571
15354
  }
14572
15355
  /**
14573
15356
  * Fires the section requests again for a section, whether it is current or
14574
- * not, with `first` false once an earlier request was answered.
15357
+ * not, with `first` false once an earlier delivery succeeded.
14575
15358
  * @param {string | Element} ref the section's `data-step`, or the section
14576
15359
  * itself
14577
15360
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
@@ -14633,6 +15416,9 @@ var fml = (function (exports) {
14633
15416
  'table.initial': 'Start searching to see results.',
14634
15417
  'table.error': 'Error while loading data:',
14635
15418
  'table.no-data': 'No elements found.',
15419
+ 'table.loaded': 'Showing {loaded} of {total}',
15420
+ 'table.range': 'Rows {from}-{to} of {total}',
15421
+ 'table.retry': 'Retry',
14636
15422
  'dropdown.empty': 'No results',
14637
15423
  'select.remove': 'Remove',
14638
15424
  'files.dropzone-label': 'Click or drop your files here',
@@ -14684,6 +15470,9 @@ var fml = (function (exports) {
14684
15470
  'table.initial': 'Avvia la ricerca per visualizzare i risultati.',
14685
15471
  'table.error': 'Errore nel caricamento dei dati:',
14686
15472
  'table.no-data': 'Nessun elemento trovato.',
15473
+ 'table.loaded': 'Mostrati {loaded} di {total}',
15474
+ 'table.range': 'Righe {from}-{to} di {total}',
15475
+ 'table.retry': 'Riprova',
14687
15476
  'dropdown.empty': 'Nessun risultato',
14688
15477
  'select.remove': 'Rimuovi',
14689
15478
  'files.dropzone-label': 'Clicca o trascina i file qui',
@@ -14735,6 +15524,9 @@ var fml = (function (exports) {
14735
15524
  'table.initial': 'Inicia la búsqueda para ver los resultados.',
14736
15525
  'table.error': 'Error al cargar los datos:',
14737
15526
  'table.no-data': 'No se encontraron elementos.',
15527
+ 'table.loaded': 'Mostrando {loaded} de {total}',
15528
+ 'table.range': 'Filas {from}-{to} de {total}',
15529
+ 'table.retry': 'Reintentar',
14738
15530
  'dropdown.empty': 'Sin resultados',
14739
15531
  'select.remove': 'Eliminar',
14740
15532
  'files.dropzone-label': 'Haz clic o arrastra tus archivos aquí',
@@ -14789,6 +15581,9 @@ var fml = (function (exports) {
14789
15581
  'table.initial': 'Lancez la recherche pour voir les résultats.',
14790
15582
  'table.error': 'Erreur lors du chargement des données :',
14791
15583
  'table.no-data': 'Aucun élément trouvé.',
15584
+ 'table.loaded': 'Affichage de {loaded} sur {total}',
15585
+ 'table.range': 'Lignes {from} à {to} sur {total}',
15586
+ 'table.retry': 'Réessayer',
14792
15587
  'dropdown.empty': 'Aucun résultat',
14793
15588
  'select.remove': 'Retirer',
14794
15589
  'files.dropzone-label': 'Cliquez ou déposez vos fichiers ici',
@@ -14850,8 +15645,11 @@ var fml = (function (exports) {
14850
15645
  /**
14851
15646
  * @param {{ language?: string, translations?: Record<string, any>, httpClient?: HttpClient }} [options]
14852
15647
  * `language` is fixed for the page: a full BCP-47 tag or a primary subtag,
14853
- * defaulting to `navigator.language`, then to 'en'. Its primary subtag,
14854
- * lowercased, picks the built-in translations (en, it, es, fr); the messages
15648
+ * defaulting to `navigator.language`, then to 'en', an empty one included.
15649
+ * It is canonicalized (`IT-it` and `it_IT` both read `it-IT`), and one that
15650
+ * is no language tag at all throws here, rather than at the first message
15651
+ * or number formatted in it. Its primary subtag picks the built-in
15652
+ * translations (en, it, es, fr); the messages
14855
15653
  * are the english ones, overridden by that language's, overridden by
14856
15654
  * `translations`, so a language without built-in translations reads in
14857
15655
  * english. `translations` is a flat map of keys to messages: reword built-in
@@ -14859,9 +15657,18 @@ var fml = (function (exports) {
14859
15657
  * `httpClient` is the client every ful component fetches through, registered
14860
15658
  * as the `http-client` component; without one, `configure` builds a client
14861
15659
  * that sends the csrf token and navigates to '/' on a 401 response.
15660
+ * @throws {RangeError} when `language` is not a language tag
14862
15661
  */
14863
15662
  constructor(options = {}) {
14864
- this.#language = options.language ?? navigator?.language ?? 'en';
15663
+ const language = options.language || navigator?.language || 'en';
15664
+ try {
15665
+ // unicode locale identifiers separate subtags with an underscore too, Intl takes the dash alone
15666
+ this.#language = Intl.getCanonicalLocales(String(language).replaceAll('_', '-'))[0];
15667
+ } catch {
15668
+ throw new RangeError(
15669
+ `Plugin: language '${language}' is not a language tag: pass a BCP-47 tag such as 'it' or 'it-IT', or leave it out for the browser's language`,
15670
+ );
15671
+ }
14865
15672
  this.#translations = options.translations ?? {};
14866
15673
  this.#httpClient = options.httpClient ?? null;
14867
15674
  }
@@ -14870,7 +15677,7 @@ var fml = (function (exports) {
14870
15677
  * Defines on `registry` the `l10n` module, the `http-client` component,
14871
15678
  * every `ful-*` element, the `loaders:select`, `loaders:form` and
14872
15679
  * `loaders:table` components, and an overlay publishing `l10n` (the merged
14873
- * messages) and `locale` (the `language` as given) to every template.
15680
+ * messages) and `locale` (the `language`, canonicalized) to every template.
14874
15681
  * @param {{ defineModule(name: string, module: any): any, defineComponent(name: string, component: any): any, defineElement(tag: string, klass: CustomElementConstructor): any, defineOverlay(...data: any[]): any }} registry
14875
15682
  * the ftl `Registry`, each of those methods answering the registry itself
14876
15683
  * @returns {void}
@@ -14878,7 +15685,7 @@ var fml = (function (exports) {
14878
15685
  configure(registry) {
14879
15686
  const httpClient =
14880
15687
  this.#httpClient ?? HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
14881
- const language = this.#language.split('-')[0].toLowerCase();
15688
+ const language = this.#language.split('-')[0];
14882
15689
  const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
14883
15690
  registry
14884
15691
  .defineModule('l10n', Localization)
@@ -14961,7 +15768,6 @@ var fml = (function (exports) {
14961
15768
  SortButton: SortButton,
14962
15769
  Table: Table,
14963
15770
  TableLoader: TableLoader,
14964
- TableSchemaParser: TableSchemaParser,
14965
15771
  Tabs: Tabs,
14966
15772
  TextFilter: TextFilter,
14967
15773
  Timing: Timing,
@@ -15040,7 +15846,6 @@ var fml = (function (exports) {
15040
15846
  exports.SortButton = SortButton;
15041
15847
  exports.Table = Table;
15042
15848
  exports.TableLoader = TableLoader;
15043
- exports.TableSchemaParser = TableSchemaParser;
15044
15849
  exports.Tabs = Tabs;
15045
15850
  exports.Template = Template;
15046
15851
  exports.Templates = Templates;