@optionfactory/fml 8.0.0 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/client-errors.iife.js +11 -8
  2. package/dist/client-errors.iife.js.map +1 -1
  3. package/dist/client-errors.iife.min.js +1 -1
  4. package/dist/client-errors.iife.min.js.map +1 -1
  5. package/dist/custom-elements.json +1210 -0
  6. package/dist/fml.css +10 -1
  7. package/dist/fml.css.map +1 -1
  8. package/dist/fml.d.mts +73 -13
  9. package/dist/fml.iife.js +628 -209
  10. package/dist/fml.iife.js.map +1 -1
  11. package/dist/fml.iife.min.js +1 -1
  12. package/dist/fml.iife.min.js.map +1 -1
  13. package/dist/fml.min.mjs +1 -1
  14. package/dist/fml.min.mjs.map +1 -1
  15. package/dist/fml.mjs +628 -209
  16. package/dist/fml.mjs.map +1 -1
  17. package/dist/ftl.d.mts +2 -1
  18. package/dist/ftl.iife.js +90 -34
  19. package/dist/ftl.iife.js.map +1 -1
  20. package/dist/ftl.iife.min.js +1 -1
  21. package/dist/ftl.iife.min.js.map +1 -1
  22. package/dist/ftl.min.mjs +1 -1
  23. package/dist/ftl.min.mjs.map +1 -1
  24. package/dist/ftl.mjs +90 -34
  25. package/dist/ftl.mjs.map +1 -1
  26. package/dist/ful.css +10 -1
  27. package/dist/ful.css.map +1 -1
  28. package/dist/ful.d.mts +22 -6
  29. package/dist/ful.iife.js +496 -166
  30. package/dist/ful.iife.js.map +1 -1
  31. package/dist/ful.iife.min.js +1 -1
  32. package/dist/ful.iife.min.js.map +1 -1
  33. package/dist/ful.min.mjs +1 -1
  34. package/dist/ful.min.mjs.map +1 -1
  35. package/dist/ful.mjs +496 -166
  36. package/dist/ful.mjs.map +1 -1
  37. package/dist/httpc.d.mts +49 -6
  38. package/dist/httpc.iife.js +40 -9
  39. package/dist/httpc.iife.js.map +1 -1
  40. package/dist/httpc.iife.min.js +1 -1
  41. package/dist/httpc.iife.min.js.map +1 -1
  42. package/dist/httpc.min.mjs +1 -1
  43. package/dist/httpc.min.mjs.map +1 -1
  44. package/dist/httpc.mjs +40 -9
  45. package/dist/httpc.mjs.map +1 -1
  46. package/dist/vscode.html-custom-data.json +357 -0
  47. package/dist/web-types.json +1006 -0
  48. package/package.json +11 -9
package/dist/fml.mjs CHANGED
@@ -3457,7 +3457,7 @@ class EvaluatingVisitor {
3457
3457
  case '<=':
3458
3458
  return lhs <= rhs;
3459
3459
  default:
3460
- throw new Error('unknown cmp op ' + node.op);
3460
+ throw new Error(`unknown cmp op ${node.op}`);
3461
3461
  }
3462
3462
  }
3463
3463
  [nodes.call](node) {
@@ -3468,7 +3468,7 @@ class EvaluatingVisitor {
3468
3468
  }
3469
3469
  const fn = module[fnRef.value];
3470
3470
  if (!fn) {
3471
- throw new Error(`Function "#${fnRef.module === null ? '' : fnRef.module + ':'}${fnRef.value}" not found`);
3471
+ throw new Error(`Function "#${fnRef.module === null ? '' : `${fnRef.module}:`}${fnRef.value}" not found`);
3472
3472
  }
3473
3473
  const args = node.args.map((arg) => this.visit(arg));
3474
3474
  return fn.apply(this.#resolve_proxy(), args);
@@ -3504,14 +3504,14 @@ class EvaluatingVisitor {
3504
3504
  return cond ? cond : this.visit(node.ifFalse);
3505
3505
  }
3506
3506
  [nodes.access](node) {
3507
- let prev = undefined;
3507
+ let prev ;
3508
3508
  let cur = this.visit(node.lhs);
3509
3509
  for (let i = 0; i !== node.rhs.length; ++i) {
3510
3510
  const rhs = node.rhs[i];
3511
3511
  if (rhs.ns && cur == null) {
3512
3512
  return undefined;
3513
3513
  }
3514
- let value = undefined;
3514
+ let value ;
3515
3515
  switch (rhs.type) {
3516
3516
  case nodes.member: {
3517
3517
  value = cur[rhs.rhs];
@@ -3552,7 +3552,7 @@ class EvaluatingVisitor {
3552
3552
  case nodes.templated.ten:
3553
3553
  return { type: nodes.dom.n, value: this.visit(node.value) };
3554
3554
  default:
3555
- throw new Error('unknown node type ' + node.type.toString());
3555
+ throw new Error(`unknown node type ${node.type.toString()}`);
3556
3556
  }
3557
3557
  });
3558
3558
  }
@@ -3728,7 +3728,7 @@ class Attributes {
3728
3728
  .forEach((a) => {
3729
3729
  const target = a.substring(prefix.length);
3730
3730
  if (target === 'class') {
3731
- const classes = from.getAttribute(prefix + 'class')?.split(/\s+/).filter((a) => a.length) ?? [];
3731
+ const classes = from.getAttribute(`${prefix}class`)?.split(/\s+/).filter((a) => a.length) ?? [];
3732
3732
  to.classList.add(...classes);
3733
3733
  return;
3734
3734
  }
@@ -3820,8 +3820,7 @@ class Nodes {
3820
3820
  * @returns
3821
3821
  */
3822
3822
  static isParsed(el) {
3823
- //@ts-ignore
3824
- for (let c = el; c; c = c.parentNode) {
3823
+ for (let c = /** @type {Node | null} */ (el); c; c = c.parentNode) {
3825
3824
  if (c.nextSibling) {
3826
3825
  return true;
3827
3826
  }
@@ -3884,19 +3883,18 @@ class Nodes {
3884
3883
  }
3885
3884
 
3886
3885
  class NodeOperations {
3887
- #forRemoval;
3888
- constructor() {
3889
- this.#forRemoval = [];
3890
- }
3886
+ #forRemoval = new Set();
3891
3887
  removed(node) {
3892
- return this.#forRemoval.includes(node);
3888
+ return this.#forRemoval.has(node);
3893
3889
  }
3894
3890
  remove(node) {
3895
3891
  node.replaceChildren?.();
3896
3892
  Object.keys(node.dataset || {})
3897
3893
  .filter((k) => k.startsWith('tpl'))
3898
- .forEach((k) => delete node.dataset[k]);
3899
- this.#forRemoval.push(node);
3894
+ .forEach((k) => {
3895
+ delete node.dataset[k];
3896
+ });
3897
+ this.#forRemoval.add(node);
3900
3898
  }
3901
3899
  popData(node, key) {
3902
3900
  const v = node.dataset[key];
@@ -3914,9 +3912,10 @@ class NodeOperations {
3914
3912
  this.remove(ref);
3915
3913
  }
3916
3914
  cleanup() {
3917
- while (this.#forRemoval.length) {
3918
- this.#forRemoval.pop().remove();
3915
+ for (const node of this.#forRemoval) {
3916
+ node.remove();
3919
3917
  }
3918
+ this.#forRemoval.clear();
3920
3919
  }
3921
3920
  }
3922
3921
 
@@ -3971,7 +3970,7 @@ class CommandsHandler {
3971
3970
  }
3972
3971
  static tplRemove(node, value, ops, modules, dataStack) {
3973
3972
  switch (value.toLowerCase()) {
3974
- case 'tag':
3973
+ case 'tag': {
3975
3974
  const fragment = new DocumentFragment();
3976
3975
  while (node.firstChild) {
3977
3976
  fragment.appendChild(node.firstChild);
@@ -3985,6 +3984,7 @@ class CommandsHandler {
3985
3984
  ops.remove(node);
3986
3985
  }
3987
3986
  break;
3987
+ }
3988
3988
  case 'body':
3989
3989
  node.replaceChildren();
3990
3990
  break;
@@ -4050,8 +4050,10 @@ class CommandsHandler {
4050
4050
  }
4051
4051
  }
4052
4052
 
4053
- // Module-isolated string cache for dataset-to-attribute conversions
4053
+ // Module-isolated string cache for dataset-to-attribute conversions, bounded with
4054
+ // FIFO eviction like the expression ast cache
4054
4055
  const attributeCache = new Map();
4056
+ const ATTRIBUTE_CACHE_MAX_SIZE = 1000;
4055
4057
 
4056
4058
  /**
4057
4059
  * Converts a tpl camelCase dataset key into a kebab-case attribute name.
@@ -4062,6 +4064,9 @@ const attributeCache = new Map();
4062
4064
  function toAttr(dataSetKey) {
4063
4065
  let cached = attributeCache.get(dataSetKey);
4064
4066
  if (!cached) {
4067
+ if (attributeCache.size >= ATTRIBUTE_CACHE_MAX_SIZE) {
4068
+ attributeCache.delete(attributeCache.keys().next().value);
4069
+ }
4065
4070
  cached = dataSetKey
4066
4071
  .substring(3)
4067
4072
  .split(/(?=[A-Z])/)
@@ -4196,9 +4201,10 @@ class Template {
4196
4201
  * @param {string} expression
4197
4202
  * @param {(typeof Expressions.MODE_EXPRESSION | typeof Expressions.MODE_TEMPLATED)?} [mode]
4198
4203
  * @param {...*} data
4204
+ * @returns the evaluated expression result
4199
4205
  */
4200
4206
  evaluate(expression, mode, ...data) {
4201
- new ExpressionEvaluator(this.#modules, this.#dataStack).withOverlay(...data).evaluate(expression, mode);
4207
+ return new ExpressionEvaluator(this.#modules, this.#dataStack).withOverlay(...data).evaluate(expression, mode);
4202
4208
  }
4203
4209
  /**
4204
4210
  * Returns an expression evaluator with bound modules and dataStack.
@@ -4343,8 +4349,8 @@ class RenderError extends Error {
4343
4349
  if (node.nodeType === Node.TEXT_NODE) {
4344
4350
  node.nodeValue = node.nodeValue.trim();
4345
4351
  }
4346
- for (var n = 0; n < node.childNodes.length; n++) {
4347
- var child = node.childNodes[n];
4352
+ for (let n = 0; n < node.childNodes.length; n++) {
4353
+ const child = node.childNodes[n];
4348
4354
  if (child.nodeType === Node.TEXT_NODE) {
4349
4355
  child.nodeValue = child.nodeValue.trim();
4350
4356
  if (child.nodeValue.length === 0) {
@@ -4363,8 +4369,7 @@ class UpgradeQueue {
4363
4369
  #q = new Map();
4364
4370
  constructor() {
4365
4371
  document.addEventListener('DOMContentLoaded', async () => {
4366
- const pending = Array.from(this.entries).map(([child, promise]) => promise);
4367
- await Promise.all(pending);
4372
+ await this.settle();
4368
4373
  document.dispatchEvent(
4369
4374
  new CustomEvent('ftl:ready', {
4370
4375
  bubbles: false,
@@ -4373,16 +4378,39 @@ class UpgradeQueue {
4373
4378
  );
4374
4379
  });
4375
4380
  }
4381
+ #finished = new Map();
4376
4382
  enqueue(el) {
4377
4383
  if (this.#q.has(el)) {
4378
4384
  //already upgrading, can happen when disconnecting an element
4379
4385
  //while it's already queued for upgrade (e.g.: ful-form)
4380
4386
  return;
4381
4387
  }
4388
+ //settling waits on a signal that only ever resolves, so nothing here attaches a
4389
+ //rejection handler to the upgrade itself: a component that fails is still
4390
+ //reported the way it always was
4391
+ const { promise: finished, resolve: markFinished } = /** @type {PromiseWithResolvers<void>} */ (
4392
+ Promise.withResolvers()
4393
+ );
4382
4394
  const promise = Nodes.waitParsed(el)
4383
4395
  .then(() => el.upgrade())
4384
- .finally(() => this.#q.delete(el));
4396
+ .finally(() => {
4397
+ this.#q.delete(el);
4398
+ this.#finished.delete(el);
4399
+ markFinished();
4400
+ });
4385
4401
  this.#q.set(el, promise);
4402
+ this.#finished.set(el, finished);
4403
+ }
4404
+ /**
4405
+ * Waits for every queued upgrade to settle, including the ones enqueued while
4406
+ * waiting: a component is only queued once its parent connects it, so a single pass
4407
+ * would miss everything nested. A component that fails to upgrade does not hold the
4408
+ * others back, readiness means the queue drained rather than that everything worked.
4409
+ */
4410
+ async settle() {
4411
+ while (this.#finished.size !== 0) {
4412
+ await Promise.all(Array.from(this.#finished.values()));
4413
+ }
4386
4414
  }
4387
4415
  get entries() {
4388
4416
  return this.#q.entries();
@@ -4578,18 +4606,30 @@ class Templates {
4578
4606
  }
4579
4607
  }
4580
4608
 
4581
- class Rendering {
4582
- static async waitFor(el) {
4609
+ /**
4610
+ * Waits for the queued upgrades matching the filter, including the ones enqueued while
4611
+ * waiting: a component is only queued once its parent connects it, so a single pass
4612
+ * would miss everything nested.
4613
+ * @param {(el: Element) => boolean} accept
4614
+ */
4615
+ const settle = async (accept) => {
4616
+ for (;;) {
4583
4617
  const pending = Array.from(registry.upgrades)
4584
- .filter(([child, promise]) => el.contains(child))
4585
- .map(([child, promise]) => promise);
4618
+ .filter(([child]) => accept(child))
4619
+ .map(([, promise]) => promise);
4620
+ if (pending.length === 0) {
4621
+ return;
4622
+ }
4586
4623
  await Promise.all(pending);
4587
4624
  }
4625
+ };
4626
+
4627
+ class Rendering {
4628
+ static async waitFor(el) {
4629
+ await settle((child) => el.contains(child));
4630
+ }
4588
4631
  static async waitForChildren(el) {
4589
- const pending = Array.from(registry.upgrades)
4590
- .filter(([child, promise]) => el !== child && el.contains(child))
4591
- .map(([child, promise]) => promise);
4592
- await Promise.all(pending);
4632
+ await settle((child) => el !== child && el.contains(child));
4593
4633
  }
4594
4634
  }
4595
4635
 
@@ -4631,7 +4671,7 @@ class ParsedElement extends HTMLElement {
4631
4671
  template(name) {
4632
4672
  const { modules, data } = registry.context();
4633
4673
  let t = this.#bits().TEMPLATES[name ?? 'default'].withData(data).withModules(modules);
4634
- for (let k of ['l10n', 'config']) {
4674
+ for (const k of ['l10n', 'config']) {
4635
4675
  const v = this.constructor[k];
4636
4676
  if (v) {
4637
4677
  t = t.withOverlay({ [k]: v });
@@ -4661,6 +4701,19 @@ class ParsedElement extends HTMLElement {
4661
4701
  return;
4662
4702
  }
4663
4703
  Reflect.set(this, 'disabled', disabled);
4704
+ if (disabled) {
4705
+ this.#unclaimFormDisabled();
4706
+ }
4707
+ }
4708
+ //a disabled ancestor fieldset already matches the element through :disabled, and
4709
+ //an attribute of its own would keep the element disabled once the fieldset is
4710
+ //re-enabled, as formDisabledCallback(false) is only delivered on an actual state
4711
+ //change. a claim of its own made while already disabled by ancestry is safe: the
4712
+ //platform fires no callback for it, so it survives the fieldset being re-enabled
4713
+ #unclaimFormDisabled() {
4714
+ if (this.closest('fieldset:disabled')) {
4715
+ this.removeAttribute('disabled');
4716
+ }
4664
4717
  }
4665
4718
  async upgrade() {
4666
4719
  if (this.#parsed) {
@@ -4676,6 +4729,9 @@ class ParsedElement extends HTMLElement {
4676
4729
  );
4677
4730
  const disabled = this.#disabledBeforeParsed ?? false;
4678
4731
  await this.render({ slots, observed, disabled });
4732
+ if (disabled) {
4733
+ this.#unclaimFormDisabled();
4734
+ }
4679
4735
  }
4680
4736
  render(c) {}
4681
4737
  reflect(fn) {
@@ -4772,13 +4828,22 @@ class Base64 {
4772
4828
 
4773
4829
  let vi = 0;
4774
4830
  let si = 0;
4775
- while (vi < str.length * 0.75) {
4831
+ //every group yields three bytes, but a padded input stops short of the last
4832
+ //one or two: writing them anyway would rely on typed arrays dropping writes
4833
+ //past their length
4834
+ while (vi < nbytes) {
4776
4835
  const v1 = d.indexOf(str.charAt(si++));
4777
4836
  const v2 = d.indexOf(str.charAt(si++));
4778
4837
  const v3 = d.indexOf(str.charAt(si++));
4779
4838
  const v4 = d.indexOf(str.charAt(si++));
4780
4839
  view[vi++] = (v1 << 2) | (v2 >> 4);
4840
+ if (vi === nbytes) {
4841
+ break;
4842
+ }
4781
4843
  view[vi++] = ((v2 & 15) << 4) | (v3 >> 2);
4844
+ if (vi === nbytes) {
4845
+ break;
4846
+ }
4782
4847
  view[vi++] = ((v3 & 3) << 6) | v4;
4783
4848
  }
4784
4849
 
@@ -4805,7 +4870,7 @@ class Hex {
4805
4870
  return Array.from(bytes)
4806
4871
  .map((b) => b.toString(16))
4807
4872
  .map((b) => (upper ? b.toUpperCase() : b))
4808
- .map((o) => o.padStart(2, 0))
4873
+ .map((o) => o.padStart(2, '0'))
4809
4874
  .join('');
4810
4875
  }
4811
4876
  }
@@ -5135,6 +5200,26 @@ const unmarshal = async (response, type) => {
5135
5200
  }
5136
5201
  };
5137
5202
 
5203
+ /**
5204
+ * Yields the entries of a headers or params initializer preserving nullish values:
5205
+ * normalizing through `Headers`/`URLSearchParams` first would stringify them to
5206
+ * "null"/"undefined" instead of removing the key.
5207
+ * @param {any} source
5208
+ * @returns {Iterable<[string, any]>}
5209
+ */
5210
+ const rawEntries = (source) => {
5211
+ if (source == null) {
5212
+ return [];
5213
+ }
5214
+ if (typeof source === 'string') {
5215
+ return new URLSearchParams(source);
5216
+ }
5217
+ if (typeof source[Symbol.iterator] === 'function') {
5218
+ return source;
5219
+ }
5220
+ return Object.entries(source);
5221
+ };
5222
+
5138
5223
  class HttpRequestBuilder {
5139
5224
  #client;
5140
5225
  #method;
@@ -5187,11 +5272,11 @@ class HttpRequestBuilder {
5187
5272
  }
5188
5273
  /**
5189
5274
  * Add all passed headers to the request, overriding existing ones if that key already exists. Null and undefined values cause the key to be removed.
5190
- * @param {HeadersInit} hs
5275
+ * @param {HeadersInit|Record<string,string|null|undefined>} hs
5191
5276
  * @returns {HttpRequestBuilder} this builder
5192
5277
  */
5193
5278
  headers(hs) {
5194
- for (const [k, v] of new Headers(hs).entries()) {
5279
+ for (const [k, v] of rawEntries(hs)) {
5195
5280
  if (v == null) {
5196
5281
  this.#headers.delete(k);
5197
5282
  } else {
@@ -5216,11 +5301,11 @@ class HttpRequestBuilder {
5216
5301
  }
5217
5302
  /**
5218
5303
  * Add all query parameters to the request, overriding existing ones if that key already exists. Null and undefined values cause the key to be removed
5219
- * @param {URLSearchParams|Record<string,string>|string[][]|string} ps
5304
+ * @param {URLSearchParams|Record<string,string|null|undefined>|string[][]|string} ps
5220
5305
  * @returns {HttpRequestBuilder} this builder
5221
5306
  */
5222
5307
  params(ps) {
5223
- for (const [k, v] of new URLSearchParams(ps).entries()) {
5308
+ for (const [k, v] of rawEntries(ps)) {
5224
5309
  if (v == null) {
5225
5310
  this.#params.delete(k);
5226
5311
  } else {
@@ -5236,8 +5321,10 @@ class HttpRequestBuilder {
5236
5321
  * @returns {HttpRequestBuilder} this builder
5237
5322
  */
5238
5323
  param(k, ...vs) {
5324
+ //overriding, as header, headers and params all do: pass every value in one
5325
+ //call to get a multi valued parameter
5326
+ this.#params.delete(k);
5239
5327
  if (vs.length === 0 || vs[0] == null) {
5240
- this.#params.delete(k);
5241
5328
  return this;
5242
5329
  }
5243
5330
  for (const v of vs) {
@@ -5270,7 +5357,7 @@ class HttpRequestBuilder {
5270
5357
  /**
5271
5358
  * Sets the request body as a FormData configured using the callback.
5272
5359
  * `Content-Type: multipart/form-data` header is automatically added by fetch if not explicitly set.
5273
- * @param {function(HttpMultipartRequestCustomizer):void} callback
5360
+ * @param {(c: HttpMultipartRequestCustomizer) => void} callback
5274
5361
  */
5275
5362
  multipart(callback) {
5276
5363
  const formData = new FormData();
@@ -5435,7 +5522,7 @@ class HttpMultipartRequestCustomizer {
5435
5522
  * @returns this builder
5436
5523
  */
5437
5524
  blobs(name, values) {
5438
- for (let v of values) {
5525
+ for (const v of values) {
5439
5526
  this.#formData.append(name, v);
5440
5527
  }
5441
5528
  return this;
@@ -5470,7 +5557,16 @@ class LocalStorage extends Storage {
5470
5557
  }
5471
5558
  static load(k) {
5472
5559
  const got = localStorage.getItem(k);
5473
- return got === null ? undefined : JSON.parse(got);
5560
+ if (got === null) {
5561
+ return undefined;
5562
+ }
5563
+ try {
5564
+ return JSON.parse(got);
5565
+ } catch {
5566
+ //not what save wrote: drop it, otherwise every later read fails the same way
5567
+ localStorage.removeItem(k);
5568
+ return undefined;
5569
+ }
5474
5570
  }
5475
5571
  static remove(k) {
5476
5572
  localStorage.removeItem(k);
@@ -5488,7 +5584,16 @@ class SessionStorage extends Storage {
5488
5584
  }
5489
5585
  static load(k) {
5490
5586
  const got = sessionStorage.getItem(k);
5491
- return got === null ? undefined : JSON.parse(got);
5587
+ if (got === null) {
5588
+ return undefined;
5589
+ }
5590
+ try {
5591
+ return JSON.parse(got);
5592
+ } catch {
5593
+ //not what save wrote: drop it, otherwise every later read fails the same way
5594
+ sessionStorage.removeItem(k);
5595
+ return undefined;
5596
+ }
5492
5597
  }
5493
5598
  static remove(k) {
5494
5599
  sessionStorage.removeItem(k);
@@ -5510,7 +5615,7 @@ class VersionedLocalStorage {
5510
5615
  return undefined;
5511
5616
  }
5512
5617
  if (stored.revision !== revision) {
5513
- localStorage.removeItem(key);
5618
+ LocalStorage.remove(key);
5514
5619
  return undefined;
5515
5620
  }
5516
5621
  return stored.data;
@@ -5527,7 +5632,7 @@ class VersionedSessionStorage {
5527
5632
  return undefined;
5528
5633
  }
5529
5634
  if (stored.revision !== revision) {
5530
- localStorage.removeItem(key);
5635
+ SessionStorage.remove(key);
5531
5636
  return undefined;
5532
5637
  }
5533
5638
  return stored.data;
@@ -5662,7 +5767,7 @@ class Timing {
5662
5767
  */
5663
5768
  static debounce(timeoutMs, func, options) {
5664
5769
  const opts = options ?? Timing.DEBOUNCE_DEFAULT;
5665
- let tid = null;
5770
+ let tid = /** @type {number | null} */ (null);
5666
5771
  let args = [];
5667
5772
  let previousTimestamp = 0;
5668
5773
 
@@ -5682,8 +5787,8 @@ class Timing {
5682
5787
  }
5683
5788
  };
5684
5789
 
5685
- const debounced = function () {
5686
- args = [...arguments];
5790
+ const debounced = (...called) => {
5791
+ args = called;
5687
5792
  previousTimestamp = performance.now();
5688
5793
  if (tid === null) {
5689
5794
  tid = setTimeout(later, timeoutMs);
@@ -5692,7 +5797,11 @@ class Timing {
5692
5797
  }
5693
5798
  }
5694
5799
  };
5695
- const abort = () => clearTimeout(tid);
5800
+ const abort = () => {
5801
+ clearTimeout(tid ?? undefined);
5802
+ tid = null;
5803
+ args = [];
5804
+ };
5696
5805
  return [debounced, abort];
5697
5806
  }
5698
5807
  static THROTTLE_DEFAULT = 0;
@@ -5707,7 +5816,7 @@ class Timing {
5707
5816
  */
5708
5817
  static throttle(timeoutMs, func, options) {
5709
5818
  const opts = options ?? Timing.THROTTLE_DEFAULT;
5710
- let tid = null;
5819
+ let tid = /** @type {number | null} */ (null);
5711
5820
  let args = [];
5712
5821
  let previousTimestamp = 0;
5713
5822
 
@@ -5719,13 +5828,13 @@ class Timing {
5719
5828
  args = [];
5720
5829
  }
5721
5830
  };
5722
- const throttled = function () {
5831
+ const throttled = (...called) => {
5723
5832
  const now = performance.now();
5724
5833
  if (!previousTimestamp && opts & Timing.THROTTLE_NO_LEADING) {
5725
5834
  previousTimestamp = now;
5726
5835
  }
5727
5836
  const remaining = previousTimestamp === 0 ? 0 : timeoutMs - (now - previousTimestamp);
5728
- args = [...arguments];
5837
+ args = called;
5729
5838
  if (remaining <= 0 || remaining > timeoutMs) {
5730
5839
  if (tid !== null) {
5731
5840
  clearTimeout(tid);
@@ -5740,7 +5849,11 @@ class Timing {
5740
5849
  tid = setTimeout(later, remaining);
5741
5850
  }
5742
5851
  };
5743
- const abort = () => clearTimeout(tid);
5852
+ const abort = () => {
5853
+ clearTimeout(tid ?? undefined);
5854
+ tid = null;
5855
+ args = [];
5856
+ };
5744
5857
  return [throttled, abort];
5745
5858
  }
5746
5859
  }
@@ -5754,7 +5867,7 @@ class Bindings {
5754
5867
  */
5755
5868
  static flatten(obj, prefix, stops) {
5756
5869
  return Object.keys(obj).reduce((acc, k) => {
5757
- const pre = prefix.length ? prefix + '.' + k : k;
5870
+ const pre = prefix.length ? `${prefix}.${k}` : k;
5758
5871
  if (!stops.has(pre) && typeof obj[k] === 'object' && obj[k] !== null) {
5759
5872
  Object.assign(acc, Bindings.flatten(obj[k], pre, stops));
5760
5873
  } else {
@@ -5772,7 +5885,7 @@ class Bindings {
5772
5885
  static providePath(result, path, value) {
5773
5886
  const keys = path.split('.').map((k) => (/^[0-9]+$/.test(k) ? +k : k));
5774
5887
  let current = result ?? {};
5775
- let previous = null;
5888
+ let previous = /** @type {any} */ (null);
5776
5889
  for (let i = 0; ; ++i) {
5777
5890
  const ckey = keys[i];
5778
5891
  const pkey = keys[i - 1];
@@ -5805,12 +5918,12 @@ class Bindings {
5805
5918
  if (!el.checked) {
5806
5919
  return undefined;
5807
5920
  }
5808
- return el.dataset['fulBindType'] === 'boolean' ? el.value === 'true' : el.value;
5921
+ return el.dataset.fulBindType === 'boolean' ? el.value === 'true' : el.value;
5809
5922
  }
5810
5923
  if (el.getAttribute('type') === 'checkbox') {
5811
5924
  return el.checked;
5812
5925
  }
5813
- if (el.dataset['fulBindType'] === 'boolean') {
5926
+ if (el.dataset.fulBindType === 'boolean') {
5814
5927
  return !el.value ? null : el.value === 'true';
5815
5928
  }
5816
5929
  if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') {
@@ -5843,12 +5956,14 @@ class Bindings {
5843
5956
 
5844
5957
  /**
5845
5958
  *
5846
- * @param {Element & {checked?: boolean} & {value?: any}} el
5959
+ * @param {Element & {dataset?: any} & {checked?: boolean} & {value?: any}} el
5847
5960
  * @returns
5848
5961
  */
5849
5962
  static mutate(el, raw) {
5850
5963
  if (el.getAttribute('type') === 'radio') {
5851
- el.checked = el.getAttribute('value') === raw;
5964
+ //values are matched as strings, as ful-radio-group does: extract decodes
5965
+ //boolean radios, and payloads carry numbers where the attribute is text
5966
+ el.checked = raw != null && el.getAttribute('value') === String(raw);
5852
5967
  return;
5853
5968
  }
5854
5969
  if (el.getAttribute('type') === 'checkbox') {
@@ -5872,7 +5987,9 @@ class Bindings {
5872
5987
  static errors(form, es, scrollOnError) {
5873
5988
  const fieldErrors = es.filter((e) => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT');
5874
5989
  const globalErrors = es.filter((e) => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
5875
- form.querySelectorAll(`[name]`).forEach((el) => el.setCustomValidity?.(''));
5990
+ form.querySelectorAll(`[name]`).forEach((el) => {
5991
+ el.setCustomValidity?.('');
5992
+ });
5876
5993
  form.querySelectorAll('ful-errors').forEach((el) => {
5877
5994
  el.replaceChildren();
5878
5995
  el.setAttribute('hidden', '');
@@ -5880,12 +5997,12 @@ class Bindings {
5880
5997
  fieldErrors.forEach((e) => {
5881
5998
  const name = e.context.replace(/\[/g, '.').replace(/\]\./g, '.').replace(/\]/g, '');
5882
5999
  const parts = name.split('.');
5883
- for (let i = parts.length; i != 0; --i) {
6000
+ for (let i = parts.length; i !== 0; --i) {
5884
6001
  const prefix = parts.slice(0, i).join('.');
5885
6002
  const suffix = parts.slice(i, parts.length).join('.');
5886
- form.querySelectorAll(`[name='${CSS.escape(prefix)}']`).forEach((input) =>
5887
- input.setCustomValidity?.(e.reason, suffix),
5888
- );
6003
+ form.querySelectorAll(`[name='${CSS.escape(prefix)}']`).forEach((input) => {
6004
+ input.setCustomValidity?.(e.reason, suffix);
6005
+ });
5889
6006
  }
5890
6007
  });
5891
6008
  form.querySelectorAll('ful-errors').forEach((el) => {
@@ -5895,7 +6012,7 @@ class Bindings {
5895
6012
  el.removeAttribute('hidden');
5896
6013
  }
5897
6014
  });
5898
- if (es.length == 0 || !scrollOnError) {
6015
+ if (es.length === 0 || !scrollOnError) {
5899
6016
  return;
5900
6017
  }
5901
6018
  Array.from(form.querySelectorAll(`:invalid`))
@@ -5967,7 +6084,8 @@ class FormLoader {
5967
6084
  class Form extends ParsedElement {
5968
6085
  form;
5969
6086
  render() {
5970
- const form = (this.form = document.createElement('form'));
6087
+ const form = document.createElement('form');
6088
+ this.form = form;
5971
6089
  form.setAttribute('novalidate', '');
5972
6090
  Attributes.forward('form-', this, form);
5973
6091
  form.replaceChildren(...this.childNodes);
@@ -5990,50 +6108,53 @@ class Form extends ParsedElement {
5990
6108
  */
5991
6109
  async submit(submitter) {
5992
6110
  this.spinner(true);
6111
+ //one try: building the loader and preparing the request are as much part of a
6112
+ //submit as sending it, and a mapper that throws is how a caller reports a
6113
+ //problem with the values
6114
+ let values;
6115
+ let request;
5993
6116
  try {
5994
6117
  const loader = registry.component(this.getAttribute('loader') ?? 'loaders:form').create(this);
5995
- const values = Bindings.extractFrom(this.form, submitter);
5996
- let request = await loader.prepare(values, this);
5997
- try {
5998
- const se = new CustomEvent('submit', {
6118
+ values = Bindings.extractFrom(this.form, submitter);
6119
+ request = await loader.prepare(values, this);
6120
+ const se = new CustomEvent('submit', {
6121
+ bubbles: true,
6122
+ cancelable: true,
6123
+ detail: { submitter, values, request },
6124
+ });
6125
+ if (!this.dispatchEvent(se)) {
6126
+ return;
6127
+ }
6128
+ this.errors = [];
6129
+ const sre = new CustomEvent('submit:requested', {
6130
+ bubbles: true,
6131
+ cancelable: false,
6132
+ detail: { submitter, values: se.detail.values, request: se.detail.request },
6133
+ });
6134
+ let response = await AsyncEvents.fireAsync(this, sre, { mode: 'pipeline' });
6135
+ request = sre.detail.request;
6136
+
6137
+ response = await loader.submit(request, this, response);
6138
+ const mapped = await loader.transform(response, this);
6139
+ this.dispatchEvent(
6140
+ new CustomEvent('submit:success', {
5999
6141
  bubbles: true,
6000
- cancelable: true,
6001
- detail: { submitter, values, request },
6002
- });
6003
- if (!this.dispatchEvent(se)) {
6004
- return;
6005
- }
6006
- this.errors = [];
6007
- const sre = new CustomEvent('submit:requested', {
6142
+ cancelable: false,
6143
+ detail: { submitter, values, request, response: mapped },
6144
+ }),
6145
+ );
6146
+ } catch (e) {
6147
+ this.dispatchEvent(
6148
+ new CustomEvent('submit:failure', {
6008
6149
  bubbles: true,
6009
6150
  cancelable: false,
6010
- detail: { submitter, values: se.detail.values, request: se.detail.request },
6011
- });
6012
- let response = await AsyncEvents.fireAsync(this, sre, { mode: 'pipeline' });
6013
- request = sre.detail.request;
6014
-
6015
- response = await loader.submit(request, this, response);
6016
- const mapped = await loader.transform(response, this);
6017
- this.dispatchEvent(
6018
- new CustomEvent('submit:success', {
6019
- bubbles: true,
6020
- cancelable: false,
6021
- detail: { submitter, values, request, response: mapped },
6022
- }),
6023
- );
6024
- } catch (e) {
6025
- this.dispatchEvent(
6026
- new CustomEvent('submit:failure', {
6027
- bubbles: true,
6028
- cancelable: false,
6029
- detail: { submitter, values, request, exception: e },
6030
- }),
6031
- );
6032
- if (e instanceof Failure) {
6033
- this.errors = e.problems;
6034
- }
6035
- console.warn('failed to submit form', this, 'reason:', e);
6151
+ detail: { submitter, values, request, exception: e },
6152
+ }),
6153
+ );
6154
+ if (e instanceof Failure) {
6155
+ this.errors = e.problems;
6036
6156
  }
6157
+ console.warn('failed to submit form', this, 'reason:', e);
6037
6158
  } finally {
6038
6159
  this.spinner(false);
6039
6160
  }
@@ -6041,7 +6162,20 @@ class Form extends ParsedElement {
6041
6162
  reset() {
6042
6163
  this.form.reset();
6043
6164
  }
6165
+ #spinning = 0;
6044
6166
  spinner(spin) {
6167
+ //submits can overlap: only the outermost one saves and restores the button states
6168
+ if (spin) {
6169
+ ++this.#spinning;
6170
+ if (this.#spinning !== 1) {
6171
+ return;
6172
+ }
6173
+ } else {
6174
+ this.#spinning = Math.max(0, this.#spinning - 1);
6175
+ if (this.#spinning !== 0) {
6176
+ return;
6177
+ }
6178
+ }
6045
6179
  this.querySelectorAll('ful-spinner').forEach((el) => {
6046
6180
  const hel = /** @type HTMLElement */ (el);
6047
6181
  hel.hidden = !spin;
@@ -6072,7 +6206,7 @@ class Form extends ParsedElement {
6072
6206
  }
6073
6207
 
6074
6208
  class Input extends ParsedElement {
6075
- static observed = ['value', 'readonly:presence', 'required:presence'];
6209
+ static observed = ['value', 'readonly:presence', 'required:presence', 'placeholder'];
6076
6210
  static slots = true;
6077
6211
  static template = `
6078
6212
  <div class="form-label">
@@ -6109,12 +6243,6 @@ class Input extends ParsedElement {
6109
6243
  this._input = fragment.querySelector('input,textarea');
6110
6244
 
6111
6245
  Attributes.forward('input-', this, this._input);
6112
- if (!skipObservedSetup) {
6113
- this.disabled = disabled;
6114
- this.readonly = observed.readonly;
6115
- this.required = observed.required;
6116
- this.value = observed.value;
6117
- }
6118
6246
  this._input.addEventListener('keydown', (evt) => {
6119
6247
  if (evt.key !== 'Enter' || this._type() === 'textarea') {
6120
6248
  return;
@@ -6130,19 +6258,26 @@ class Input extends ParsedElement {
6130
6258
  form.requestSubmit(submitter);
6131
6259
  });
6132
6260
  this._input.addEventListener('input', (evt) => {
6133
- const re = this.getAttribute('mask');
6134
- if (!re) {
6261
+ const mask = this.getAttribute('mask');
6262
+ if (!mask) {
6135
6263
  return;
6136
6264
  }
6265
+ const strip = (v) => v.replace(new RegExp(mask, 'g'), '');
6137
6266
  const before = evt.target.value;
6138
- const after = before.replace(new RegExp(re, 'g'), '');
6267
+ const after = strip(before);
6139
6268
  if (before === after) {
6140
6269
  return;
6141
6270
  }
6142
6271
  const start = evt.target.selectionStart;
6143
- const offset = before.length - after.length;
6144
6272
  evt.target.value = after;
6145
- evt.target.setSelectionRange(start - offset, start - offset);
6273
+ if (start === null) {
6274
+ //email, number and the date types have no selection to restore
6275
+ return;
6276
+ }
6277
+ //the caret keeps its place among the characters that survived, so only the
6278
+ //ones stripped before it count
6279
+ const caret = strip(before.slice(0, start)).length;
6280
+ evt.target.setSelectionRange(caret, caret);
6146
6281
  });
6147
6282
  this._input.addEventListener('change', (evt) => {
6148
6283
  evt.stopPropagation();
@@ -6162,6 +6297,15 @@ class Input extends ParsedElement {
6162
6297
  this._input.ariaDescribedByElements = [this._fieldError];
6163
6298
  this._input.ariaLabelledByElements = [label];
6164
6299
  this.replaceChildren(fragment);
6300
+ if (!skipObservedSetup) {
6301
+ // biome-ignore lint/complexity/noUselessThisAlias: keeps checkJs from seeing these as class fields
6302
+ const el = this;
6303
+ el.disabled = disabled;
6304
+ el.readonly = observed.readonly;
6305
+ el.required = observed.required;
6306
+ el.placeholder = observed.placeholder;
6307
+ el.value = observed.value;
6308
+ }
6165
6309
  }
6166
6310
  get value() {
6167
6311
  const uppercase = this.hasAttribute('uppercase');
@@ -6188,6 +6332,11 @@ class Input extends ParsedElement {
6188
6332
  }
6189
6333
  set disabled(d) {
6190
6334
  Attributes.toggle(this._input, 'disabled', d);
6335
+ //also on the host: a form associated element only matches :disabled through its
6336
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
6337
+ //is needed, disabled is deliberately not observed: the platform delivers it
6338
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
6339
+ Attributes.toggle(this, 'disabled', d);
6191
6340
  }
6192
6341
  get required() {
6193
6342
  return this._input.getAttribute('aria-required') === 'true';
@@ -6198,6 +6347,18 @@ class Input extends ParsedElement {
6198
6347
  Attributes.toggle(this, 'required', d);
6199
6348
  });
6200
6349
  }
6350
+ get placeholder() {
6351
+ const v = this._input.getAttribute('placeholder');
6352
+ return v === ' ' ? null : v;
6353
+ }
6354
+ set placeholder(d) {
6355
+ //without a placeholder :placeholder-shown never matches, and floating labels
6356
+ //rely on it, so a blank one stands in for none
6357
+ Attributes.set(this._input, 'placeholder', d ?? ' ');
6358
+ this.reflect(() => {
6359
+ Attributes.set(this, 'placeholder', d);
6360
+ });
6361
+ }
6201
6362
  focus(options) {
6202
6363
  this._input.focus(options);
6203
6364
  }
@@ -6217,23 +6378,23 @@ class Input extends ParsedElement {
6217
6378
 
6218
6379
  class LocalDate extends ParsedElement {
6219
6380
  render() {
6220
- const content = this.innerHTML.trim();
6381
+ const content = this.textContent.trim();
6221
6382
  if (content === '') {
6222
- this.innerHTML = this.getAttribute('default') ?? '';
6383
+ this.replaceChildren(this.getAttribute('default') ?? '');
6223
6384
  return;
6224
6385
  }
6225
6386
  const locale = this.getAttribute('locale') ?? Intl.DateTimeFormat().resolvedOptions().locale;
6226
6387
  const formatter = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'numeric', day: 'numeric' });
6227
6388
  const [y, m, d] = content.split('-').map(Number);
6228
- this.innerHTML = formatter.format(new Date(y, m - 1, d));
6389
+ this.replaceChildren(formatter.format(new Date(y, m - 1, d)));
6229
6390
  }
6230
6391
  }
6231
6392
 
6232
6393
  class Instant extends ParsedElement {
6233
6394
  render() {
6234
- const content = this.innerHTML.trim();
6395
+ const content = this.textContent.trim();
6235
6396
  if (content === '') {
6236
- this.innerHTML = this.getAttribute('default') ?? '';
6397
+ this.replaceChildren(this.getAttribute('default') ?? '');
6237
6398
  return;
6238
6399
  }
6239
6400
  const locale = this.getAttribute('locale') ?? Intl.DateTimeFormat().resolvedOptions().locale;
@@ -6246,7 +6407,7 @@ class Instant extends ParsedElement {
6246
6407
  second: 'numeric',
6247
6408
  hour12: false,
6248
6409
  });
6249
- this.innerHTML = format.format(new Date(Instant.isoToLocal(content)));
6410
+ this.replaceChildren(format.format(new Date(Instant.isoToLocal(content))));
6250
6411
  }
6251
6412
  static isoToLocal(iso) {
6252
6413
  //this is so sad
@@ -6259,16 +6420,17 @@ class Instant extends ParsedElement {
6259
6420
  }
6260
6421
 
6261
6422
  class InputLocalDate extends Input {
6262
- static observed = ['value', 'readonly:presence', 'required:presence', 'min', 'max', 'step'];
6423
+ static observed = ['value', 'readonly:presence', 'required:presence', 'placeholder', 'min', 'max', 'step'];
6263
6424
  _type() {
6264
6425
  return 'date';
6265
6426
  }
6266
6427
  render(conf) {
6267
6428
  const { observed } = conf;
6268
6429
  super.render(conf);
6430
+ //step first: on a time input min and max are snapped to its grid
6431
+ this.step = observed.step;
6269
6432
  this.min = observed.min;
6270
6433
  this.max = observed.max;
6271
- this.step = observed.step;
6272
6434
  }
6273
6435
  get min() {
6274
6436
  const v = this._input.min;
@@ -6314,13 +6476,14 @@ class InputLocalDate extends Input {
6314
6476
  case 'd':
6315
6477
  r.setDate(r.getDate() + offset * sign);
6316
6478
  break;
6317
- case 'm':
6479
+ case 'm': {
6318
6480
  const originalDay = r.getDate();
6319
6481
  r.setMonth(r.getMonth() + offset * sign);
6320
6482
  if (r.getDate() !== originalDay) {
6321
6483
  r.setDate(0);
6322
6484
  }
6323
6485
  break;
6486
+ }
6324
6487
  case 'y':
6325
6488
  r.setFullYear(r.getFullYear() + offset * sign);
6326
6489
  break;
@@ -6333,10 +6496,62 @@ class InputLocalTime extends InputLocalDate {
6333
6496
  _type() {
6334
6497
  return 'time';
6335
6498
  }
6499
+ get min() {
6500
+ const v = this._input.min;
6501
+ return v === '' ? null : v;
6502
+ }
6503
+ set min(v) {
6504
+ this._input.min = this.#fromNowOrOffset(v);
6505
+ }
6506
+ get max() {
6507
+ const v = this._input.max;
6508
+ return v === '' ? null : v;
6509
+ }
6510
+ set max(v) {
6511
+ this._input.max = this.#fromNowOrOffset(v);
6512
+ }
6513
+ /**
6514
+ * Resolves `now` and hour or minute offsets against the current time, wrapping
6515
+ * around midnight. `m` is minutes here, unlike the date offsets of the parent where
6516
+ * it is months: months mean nothing on a time. Anything else is passed through.
6517
+ */
6518
+ #fromNowOrOffset(v) {
6519
+ if (!v) {
6520
+ return '';
6521
+ }
6522
+ const resolved = new Date();
6523
+ if (v !== 'now') {
6524
+ const re = /^([+-])(\d+)([hm])$/;
6525
+ const match = re.exec(v);
6526
+ if (!match) {
6527
+ return v;
6528
+ }
6529
+ const sign = match[1] === '-' ? -1 : 1;
6530
+ const offset = +match[2] * sign;
6531
+ if (match[3] === 'h') {
6532
+ resolved.setHours(resolved.getHours() + offset);
6533
+ } else {
6534
+ resolved.setMinutes(resolved.getMinutes() + offset);
6535
+ }
6536
+ }
6537
+ return InputLocalTime.#snapped(resolved, Number(this._input.step) || 60);
6538
+ }
6539
+ /**
6540
+ * Truncates a time to the step grid: min anchors that grid, so a bound that is not
6541
+ * on it makes every value on it invalid.
6542
+ */
6543
+ static #snapped(date, stepSeconds) {
6544
+ const pad = (n) => String(n).padStart(2, '0');
6545
+ const seconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
6546
+ const snapped = Math.floor(seconds / stepSeconds) * stepSeconds;
6547
+ const hh = pad(Math.floor(snapped / 3600));
6548
+ const mm = pad(Math.floor((snapped % 3600) / 60));
6549
+ return stepSeconds % 60 === 0 ? `${hh}:${mm}` : `${hh}:${mm}:${pad(snapped % 60)}`;
6550
+ }
6336
6551
  }
6337
6552
 
6338
6553
  class InputInstant extends Input {
6339
- static observed = ['value', 'readonly:presence', 'required:presence', 'min', 'max', 'step'];
6554
+ static observed = ['value', 'readonly:presence', 'required:presence', 'placeholder', 'min', 'max', 'step'];
6340
6555
  _type() {
6341
6556
  return 'datetime-local';
6342
6557
  }
@@ -6381,28 +6596,28 @@ class InputFile extends Input {
6381
6596
  static l10n = {
6382
6597
  en: {
6383
6598
  dropzonelabel: 'Click or drop your files here',
6384
- unaccepptablefiletype: 'Only files of type {0} are supported',
6599
+ unacceptablefiletype: 'Only files of type {0} are supported',
6385
6600
  maxfilesizeexceeded: 'Maximum supported file size is {0}',
6386
6601
  maxtotalsizeexceeded: 'Maximum supported total file size is {0}',
6387
6602
  maxfilesexceeded: 'Maximum files count exceeded',
6388
6603
  },
6389
6604
  it: {
6390
6605
  dropzonelabel: 'Clicca o trascina i file qui',
6391
- unaccepptablefiletype: 'Solo i file di tipo {0} sono supportati',
6606
+ unacceptablefiletype: 'Solo i file di tipo {0} sono supportati',
6392
6607
  maxfilesizeexceeded: 'La dimensione massima di un file è di {0}',
6393
6608
  maxtotalsizeexceeded: 'La dimensione massima complessiva dei file è di {0}',
6394
6609
  maxfilesexceeded: 'Numero massimo di file superato',
6395
6610
  },
6396
6611
  es: {
6397
6612
  dropzonelabel: 'Haz clic o arrastra tus archivos aquí',
6398
- unaccepptablefiletype: 'Solo se admiten archivos de tipo {0}',
6613
+ unacceptablefiletype: 'Solo se admiten archivos de tipo {0}',
6399
6614
  maxfilesizeexceeded: 'El tamaño máximo de archivo admitido es {0}',
6400
6615
  maxtotalsizeexceeded: 'El tamaño total máximo admitido es {0}',
6401
6616
  maxfilesexceeded: 'Se ha superado el número máximo de archivos',
6402
6617
  },
6403
6618
  fr: {
6404
6619
  dropzonelabel: 'Cliquez ou déposez vos fichiers ici',
6405
- unaccepptablefiletype: 'Seuls les fichiers de type {0} sont pris en charge',
6620
+ unacceptablefiletype: 'Seuls les fichiers de type {0} sont pris en charge',
6406
6621
  maxfilesizeexceeded: 'La taille maximale de fichier prise en charge est {0}',
6407
6622
  maxtotalsizeexceeded: 'La taille totale maximale prise en charge est {0}',
6408
6623
  maxfilesexceeded: 'Nombre maximal de fichiers dépassé',
@@ -6412,6 +6627,7 @@ class InputFile extends Input {
6412
6627
  'value',
6413
6628
  'readonly:presence',
6414
6629
  'required:presence',
6630
+ 'placeholder',
6415
6631
  'accept:csv',
6416
6632
  'multiple:presence',
6417
6633
  'itemlist:presence',
@@ -6461,7 +6677,7 @@ class InputFile extends Input {
6461
6677
  };
6462
6678
  render(conf) {
6463
6679
  const { observed } = conf;
6464
- super.render(conf);
6680
+ super.render({ ...conf, skipObservedSetup: true });
6465
6681
  this.#items = this.querySelector('ful-item-list');
6466
6682
  this.#dropzone = this.querySelector('[data-ref=dropzone]');
6467
6683
  this.#warnings = this.querySelector('ful-field-warnings');
@@ -6472,6 +6688,12 @@ class InputFile extends Input {
6472
6688
  this.maxfiles = observed.maxfiles;
6473
6689
  this.maxfilesize = observed.maxfilesize;
6474
6690
  this.maxtotalsize = observed.maxtotalsize;
6691
+
6692
+ this.disabled = conf.disabled;
6693
+ this.readonly = observed.readonly;
6694
+ this.required = observed.required;
6695
+ this.placeholder = observed.placeholder;
6696
+ this.value = observed.value;
6475
6697
  this.#warnings.addEventListener('animationend', (e) => {
6476
6698
  e.target.remove();
6477
6699
  });
@@ -6479,11 +6701,17 @@ class InputFile extends Input {
6479
6701
  if (!e.target.closest('button')) {
6480
6702
  return;
6481
6703
  }
6482
- const fileName = e.target.closest('ful-item').dataset.name;
6704
+ const idx = [...this.#items.children].indexOf(e.target.closest('ful-item'));
6705
+ if (idx === -1) {
6706
+ return;
6707
+ }
6483
6708
  const dt = new DataTransfer();
6484
- [...this.files].filter((f) => f.name !== fileName).forEach((f) => dt.items.add(f));
6709
+ [...this.files]
6710
+ .filter((f, i) => i !== idx)
6711
+ .forEach((f) => {
6712
+ dt.items.add(f);
6713
+ });
6485
6714
  this.files = dt.files;
6486
- this.#update();
6487
6715
  });
6488
6716
  this.#dropzone.addEventListener('click', (e) => {
6489
6717
  this.querySelector('input')?.click();
@@ -6494,10 +6722,15 @@ class InputFile extends Input {
6494
6722
  });
6495
6723
  this.#dropzone.addEventListener('drop', (e) => {
6496
6724
  e.preventDefault();
6725
+ const dropped = [...e.dataTransfer.items].filter((i) => i.kind === 'file');
6726
+ if (dropped.length === 0) {
6727
+ return;
6728
+ }
6497
6729
  const dt = new DataTransfer();
6498
- [...e.dataTransfer.items].filter((i) => i.kind === 'file').forEach((i) => dt.items.add(i.getAsFile()));
6730
+ dropped.forEach((i) => {
6731
+ dt.items.add(i.getAsFile());
6732
+ });
6499
6733
  this.files = dt.files;
6500
- this.#update();
6501
6734
  });
6502
6735
  this._input.addEventListener('change', (e) => {
6503
6736
  this.#update();
@@ -6512,6 +6745,7 @@ class InputFile extends Input {
6512
6745
  }
6513
6746
  #update() {
6514
6747
  this.setCustomValidity();
6748
+ this.#warnings.replaceChildren();
6515
6749
  this.#ensureAcceptable();
6516
6750
  this.#ensureFileSizes();
6517
6751
  this.#ensureTotalSize();
@@ -6522,7 +6756,7 @@ class InputFile extends Input {
6522
6756
  .renderTo(this.#items);
6523
6757
  }
6524
6758
  warning(key, args) {
6525
- this.template('warning').withOverlay({ key, args }).renderTo(this.#warnings);
6759
+ this.template('warning').withOverlay({ key, args }).appendTo(this.#warnings);
6526
6760
  }
6527
6761
  #ensureAcceptable() {
6528
6762
  if (!this.#accept.length) {
@@ -6535,10 +6769,14 @@ class InputFile extends Input {
6535
6769
  if (unacceptable.length === 0) {
6536
6770
  return;
6537
6771
  }
6538
- this.warning('unaccepptablefiletype', this.#accept.join(', '));
6772
+ this.warning('unacceptablefiletype', this.#accept.join(', '));
6539
6773
  const dt = new DataTransfer();
6540
- [...this.files].filter((f) => !unacceptable.includes(f)).forEach((f) => dt.items.add(f));
6541
- this.files = dt.files;
6774
+ [...this.files]
6775
+ .filter((f) => !unacceptable.includes(f))
6776
+ .forEach((f) => {
6777
+ dt.items.add(f);
6778
+ });
6779
+ this._input.files = dt.files;
6542
6780
  }
6543
6781
  #ensureFilesCount() {
6544
6782
  if (this.#maxfiles === null) {
@@ -6548,8 +6786,7 @@ class InputFile extends Input {
6548
6786
  return;
6549
6787
  }
6550
6788
  this.warning('maxfilesexceeded');
6551
- const dt = new DataTransfer();
6552
- this.files = dt.files;
6789
+ this._input.files = new DataTransfer().files;
6553
6790
  }
6554
6791
 
6555
6792
  #ensureFileSizes() {
@@ -6562,8 +6799,12 @@ class InputFile extends Input {
6562
6799
  }
6563
6800
  this.warning('maxfilesizeexceeded', this.#formatByteSize(this.#maxfilesize));
6564
6801
  const dt = new DataTransfer();
6565
- [...this.files].filter((f) => !oversized.includes(f)).forEach((f) => dt.items.add(f));
6566
- this.files = dt.files;
6802
+ [...this.files]
6803
+ .filter((f) => !oversized.includes(f))
6804
+ .forEach((f) => {
6805
+ dt.items.add(f);
6806
+ });
6807
+ this._input.files = dt.files;
6567
6808
  }
6568
6809
  #ensureTotalSize() {
6569
6810
  if (this.#maxtotalsize === null) {
@@ -6574,7 +6815,7 @@ class InputFile extends Input {
6574
6815
  return;
6575
6816
  }
6576
6817
  this.warning('maxtotalsizeexceeded', this.#formatByteSize(this.#maxtotalsize));
6577
- this.files = new DataTransfer().files;
6818
+ this._input.files = new DataTransfer().files;
6578
6819
  }
6579
6820
  get accept() {
6580
6821
  return this.#accept;
@@ -6600,6 +6841,7 @@ class InputFile extends Input {
6600
6841
  }
6601
6842
  set files(vs) {
6602
6843
  this._input.files = vs;
6844
+ this.#update();
6603
6845
  }
6604
6846
  get file() {
6605
6847
  return this.files[0] ?? null;
@@ -6620,7 +6862,6 @@ class InputFile extends Input {
6620
6862
  return;
6621
6863
  }
6622
6864
  this.files = new DataTransfer().files;
6623
- this.#update();
6624
6865
  }
6625
6866
  get totalsize() {
6626
6867
  return Array.from(this.files).reduce((a, f) => a + f.size, 0);
@@ -6787,7 +7028,7 @@ class SelectLoader {
6787
7028
  const http = registry.component('http-client');
6788
7029
  const responseMapper = SelectLoader.#responseMapperFrom(el);
6789
7030
 
6790
- if ('chunked' == el.getAttribute('mode')) {
7031
+ if ('chunked' === el.getAttribute('mode')) {
6791
7032
  return new PartialRemoteLoader({
6792
7033
  http,
6793
7034
  url: el.getAttribute('src'),
@@ -6863,8 +7104,14 @@ class Dropdown extends ParsedElement {
6863
7104
  });
6864
7105
  this.replaceChildren(fragment);
6865
7106
  }
7107
+ #selected() {
7108
+ return this.#menu?.querySelector('[selected]') ?? this.#menu?.firstElementChild ?? null;
7109
+ }
6866
7110
  acceptSelection() {
6867
- const selected = this.#menu.querySelector('[selected]') ?? this.#menu.firstElementChild;
7111
+ const selected = this.#selected();
7112
+ if (!selected) {
7113
+ return;
7114
+ }
6868
7115
  this.#change(selected);
6869
7116
  }
6870
7117
  update(values) {
@@ -6900,6 +7147,9 @@ class Dropdown extends ParsedElement {
6900
7147
  try {
6901
7148
  const data = await loader();
6902
7149
  this.update(data);
7150
+ } catch (e) {
7151
+ this.hide();
7152
+ throw e;
6903
7153
  } finally {
6904
7154
  this.#spinner.setAttribute('hidden', '');
6905
7155
  this.#menu.removeAttribute('hidden');
@@ -6907,9 +7157,9 @@ class Dropdown extends ParsedElement {
6907
7157
  }
6908
7158
  async moveOrShow(forward, loader) {
6909
7159
  if (this.shown) {
6910
- const selected = this.#menu.querySelector('[selected]') ?? this.#menu.firstElementChild;
6911
- const candidate = selected[`${forward ? 'next' : 'previous'}ElementSibling`];
6912
- if (candidate) {
7160
+ const selected = this.#selected();
7161
+ const candidate = selected?.[`${forward ? 'next' : 'previous'}ElementSibling`];
7162
+ if (selected && candidate) {
6913
7163
  selected.removeAttribute('selected');
6914
7164
  candidate.setAttribute('selected', '');
6915
7165
  candidate.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
@@ -6962,6 +7212,7 @@ class Select extends ParsedElement {
6962
7212
  #multiple;
6963
7213
  #fieldError;
6964
7214
  #values = new Map();
7215
+ #token = 0;
6965
7216
  constructor() {
6966
7217
  super();
6967
7218
  this.internals = this.attachInternals();
@@ -6974,7 +7225,11 @@ class Select extends ParsedElement {
6974
7225
  .create(this, { options: slots.options });
6975
7226
 
6976
7227
  this.#multiple = this.hasAttribute('multiple');
6977
- await this.#loader.prefetch?.();
7228
+ try {
7229
+ await this.#loader.prefetch?.();
7230
+ } catch (/** @type any */ e) {
7231
+ console.warn('failed to prefetch select options', this, 'reason:', e);
7232
+ }
6978
7233
  const fragment = this.template().withOverlay({ slots, name }).render();
6979
7234
  this.#input = fragment.querySelector('input');
6980
7235
  this.#items = fragment.querySelector('ful-item-list');
@@ -6993,11 +7248,9 @@ class Select extends ParsedElement {
6993
7248
  this.#fieldError = fragment.querySelector('ful-field-error');
6994
7249
  this.#input.ariaDescribedByElements = [this.#fieldError];
6995
7250
  this.#input.ariaLabelledByElements = [label];
6996
-
6997
- const self = this;
6998
7251
  const [dload, abortdload] = Timing.throttle(400, () => {
6999
- self.#input.setAttribute('aria-expanded', 'true');
7000
- self.#ddmenu.show(() => self.#loader.load(self.#input.value));
7252
+ this.#input.setAttribute('aria-expanded', 'true');
7253
+ this.#ddmenu.show(() => this.#loader.load(this.#input.value));
7001
7254
  });
7002
7255
  this.addEventListener('click', (/** @type any */ e) => {
7003
7256
  if (e.target.matches('input')) {
@@ -7064,13 +7317,13 @@ class Select extends ParsedElement {
7064
7317
  case 'ArrowUp': {
7065
7318
  e.preventDefault();
7066
7319
  this.#input.setAttribute('aria-expanded', 'true');
7067
- this.#ddmenu.moveOrShow(false, () => self.#loader.load(self.#input.value));
7320
+ this.#ddmenu.moveOrShow(false, () => this.#loader.load(this.#input.value));
7068
7321
  break;
7069
7322
  }
7070
7323
  case 'ArrowDown': {
7071
7324
  e.preventDefault();
7072
7325
  this.#input.setAttribute('aria-expanded', 'true');
7073
- this.#ddmenu.moveOrShow(true, () => self.#loader.load(self.#input.value));
7326
+ this.#ddmenu.moveOrShow(true, () => this.#loader.load(this.#input.value));
7074
7327
  break;
7075
7328
  }
7076
7329
  case 'Escape': {
@@ -7079,6 +7332,12 @@ class Select extends ParsedElement {
7079
7332
  break;
7080
7333
  }
7081
7334
  case 'Enter': {
7335
+ if (!this.#ddmenu.shown) {
7336
+ //nothing to accept: submit the form as ful-input does. the inner
7337
+ //input carries form="" so it never submits one on its own
7338
+ this.#requestSubmit();
7339
+ return;
7340
+ }
7082
7341
  e.preventDefault();
7083
7342
  this.#input.setAttribute('aria-expanded', 'false');
7084
7343
  this.#ddmenu.acceptSelection();
@@ -7127,6 +7386,16 @@ class Select extends ParsedElement {
7127
7386
  async withLoader(fn) {
7128
7387
  return await fn(this.#loader);
7129
7388
  }
7389
+ #requestSubmit() {
7390
+ const form = this.internals.form;
7391
+ if (!form) {
7392
+ return;
7393
+ }
7394
+ const candidates = /** @type [HTMLButtonElement|HTMLInputElement] */ (
7395
+ Array.from(form.querySelectorAll('button:not(:disabled), input:not(:disabled)'))
7396
+ );
7397
+ form.requestSubmit(candidates.find((el) => el.type === 'submit'));
7398
+ }
7130
7399
  #changed() {
7131
7400
  const selection = [...this.#values.entries()].map((e) => ({
7132
7401
  key: e[0],
@@ -7156,16 +7425,43 @@ class Select extends ParsedElement {
7156
7425
  this.template('items').withOverlay({ entries: this.#values.entries() }).renderTo(this.#items);
7157
7426
  }
7158
7427
  set value(vs) {
7159
- if (vs === null) {
7160
- this.#values = new Map();
7161
- this.#syncBadges();
7428
+ //the csvm mapper yields [] for a missing multiple value, an empty string is
7429
+ //left alone: it is a usable key for an <option value="">
7430
+ const keys = vs == null ? [] : Array.isArray(vs) ? vs : [vs];
7431
+ //the keys are known synchronously and are all `value` reads, so they are applied
7432
+ //now: only the labels need the loader, until then a key stands in for its own
7433
+ this.#values = new Map(keys.map((k) => [k, [k]]));
7434
+ this.#syncBadges();
7435
+ const token = ++this.#token;
7436
+ if (keys.length === 0) {
7162
7437
  return;
7163
7438
  }
7164
- (async () => {
7165
- const entries = await (this.#multiple ? this.#loader.exact(...vs) : this.#loader.exact(vs));
7166
- this.#values = new Map(entries.map((e) => [e[0], e.slice(1)]));
7167
- this.#syncBadges();
7168
- })();
7439
+ this.#resolve(keys, token);
7440
+ }
7441
+ /**
7442
+ * Resolves the labels of the assigned keys. A failed lookup is left to reject so
7443
+ * that it is reported like any other failure: the keys stay applied either way.
7444
+ */
7445
+ async #resolve(keys, token) {
7446
+ const entries = await this.#loader.exact(...keys);
7447
+ if (token !== this.#token) {
7448
+ //a newer assignment has been made in the meantime
7449
+ return;
7450
+ }
7451
+ //label the keys that are still selected: a removal made while the lookup was in
7452
+ //flight must not be undone by it, and a key the loader does not know is dropped
7453
+ const resolved = new Map(entries.map((e) => [e[0], e.slice(1)]));
7454
+ for (const key of keys) {
7455
+ if (!this.#values.has(key)) {
7456
+ continue;
7457
+ }
7458
+ if (resolved.has(key)) {
7459
+ this.#values.set(key, resolved.get(key));
7460
+ } else {
7461
+ this.#values.delete(key);
7462
+ }
7463
+ }
7464
+ this.#syncBadges();
7169
7465
  }
7170
7466
  get value() {
7171
7467
  if (this.#multiple) {
@@ -7184,6 +7480,11 @@ class Select extends ParsedElement {
7184
7480
  }
7185
7481
  set disabled(d) {
7186
7482
  Attributes.toggle(this.#input, 'disabled', d);
7483
+ //also on the host: a form associated element only matches :disabled through its
7484
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
7485
+ //is needed, disabled is deliberately not observed: the platform delivers it
7486
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
7487
+ Attributes.toggle(this, 'disabled', d);
7187
7488
  }
7188
7489
  get readonly() {
7189
7490
  return this.#input.readOnly;
@@ -7289,7 +7590,9 @@ class RadioGroup extends ParsedElement {
7289
7590
  return [input, label];
7290
7591
  });
7291
7592
 
7292
- radioEls.forEach((el) => el.remove());
7593
+ radioEls.forEach((el) => {
7594
+ el.remove();
7595
+ });
7293
7596
  this.template().withOverlay({ name, slots, inputsAndLabels }).renderTo(this);
7294
7597
  this.#fieldset = this.firstElementChild;
7295
7598
  this.disabled = disabled;
@@ -7333,6 +7636,11 @@ class RadioGroup extends ParsedElement {
7333
7636
  }
7334
7637
  set disabled(d) {
7335
7638
  Attributes.toggle(this.#fieldset, 'disabled', d);
7639
+ //also on the host: a form associated element only matches :disabled through its
7640
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
7641
+ //is needed, disabled is deliberately not observed: the platform delivers it
7642
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
7643
+ Attributes.toggle(this, 'disabled', d);
7336
7644
  }
7337
7645
  get required() {
7338
7646
  return this.#fieldset.getAttribute('aria-required') === 'true';
@@ -7382,7 +7690,7 @@ class Checkbox extends ParsedElement {
7382
7690
  this.internals.role = 'presentation';
7383
7691
  }
7384
7692
  render({ slots, observed, disabled }) {
7385
- const isSwitch = this.getAttribute('type') == 'switch';
7693
+ const isSwitch = this.getAttribute('type') === 'switch';
7386
7694
  const klass = isSwitch ? 'form-check form-switch' : 'form-check';
7387
7695
  const fragment = this.template().withOverlay({ slots, klass, isSwitch }).render();
7388
7696
  this.#container = fragment.firstElementChild;
@@ -7446,6 +7754,11 @@ class Checkbox extends ParsedElement {
7446
7754
  }
7447
7755
  set disabled(d) {
7448
7756
  Attributes.toggle(this.#input, 'disabled', d);
7757
+ //also on the host: a form associated element only matches :disabled through its
7758
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
7759
+ //is needed, disabled is deliberately not observed: the platform delivers it
7760
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
7761
+ Attributes.toggle(this, 'disabled', d);
7449
7762
  }
7450
7763
  get required() {
7451
7764
  return this.#input.getAttribute('aria-required') === 'true';
@@ -7486,9 +7799,10 @@ class Spinner extends ParsedElement {
7486
7799
  class SortButton extends ParsedElement {
7487
7800
  static observed = ['order'];
7488
7801
  #order;
7489
- render() {
7802
+ render({ observed }) {
7490
7803
  const sorter = this.getAttribute('sorter');
7491
7804
  const orders = ['asc', 'desc', null];
7805
+ this.order = observed.order;
7492
7806
  this.addEventListener('click', () => {
7493
7807
  const nextOrder = orders[(orders.indexOf(this.order) + 1) % 3];
7494
7808
  this.dispatchEvent(
@@ -7578,10 +7892,12 @@ class Pagination extends ParsedElement {
7578
7892
  #total = 0;
7579
7893
  #current = 0;
7580
7894
  render({ observed }) {
7581
- this.update(observed.current ?? 0, observed.total ?? 0);
7895
+ this.total = observed.total ?? 0;
7896
+ this.current = observed.current ?? 0;
7582
7897
  this.addEventListener('click', (/** @type any */ evt) => {
7583
7898
  const el = evt.target.closest('a');
7584
- if (!el) {
7899
+ if (!el || el.classList.contains('disabled')) {
7900
+ //a disabled link leads nowhere: the page it would ask for does not exist
7585
7901
  return;
7586
7902
  }
7587
7903
  this.dispatchEvent(
@@ -7597,25 +7913,20 @@ class Pagination extends ParsedElement {
7597
7913
  }
7598
7914
  update(current, total) {
7599
7915
  const maxRender = Number(this.getAttribute('pages') ?? '5');
7600
- const prev = { index: Math.max(0, current - 1), enabled: current > 0 };
7916
+ const hasPrev = current > 0;
7917
+ const hasNext = current + 1 < total;
7918
+ //a disabled arrow carries no page: there is nothing valid for it to point at
7919
+ const prev = { index: hasPrev ? current - 1 : null, enabled: hasPrev };
7601
7920
  const curr = { index: current, label: current + 1 };
7602
- const next = { index: Math.min(total, current + 1), enabled: current + 1 < total };
7603
- const pages = [
7604
- {
7605
- index: current,
7606
- label: current + 1,
7607
- },
7608
- ];
7609
- for (let mid = current, offset = 1; offset !== maxRender && pages.length != maxRender; ++offset) {
7610
- const p = mid - offset;
7611
- if (p >= 0) {
7612
- pages.unshift({ index: p, label: p + 1 });
7613
- }
7614
- const n = mid + offset;
7615
- if (n < total) {
7616
- pages.push({ index: n, label: n + 1 });
7617
- }
7618
- }
7921
+ const next = { index: hasNext ? current + 1 : null, enabled: hasNext };
7922
+ //the window holds at most maxRender pages, centered on the current one and slid
7923
+ //back towards the end so it stays full on the last pages
7924
+ const rendered = Math.max(1, Math.min(maxRender, total));
7925
+ const first = Math.max(0, Math.min(current - Math.floor((rendered - 1) / 2), total - rendered));
7926
+ const pages = Array.from({ length: rendered }, (_, offset) => ({
7927
+ index: first + offset,
7928
+ label: first + offset + 1,
7929
+ }));
7619
7930
  this.template().withOverlay({ total, prev, curr, next, pages }).renderTo(this);
7620
7931
  }
7621
7932
  get total() {
@@ -7642,9 +7953,10 @@ class Pagination extends ParsedElement {
7642
7953
 
7643
7954
  class TableSchemaParser {
7644
7955
  static parse(nodeOrFragment, template) {
7645
- const schema = Nodes.queryChildren(nodeOrFragment, 'schema');
7956
+ //nodeOrFragment is undefined when the slot is missing altogether
7957
+ const schema = nodeOrFragment ? Nodes.queryChildren(nodeOrFragment, 'schema') : null;
7646
7958
  if (!schema) {
7647
- throw new Error(`missing expected <schema> in ${nodeOrFragment}`);
7959
+ throw new Error('missing expected <schema>: ful-table needs a <template slot="schema"> holding one');
7648
7960
  }
7649
7961
  const headersTr = document.createElement('tr');
7650
7962
  const rowsTr = document.createElement('tr');
@@ -7863,9 +8175,10 @@ class Table extends ParsedElement {
7863
8175
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
7864
8176
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
7865
8177
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
7866
- this.#sorters = table.querySelectorAll(':scope > thead ful-sorter') ?? [];
7867
8178
  this.replaceChildren(fragment);
7868
- schema.headersTemplate.renderTo(this.querySelector('thead'));
8179
+ const thead = /** @type HTMLTableSectionElement */ (this.querySelector('thead'));
8180
+ schema.headersTemplate.renderTo(thead);
8181
+ this.#sorters = thead.querySelectorAll('ful-sorter');
7869
8182
  await Rendering.waitForChildren(this);
7870
8183
 
7871
8184
  const maybeForm = /** @type any */ (Nodes.queryChildren(this, 'ful-form'));
@@ -7900,11 +8213,16 @@ class Table extends ParsedElement {
7900
8213
  this.addEventListener('sort-requested', async (/** @type any */ e) => {
7901
8214
  const sortRequest = e.detail.value.order ? e.detail.value : null;
7902
8215
  await this.load(this.#latestRequest.pageRequest, sortRequest, this.#latestRequest.filterRequest);
7903
- this.#sorters.forEach((s) => (s.order = null));
8216
+ this.#sorters.forEach((s) => {
8217
+ s.order = null;
8218
+ });
7904
8219
  e.target.order = e.detail.value.order;
7905
8220
  });
7906
8221
  if (this.hasAttribute('autoload')) {
7907
- await this.reload();
8222
+ //not awaited: the first load must not hold up the upgrade, and a loader that
8223
+ //fails or never answers must not keep ftl:ready from firing for the page.
8224
+ //load renders its own error state and lets the failure reject, so it is reported
8225
+ this.reload();
7908
8226
  }
7909
8227
  }
7910
8228
 
@@ -7917,7 +8235,7 @@ class Table extends ParsedElement {
7917
8235
  }
7918
8236
  async load(pageRequest, sortRequest, filterRequest) {
7919
8237
  this.#body.replaceChildren();
7920
- this.#loading.removeAttribute('hidden', '');
8238
+ this.#loading.removeAttribute('hidden');
7921
8239
  this.#feedback.setAttribute('hidden', '');
7922
8240
  this.#noAutoload.setAttribute('hidden', '');
7923
8241
  try {
@@ -7926,7 +8244,7 @@ class Table extends ParsedElement {
7926
8244
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
7927
8245
  } catch (/** @type any */ error) {
7928
8246
  this.#loading.setAttribute('hidden', '');
7929
- this.#feedback.removeAttribute('hidden', '');
8247
+ this.#feedback.removeAttribute('hidden');
7930
8248
  if (!error.problems) {
7931
8249
  this.#feedback.querySelector('[data-ref=feedback-error]').textContent = error;
7932
8250
  } else {
@@ -7968,7 +8286,7 @@ class Table extends ParsedElement {
7968
8286
  }
7969
8287
 
7970
8288
  class InstantFilter extends Input {
7971
- static observed = ['value:json', 'readonly:presence', 'required:presence'];
8289
+ static observed = ['value:json', 'readonly:presence', 'required:presence', 'placeholder'];
7972
8290
  static template = `
7973
8291
  <div class="form-label">
7974
8292
  <label>{{{{ slots.default }}}}</label>
@@ -8002,10 +8320,16 @@ class InstantFilter extends Input {
8002
8320
  this.#operator = this.querySelector('[data-ref=operator]');
8003
8321
  this.#value1 = this.querySelector('[data-ref=value1]');
8004
8322
  this.#value2 = this.querySelector('[data-ref=value2]');
8323
+ //Input.render only re-dispatches changes coming from the first operand
8324
+ this.#value2.addEventListener('change', (evt) => {
8325
+ evt.stopPropagation();
8326
+ this.#notifyChange();
8327
+ });
8005
8328
 
8006
8329
  this.disabled = conf.disabled;
8007
8330
  this.readonly = conf.observed.readonly;
8008
8331
  this.required = conf.observed.required;
8332
+ this.placeholder = conf.observed.placeholder;
8009
8333
  this.value = conf.observed.value;
8010
8334
 
8011
8335
  this.addEventListener('click', (evt) => {
@@ -8015,9 +8339,13 @@ class InstantFilter extends Input {
8015
8339
  }
8016
8340
  const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
8017
8341
  const value = /** @type String */ (target.getAttribute('value'));
8342
+ const previous = btn.getAttribute('value');
8018
8343
  Attributes.toggle(this.#value2, 'hidden', value !== 'BETWEEN');
8019
8344
  btn.setAttribute('value', value);
8020
8345
  btn.innerHTML = target.innerHTML;
8346
+ if (previous !== value) {
8347
+ this.#notifyChange();
8348
+ }
8021
8349
  });
8022
8350
  }
8023
8351
 
@@ -8033,14 +8361,40 @@ class InstantFilter extends Input {
8033
8361
  return;
8034
8362
  }
8035
8363
  const [operator, ...values] = v;
8036
- this.#operator.setAttribute('value', operator);
8364
+ this.#showOperator(operator);
8037
8365
  this.#value1.value = values[0] ? Instant.isoToLocal(values[0]) : values[0];
8038
8366
  this.#value2.value = values[1] ? Instant.isoToLocal(values[1]) : values[1];
8039
8367
  }
8368
+ #showOperator(operator) {
8369
+ this.#operator.setAttribute('value', operator);
8370
+ const items = Array.from(this.#operator.nextElementSibling?.querySelectorAll('li > a[value]') ?? []);
8371
+ const item = items.find((a) => a.getAttribute('value') === operator);
8372
+ if (item) {
8373
+ this.#operator.innerHTML = item.innerHTML;
8374
+ }
8375
+ Attributes.toggle(this.#value2, 'hidden', operator !== 'BETWEEN');
8376
+ }
8377
+ #notifyChange() {
8378
+ this.dispatchEvent(
8379
+ new CustomEvent('change', {
8380
+ bubbles: true,
8381
+ cancelable: false,
8382
+ detail: {
8383
+ value: this.value,
8384
+ },
8385
+ }),
8386
+ );
8387
+ }
8388
+ get readonly() {
8389
+ return super.readonly;
8390
+ }
8040
8391
  set readonly(v) {
8041
8392
  this.#value2.readOnly = v;
8042
8393
  super.readonly = v;
8043
8394
  }
8395
+ get disabled() {
8396
+ return super.disabled;
8397
+ }
8044
8398
  set disabled(d) {
8045
8399
  Attributes.toggle(this.#value2, 'disabled', d);
8046
8400
  super.disabled = d;
@@ -8048,7 +8402,7 @@ class InstantFilter extends Input {
8048
8402
  }
8049
8403
 
8050
8404
  class LocalDateFilter extends Input {
8051
- static observed = ['value:json', 'readonly:presence', 'required:presence'];
8405
+ static observed = ['value:json', 'readonly:presence', 'required:presence', 'placeholder'];
8052
8406
  static template = `
8053
8407
  <div class="form-label">
8054
8408
  <label>{{{{ slots.default }}}}</label>
@@ -8083,10 +8437,16 @@ class LocalDateFilter extends Input {
8083
8437
  this.#operator = this.querySelector('[data-ref=operator]');
8084
8438
  this.#value1 = this.querySelector('[data-ref=value1]');
8085
8439
  this.#value2 = this.querySelector('[data-ref=value2]');
8440
+ //Input.render only re-dispatches changes coming from the first operand
8441
+ this.#value2.addEventListener('change', (evt) => {
8442
+ evt.stopPropagation();
8443
+ this.#notifyChange();
8444
+ });
8086
8445
 
8087
8446
  this.disabled = conf.disabled;
8088
8447
  this.readonly = conf.observed.readonly;
8089
8448
  this.required = conf.observed.required;
8449
+ this.placeholder = conf.observed.placeholder;
8090
8450
  this.value = conf.observed.value;
8091
8451
 
8092
8452
  this.addEventListener('click', (evt) => {
@@ -8096,14 +8456,18 @@ class LocalDateFilter extends Input {
8096
8456
  }
8097
8457
  const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
8098
8458
  const value = /** @type String */ (target.getAttribute('value'));
8459
+ const previous = btn.getAttribute('value');
8099
8460
  Attributes.toggle(this.#value2, 'hidden', value !== 'BETWEEN');
8100
8461
  btn.setAttribute('value', value);
8101
8462
  btn.innerHTML = target.innerHTML;
8463
+ if (previous !== value) {
8464
+ this.#notifyChange();
8465
+ }
8102
8466
  });
8103
8467
  }
8104
8468
  get value() {
8105
8469
  const operator = this.#operator.getAttribute('value');
8106
- const values = operator == 'BETWEEN' ? [this.#value1.value, this.#value2.value] : [this.#value1.value];
8470
+ const values = operator === 'BETWEEN' ? [this.#value1.value, this.#value2.value] : [this.#value1.value];
8107
8471
  return values.some((v) => v === '') ? undefined : [operator, ...values];
8108
8472
  }
8109
8473
  set value(v) {
@@ -8113,14 +8477,40 @@ class LocalDateFilter extends Input {
8113
8477
  return;
8114
8478
  }
8115
8479
  const [operator, ...values] = v;
8116
- this.#operator.setAttribute('value', operator);
8480
+ this.#showOperator(operator);
8117
8481
  this.#value1.value = values[0];
8118
8482
  this.#value2.value = values[1];
8119
8483
  }
8484
+ #showOperator(operator) {
8485
+ this.#operator.setAttribute('value', operator);
8486
+ const items = Array.from(this.#operator.nextElementSibling?.querySelectorAll('li > a[value]') ?? []);
8487
+ const item = items.find((a) => a.getAttribute('value') === operator);
8488
+ if (item) {
8489
+ this.#operator.innerHTML = item.innerHTML;
8490
+ }
8491
+ Attributes.toggle(this.#value2, 'hidden', operator !== 'BETWEEN');
8492
+ }
8493
+ #notifyChange() {
8494
+ this.dispatchEvent(
8495
+ new CustomEvent('change', {
8496
+ bubbles: true,
8497
+ cancelable: false,
8498
+ detail: {
8499
+ value: this.value,
8500
+ },
8501
+ }),
8502
+ );
8503
+ }
8504
+ get readonly() {
8505
+ return super.readonly;
8506
+ }
8120
8507
  set readonly(v) {
8121
8508
  this.#value2.readOnly = v;
8122
8509
  super.readonly = v;
8123
8510
  }
8511
+ get disabled() {
8512
+ return super.disabled;
8513
+ }
8124
8514
  set disabled(d) {
8125
8515
  Attributes.toggle(this.#value2, 'disabled', d);
8126
8516
  super.disabled = d;
@@ -8128,7 +8518,7 @@ class LocalDateFilter extends Input {
8128
8518
  }
8129
8519
 
8130
8520
  class TextFilter extends Input {
8131
- static observed = ['value:json', 'readonly:presence', 'required:presence'];
8521
+ static observed = ['value:json', 'readonly:presence', 'required:presence', 'placeholder'];
8132
8522
  static template = `
8133
8523
  <div class="form-label">
8134
8524
  <label>{{{{ slots.default }}}}</label>
@@ -8152,6 +8542,8 @@ class TextFilter extends Input {
8152
8542
  `;
8153
8543
  #operator;
8154
8544
  #value;
8545
+ //the sensitivity has no control of its own: it is carried through from whoever set the value
8546
+ #sensitivity = 'IGNORE_CASE';
8155
8547
  render(conf) {
8156
8548
  super.render({ ...conf, skipObservedSetup: true });
8157
8549
 
@@ -8161,6 +8553,7 @@ class TextFilter extends Input {
8161
8553
  this.disabled = conf.disabled;
8162
8554
  this.readonly = conf.observed.readonly;
8163
8555
  this.required = conf.observed.required;
8556
+ this.placeholder = conf.observed.placeholder;
8164
8557
  this.value = conf.observed.value;
8165
8558
 
8166
8559
  this.addEventListener('click', (evt) => {
@@ -8170,13 +8563,17 @@ class TextFilter extends Input {
8170
8563
  }
8171
8564
  const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
8172
8565
  const value = /** @type String */ (target.getAttribute('value'));
8566
+ const previous = btn.getAttribute('value');
8173
8567
  btn.setAttribute('value', value);
8174
8568
  btn.innerHTML = target.innerHTML;
8569
+ if (previous !== value) {
8570
+ this.#notifyChange();
8571
+ }
8175
8572
  });
8176
8573
  }
8177
8574
  get value() {
8178
8575
  const operator = this.#operator.getAttribute('value');
8179
- return this.#value.value === '' ? undefined : [operator, 'IGNORE_CASE', this.#value.value];
8576
+ return this.#value.value === '' ? undefined : [operator, this.#sensitivity, this.#value.value];
8180
8577
  }
8181
8578
  set value(v) {
8182
8579
  if (v == null) {
@@ -8184,15 +8581,35 @@ class TextFilter extends Input {
8184
8581
  return;
8185
8582
  }
8186
8583
  const [operator, sensitivity, value] = v;
8187
- this.#operator.setAttribute('value', operator);
8584
+ this.#showOperator(operator);
8585
+ this.#sensitivity = sensitivity ?? 'IGNORE_CASE';
8188
8586
  this.#value.value = value;
8189
8587
  }
8588
+ #showOperator(operator) {
8589
+ this.#operator.setAttribute('value', operator);
8590
+ const items = Array.from(this.#operator.nextElementSibling?.querySelectorAll('li > a[value]') ?? []);
8591
+ const item = items.find((a) => a.getAttribute('value') === operator);
8592
+ if (item) {
8593
+ this.#operator.innerHTML = item.innerHTML;
8594
+ }
8595
+ }
8596
+ #notifyChange() {
8597
+ this.dispatchEvent(
8598
+ new CustomEvent('change', {
8599
+ bubbles: true,
8600
+ cancelable: false,
8601
+ detail: {
8602
+ value: this.value,
8603
+ },
8604
+ }),
8605
+ );
8606
+ }
8190
8607
  }
8191
8608
 
8192
8609
  class LocalizationModule {
8193
8610
  static t(k, ...args) {
8194
- //@ts-ignore
8195
- const format = this.l10n?.[this.language]?.[k] ?? this.l10n?.['en']?.[k] ?? k;
8611
+ //@ts-expect-error l10n and language come from the element class and the data stack
8612
+ const format = this.l10n?.[this.language]?.[k] ?? this.l10n?.en?.[k] ?? k;
8196
8613
  if (args.length === 0) {
8197
8614
  return format;
8198
8615
  }
@@ -8275,9 +8692,11 @@ var ful = /*#__PURE__*/Object.freeze({
8275
8692
  });
8276
8693
 
8277
8694
  if (typeof window !== 'undefined') {
8695
+ // biome-ignore-start lint/complexity/useLiteralKeys: checkJs rejects unknown properties on Window
8278
8696
  window['ftl'] = ftl;
8279
8697
  window['httpc'] = httpc;
8280
8698
  window['ful'] = ful;
8699
+ // biome-ignore-end lint/complexity/useLiteralKeys: checkJs rejects unknown properties on Window
8281
8700
  }
8282
8701
 
8283
8702
  export { AsyncEvents, Attributes, Base64, Bindings, Checkbox, Dropdown, ExpressionEvaluator, Expressions, Failure, Form, FormLoader, Fragments, Hex, HttpClient, HttpClientError, Input, InputFile, InputInstant, InputLocalDate, InputLocalTime, Instant, InstantFilter, LightSlots, LocalDate, LocalDateFilter, LocalStorage, LocalizationModule, MediaType, Nodes, Pagination, ParsedElement, Plugin, RadioGroup, Registry, RenderError, Rendering, Select, SelectLoader, SessionStorage, SortButton, Spinner, Table, TableSchemaParser, Template, Templates, TextFilter, Timing, VersionedLocalStorage, VersionedSessionStorage, registry };