@optionfactory/fml 9.0.0-rc30 → 9.0.0-rc31

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]');
@@ -10786,6 +10889,10 @@ var fml = (function (exports) {
10786
10889
  * current `order` in the cycle `asc`, `desc`, `null`. The button does not
10787
10890
  * change its own `order`: whoever handles the event sets it, which `ful-table`
10788
10891
  * does once the sorted page has loaded.
10892
+ *
10893
+ * The stylesheet draws an up arrow for `asc` and a down arrow for `desc`. An
10894
+ * unsorted button shows the up arrow dimmed, `asc` being the order its first
10895
+ * activation asks for.
10789
10896
  */
10790
10897
  class SortButton extends ParsedElement {
10791
10898
  static attributes = ['sorter'];
@@ -11362,18 +11469,19 @@ var fml = (function (exports) {
11362
11469
  * writing the size is not a request to start loading, which is what
11363
11470
  * `autoload` and `reload()` are for. A write that does not change the size
11364
11471
  * does nothing. A write made before the render is replaced by the declared
11365
- * `page-size` when the table renders.
11472
+ * `page-size` when the table renders, or by ten when that is not a number
11473
+ * above zero.
11366
11474
  *
11367
11475
  * The reload is not awaited: a failure shows the error panel and its
11368
11476
  * rejection is unhandled.
11369
11477
  * @param {number|string|null|undefined} value a number or a string of
11370
11478
  * one, such as a select's value; absent or null is the default of ten,
11371
11479
  * so removing the attribute restores it, and a value that is not a
11372
- * number is ignored
11480
+ * finite number above zero is ignored
11373
11481
  */
11374
11482
  set pageSize(value) {
11375
11483
  const size = Number(value ?? 10);
11376
- if (Number.isNaN(size) || size === this.#latestRequest.pageRequest.size) {
11484
+ if (!Number.isFinite(size) || size <= 0 || size === this.#latestRequest.pageRequest.size) {
11377
11485
  return;
11378
11486
  }
11379
11487
  this.#latestRequest = { ...this.#latestRequest, pageRequest: { page: 0, size } };
@@ -11492,10 +11600,11 @@ var fml = (function (exports) {
11492
11600
  await Rendering.waitForChildren(this);
11493
11601
 
11494
11602
  const maybeForm = /** @type any */ (Nodes.queryChildren(this, 'ful-form'));
11603
+ const declaredSize = this.declared('page-size');
11495
11604
  this.#latestRequest = {
11496
11605
  pageRequest: {
11497
11606
  page: 0,
11498
- size: this.declared('page-size') ?? 10,
11607
+ size: Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : 10,
11499
11608
  },
11500
11609
  sortRequest: schema.sort,
11501
11610
  filterRequest: maybeForm?.values ?? {},
@@ -11579,7 +11688,10 @@ var fml = (function (exports) {
11579
11688
  * `aria-busy` is removed, `load:failure` is dispatched (bubbling, not
11580
11689
  * cancelable) with `detail` set to
11581
11690
  * `{ pageRequest, sortRequest, filterRequest, exception }`, and the
11582
- * returned promise rejects with the same error.
11691
+ * returned promise rejects with the same error. An answer the table cannot
11692
+ * page fails the same way, with an error saying so: one whose `size` is
11693
+ * not a number, as a response of another shape read without a
11694
+ * `response-mapper` is, or a page size of zero.
11583
11695
  *
11584
11696
  * A load started after this one supersedes it: when this one's answer or
11585
11697
  * failure arrives it changes nothing, dispatches nothing and resolves;
@@ -11607,6 +11719,11 @@ var fml = (function (exports) {
11607
11719
  return;
11608
11720
  }
11609
11721
  lastPage = Math.max(0, Math.ceil(pageResponse.size / pageRequest.size) - 1);
11722
+ if (!Number.isFinite(lastPage)) {
11723
+ throw new Error(
11724
+ `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`,
11725
+ );
11726
+ }
11610
11727
  if (pageRequest.page <= lastPage) {
11611
11728
  this.#latestRequest = { pageRequest, sortRequest, filterRequest };
11612
11729
  this.#loaded = true;
@@ -12872,8 +12989,10 @@ var fml = (function (exports) {
12872
12989
  * failure's problems, one per line, or its message when it carries none.
12873
12990
  */
12874
12991
  class SectionRequests {
12875
- /** @type {WeakSet<Element>} */
12992
+ /** @type {WeakSet<Element>} the sections a first delivery succeeded for */
12876
12993
  #entered = new WeakSet();
12994
+ /** @type {WeakSet<Element>} the sections whose first delivery is in flight */
12995
+ #entering = new WeakSet();
12877
12996
  /** @type {WeakMap<Element, Claims>} */
12878
12997
  #claims = new WeakMap();
12879
12998
 
@@ -12882,9 +13001,12 @@ var fml = (function (exports) {
12882
13001
  * is given, then `section:requested:<name>` when a name is given, each
12883
13002
  * bubbling from `host` with the same detail `{ name, section, index, first }`,
12884
13003
  * 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.
13004
+ * delivery of the section succeeds: a request nobody listened to does not
13005
+ * spend it, and neither does one whose answers failed, so the next request
13006
+ * is a first one again. While a first delivery is in flight a newer request
13007
+ * for the same section carries `first` false, so the content is not
13008
+ * delivered twice. A request with answers removes the error a previous
13009
+ * failure painted before it waits.
12888
13010
  * @param {Element} host the component the events are dispatched on
12889
13011
  * @param {Element} section the element the answers deliver into
12890
13012
  * @param {string|null} name the section's `data-step`, when it has one
@@ -12895,7 +13017,7 @@ var fml = (function (exports) {
12895
13017
  * problems in the section unless a newer request superseded this one
12896
13018
  */
12897
13019
  async request(host, section, name, index) {
12898
- const first = !this.#entered.has(section);
13020
+ const first = !this.#entered.has(section) && !this.#entering.has(section);
12899
13021
  const detail = { name, section, index, first };
12900
13022
  const types = [
12901
13023
  'section:requested',
@@ -12913,7 +13035,9 @@ var fml = (function (exports) {
12913
13035
  if (promises.length === 0) {
12914
13036
  return undefined;
12915
13037
  }
12916
- this.#entered.add(section);
13038
+ if (first) {
13039
+ this.#entering.add(section);
13040
+ }
12917
13041
  let claims = this.#claims.get(section);
12918
13042
  if (!claims) {
12919
13043
  claims = new Claims();
@@ -12929,13 +13053,20 @@ var fml = (function (exports) {
12929
13053
  }
12930
13054
  });
12931
13055
  try {
12932
- return await Promise.all(promises);
13056
+ const answers = await Promise.all(promises);
13057
+ if (first) {
13058
+ this.#entered.add(section);
13059
+ }
13060
+ return answers;
12933
13061
  } catch (cause) {
12934
13062
  if (owned()) {
12935
13063
  this.#paintError(section, cause);
12936
13064
  }
12937
13065
  throw cause;
12938
13066
  } finally {
13067
+ if (first) {
13068
+ this.#entering.delete(section);
13069
+ }
12939
13070
  cancelAnimationFrame(frame);
12940
13071
  if (owned()) {
12941
13072
  section.toggleAttribute('loading', false);
@@ -13052,15 +13183,27 @@ var fml = (function (exports) {
13052
13183
  /**
13053
13184
  * Calls `dismiss` when a press and its release both land on the backdrop,
13054
13185
  * so a selection dragged out of the panel and released outside does not.
13186
+ * The backdrop is where the pointer is outside the dialog's rectangle: a
13187
+ * part of the panel no child covers, the room around the error section
13188
+ * for one, is the panel.
13055
13189
  * @param {() => void} dismiss
13056
13190
  */
13057
13191
  onBackdrop(dismiss) {
13192
+ const onBackdrop = (/** @type MouseEvent */ e) => {
13193
+ if (e.target !== this.#dialog) {
13194
+ return false;
13195
+ }
13196
+ const panel = this.#dialog.getBoundingClientRect();
13197
+ return (
13198
+ e.clientX < panel.left || e.clientX > panel.right || e.clientY < panel.top || e.clientY > panel.bottom
13199
+ );
13200
+ };
13058
13201
  let pressedOutside = false;
13059
13202
  this.#dialog.addEventListener('mousedown', (e) => {
13060
- pressedOutside = e.target === this.#dialog;
13203
+ pressedOutside = onBackdrop(e);
13061
13204
  });
13062
13205
  this.#dialog.addEventListener('click', (e) => {
13063
- if (pressedOutside && e.target === this.#dialog) {
13206
+ if (pressedOutside && onBackdrop(e)) {
13064
13207
  dismiss();
13065
13208
  }
13066
13209
  });
@@ -13337,7 +13480,7 @@ var fml = (function (exports) {
13337
13480
  * opening, hides the error and loading sections, and dispatches
13338
13481
  * `section:requested` on the element (bubbling, with
13339
13482
  * `detail: { section, first, name: null, index: null }`, `section` being
13340
- * the body and `first` true only on the first answered request), so a
13483
+ * the body and `first` true until a delivery succeeds), so a
13341
13484
  * listener can fill the body. A failed request is painted into the body
13342
13485
  * and does not reject. On a dialog already open nothing is shown or
13343
13486
  * requested, and the call waits for the same ending as the calls before it;
@@ -13589,11 +13732,7 @@ var fml = (function (exports) {
13589
13732
  #answer = null;
13590
13733
  #resolvers = [];
13591
13734
  #closing = false;
13592
- #slideOutEnded = (/** @type AnimationEvent */ e) => {
13593
- if (e.target === this.#dialog) {
13594
- this.#closeNow();
13595
- }
13596
- };
13735
+ #slides = new Claims();
13597
13736
  /**
13598
13737
  * @param {{ slots: Record<string, DocumentFragment> }} c
13599
13738
  */
@@ -13720,7 +13859,7 @@ var fml = (function (exports) {
13720
13859
  * opening, hides the error and loading sections, and dispatches
13721
13860
  * `section:requested` on the element (bubbling, with
13722
13861
  * `detail: { section, first, name: null, index: null }`, `section` being
13723
- * the content section and `first` true only on the first answered request),
13862
+ * the content section and `first` true until a delivery succeeds),
13724
13863
  * so a listener can fill the content. A failed request is painted into the
13725
13864
  * content section and does not reject. On a drawer already open nothing is
13726
13865
  * shown or requested, except that one sliding out stops and stays open,
@@ -13771,11 +13910,13 @@ var fml = (function (exports) {
13771
13910
  /**
13772
13911
  * Slides the drawer out and closes it when the animation ends, setting the
13773
13912
  * `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.
13913
+ * dispatched then, after this call has returned. An animation the page
13914
+ * cancels, by hiding the drawer or an ancestor of it, closes it as one
13915
+ * that ended does. Where the user prefers reduced motion, or no animation
13916
+ * runs, it closes at once. A non-empty result answers as the button
13917
+ * carrying that `data-result` would; no result, or an empty one, is a
13918
+ * dismissal. Does nothing on a drawer that is closed or already sliding
13919
+ * out.
13779
13920
  * @param {string} [result]
13780
13921
  */
13781
13922
  close(result) {
@@ -13790,12 +13931,18 @@ var fml = (function (exports) {
13790
13931
  return;
13791
13932
  }
13792
13933
  this.#dialog.setAttribute('closing', '');
13793
- if (this.#dialog.getAnimations().length === 0) {
13934
+ const animations = this.#dialog.getAnimations();
13935
+ if (animations.length === 0) {
13794
13936
  this.#closeNow();
13795
13937
  return;
13796
13938
  }
13797
13939
  this.#closing = true;
13798
- this.#dialog.addEventListener('animationend', this.#slideOutEnded);
13940
+ const slide = this.#slides.take();
13941
+ Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
13942
+ if (!slide.stale) {
13943
+ this.#closeNow();
13944
+ }
13945
+ });
13799
13946
  }
13800
13947
  #closeNow() {
13801
13948
  this.#stopSlidingOut();
@@ -13803,7 +13950,7 @@ var fml = (function (exports) {
13803
13950
  }
13804
13951
  #stopSlidingOut() {
13805
13952
  this.#closing = false;
13806
- this.#dialog.removeEventListener('animationend', this.#slideOutEnded);
13953
+ this.#slides.invalidate();
13807
13954
  this.#dialog.removeAttribute('closing');
13808
13955
  }
13809
13956
  /** @returns {boolean} whether this call is the one that opened the modal */
@@ -14348,7 +14495,7 @@ var fml = (function (exports) {
14348
14495
  }
14349
14496
  /**
14350
14497
  * Fires the section requests again for a panel, whether it is visible or
14351
- * not, with `first` false once an earlier request was answered.
14498
+ * not, with `first` false once an earlier delivery succeeded.
14352
14499
  * @param {number | Element} ref the panel's index, or the panel itself
14353
14500
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
14354
14501
  * the answers, or to undefined when nobody listened or the delivery
@@ -14571,7 +14718,7 @@ var fml = (function (exports) {
14571
14718
  }
14572
14719
  /**
14573
14720
  * Fires the section requests again for a section, whether it is current or
14574
- * not, with `first` false once an earlier request was answered.
14721
+ * not, with `first` false once an earlier delivery succeeded.
14575
14722
  * @param {string | Element} ref the section's `data-step`, or the section
14576
14723
  * itself
14577
14724
  * @returns {Promise<any[] | undefined> | undefined} a promise resolving to
@@ -14850,8 +14997,11 @@ var fml = (function (exports) {
14850
14997
  /**
14851
14998
  * @param {{ language?: string, translations?: Record<string, any>, httpClient?: HttpClient }} [options]
14852
14999
  * `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
15000
+ * defaulting to `navigator.language`, then to 'en', an empty one included.
15001
+ * It is canonicalized (`IT-it` and `it_IT` both read `it-IT`), and one that
15002
+ * is no language tag at all throws here, rather than at the first message
15003
+ * or number formatted in it. Its primary subtag picks the built-in
15004
+ * translations (en, it, es, fr); the messages
14855
15005
  * are the english ones, overridden by that language's, overridden by
14856
15006
  * `translations`, so a language without built-in translations reads in
14857
15007
  * english. `translations` is a flat map of keys to messages: reword built-in
@@ -14859,9 +15009,18 @@ var fml = (function (exports) {
14859
15009
  * `httpClient` is the client every ful component fetches through, registered
14860
15010
  * as the `http-client` component; without one, `configure` builds a client
14861
15011
  * that sends the csrf token and navigates to '/' on a 401 response.
15012
+ * @throws {RangeError} when `language` is not a language tag
14862
15013
  */
14863
15014
  constructor(options = {}) {
14864
- this.#language = options.language ?? navigator?.language ?? 'en';
15015
+ const language = options.language || navigator?.language || 'en';
15016
+ try {
15017
+ // unicode locale identifiers separate subtags with an underscore too, Intl takes the dash alone
15018
+ this.#language = Intl.getCanonicalLocales(String(language).replaceAll('_', '-'))[0];
15019
+ } catch {
15020
+ throw new RangeError(
15021
+ `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`,
15022
+ );
15023
+ }
14865
15024
  this.#translations = options.translations ?? {};
14866
15025
  this.#httpClient = options.httpClient ?? null;
14867
15026
  }
@@ -14870,7 +15029,7 @@ var fml = (function (exports) {
14870
15029
  * Defines on `registry` the `l10n` module, the `http-client` component,
14871
15030
  * every `ful-*` element, the `loaders:select`, `loaders:form` and
14872
15031
  * `loaders:table` components, and an overlay publishing `l10n` (the merged
14873
- * messages) and `locale` (the `language` as given) to every template.
15032
+ * messages) and `locale` (the `language`, canonicalized) to every template.
14874
15033
  * @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
15034
  * the ftl `Registry`, each of those methods answering the registry itself
14876
15035
  * @returns {void}
@@ -14878,7 +15037,7 @@ var fml = (function (exports) {
14878
15037
  configure(registry) {
14879
15038
  const httpClient =
14880
15039
  this.#httpClient ?? HttpClient.builder().withCsrfToken().withRedirectOnUnauthorized('/').build();
14881
- const language = this.#language.split('-')[0].toLowerCase();
15040
+ const language = this.#language.split('-')[0];
14882
15041
  const l10n = { ...BUILTIN.en, ...BUILTIN[language], ...this.#translations };
14883
15042
  registry
14884
15043
  .defineModule('l10n', Localization)
@@ -14961,7 +15120,6 @@ var fml = (function (exports) {
14961
15120
  SortButton: SortButton,
14962
15121
  Table: Table,
14963
15122
  TableLoader: TableLoader,
14964
- TableSchemaParser: TableSchemaParser,
14965
15123
  Tabs: Tabs,
14966
15124
  TextFilter: TextFilter,
14967
15125
  Timing: Timing,
@@ -15040,7 +15198,6 @@ var fml = (function (exports) {
15040
15198
  exports.SortButton = SortButton;
15041
15199
  exports.Table = Table;
15042
15200
  exports.TableLoader = TableLoader;
15043
- exports.TableSchemaParser = TableSchemaParser;
15044
15201
  exports.Tabs = Tabs;
15045
15202
  exports.Template = Template;
15046
15203
  exports.Templates = Templates;