@optionfactory/fml 8.0.0 → 8.0.1

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 (45) 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/fml.css +10 -1
  6. package/dist/fml.css.map +1 -1
  7. package/dist/fml.d.mts +73 -13
  8. package/dist/fml.iife.js +598 -208
  9. package/dist/fml.iife.js.map +1 -1
  10. package/dist/fml.iife.min.js +1 -1
  11. package/dist/fml.iife.min.js.map +1 -1
  12. package/dist/fml.min.mjs +1 -1
  13. package/dist/fml.min.mjs.map +1 -1
  14. package/dist/fml.mjs +598 -208
  15. package/dist/fml.mjs.map +1 -1
  16. package/dist/ftl.d.mts +2 -1
  17. package/dist/ftl.iife.js +60 -33
  18. package/dist/ftl.iife.js.map +1 -1
  19. package/dist/ftl.iife.min.js +1 -1
  20. package/dist/ftl.iife.min.js.map +1 -1
  21. package/dist/ftl.min.mjs +1 -1
  22. package/dist/ftl.min.mjs.map +1 -1
  23. package/dist/ftl.mjs +60 -33
  24. package/dist/ftl.mjs.map +1 -1
  25. package/dist/ful.css +10 -1
  26. package/dist/ful.css.map +1 -1
  27. package/dist/ful.d.mts +22 -6
  28. package/dist/ful.iife.js +496 -166
  29. package/dist/ful.iife.js.map +1 -1
  30. package/dist/ful.iife.min.js +1 -1
  31. package/dist/ful.iife.min.js.map +1 -1
  32. package/dist/ful.min.mjs +1 -1
  33. package/dist/ful.min.mjs.map +1 -1
  34. package/dist/ful.mjs +496 -166
  35. package/dist/ful.mjs.map +1 -1
  36. package/dist/httpc.d.mts +49 -6
  37. package/dist/httpc.iife.js +40 -9
  38. package/dist/httpc.iife.js.map +1 -1
  39. package/dist/httpc.iife.min.js +1 -1
  40. package/dist/httpc.iife.min.js.map +1 -1
  41. package/dist/httpc.min.mjs +1 -1
  42. package/dist/httpc.min.mjs.map +1 -1
  43. package/dist/httpc.mjs +40 -9
  44. package/dist/httpc.mjs.map +1 -1
  45. package/package.json +6 -7
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,
@@ -4384,6 +4389,16 @@ class UpgradeQueue {
4384
4389
  .finally(() => this.#q.delete(el));
4385
4390
  this.#q.set(el, promise);
4386
4391
  }
4392
+ /**
4393
+ * Waits for every queued upgrade, including the ones enqueued while waiting:
4394
+ * a component is only queued once its parent connects it, so a single pass would
4395
+ * miss everything nested.
4396
+ */
4397
+ async settle() {
4398
+ while (this.#q.size !== 0) {
4399
+ await Promise.all(Array.from(this.#q.values()));
4400
+ }
4401
+ }
4387
4402
  get entries() {
4388
4403
  return this.#q.entries();
4389
4404
  }
@@ -4578,18 +4593,30 @@ class Templates {
4578
4593
  }
4579
4594
  }
4580
4595
 
4581
- class Rendering {
4582
- static async waitFor(el) {
4596
+ /**
4597
+ * Waits for the queued upgrades matching the filter, including the ones enqueued while
4598
+ * waiting: a component is only queued once its parent connects it, so a single pass
4599
+ * would miss everything nested.
4600
+ * @param {(el: Element) => boolean} accept
4601
+ */
4602
+ const settle = async (accept) => {
4603
+ for (;;) {
4583
4604
  const pending = Array.from(registry.upgrades)
4584
- .filter(([child, promise]) => el.contains(child))
4585
- .map(([child, promise]) => promise);
4605
+ .filter(([child]) => accept(child))
4606
+ .map(([, promise]) => promise);
4607
+ if (pending.length === 0) {
4608
+ return;
4609
+ }
4586
4610
  await Promise.all(pending);
4587
4611
  }
4612
+ };
4613
+
4614
+ class Rendering {
4615
+ static async waitFor(el) {
4616
+ await settle((child) => el.contains(child));
4617
+ }
4588
4618
  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);
4619
+ await settle((child) => el !== child && el.contains(child));
4593
4620
  }
4594
4621
  }
4595
4622
 
@@ -4631,7 +4658,7 @@ class ParsedElement extends HTMLElement {
4631
4658
  template(name) {
4632
4659
  const { modules, data } = registry.context();
4633
4660
  let t = this.#bits().TEMPLATES[name ?? 'default'].withData(data).withModules(modules);
4634
- for (let k of ['l10n', 'config']) {
4661
+ for (const k of ['l10n', 'config']) {
4635
4662
  const v = this.constructor[k];
4636
4663
  if (v) {
4637
4664
  t = t.withOverlay({ [k]: v });
@@ -4772,13 +4799,22 @@ class Base64 {
4772
4799
 
4773
4800
  let vi = 0;
4774
4801
  let si = 0;
4775
- while (vi < str.length * 0.75) {
4802
+ //every group yields three bytes, but a padded input stops short of the last
4803
+ //one or two: writing them anyway would rely on typed arrays dropping writes
4804
+ //past their length
4805
+ while (vi < nbytes) {
4776
4806
  const v1 = d.indexOf(str.charAt(si++));
4777
4807
  const v2 = d.indexOf(str.charAt(si++));
4778
4808
  const v3 = d.indexOf(str.charAt(si++));
4779
4809
  const v4 = d.indexOf(str.charAt(si++));
4780
4810
  view[vi++] = (v1 << 2) | (v2 >> 4);
4811
+ if (vi === nbytes) {
4812
+ break;
4813
+ }
4781
4814
  view[vi++] = ((v2 & 15) << 4) | (v3 >> 2);
4815
+ if (vi === nbytes) {
4816
+ break;
4817
+ }
4782
4818
  view[vi++] = ((v3 & 3) << 6) | v4;
4783
4819
  }
4784
4820
 
@@ -4805,7 +4841,7 @@ class Hex {
4805
4841
  return Array.from(bytes)
4806
4842
  .map((b) => b.toString(16))
4807
4843
  .map((b) => (upper ? b.toUpperCase() : b))
4808
- .map((o) => o.padStart(2, 0))
4844
+ .map((o) => o.padStart(2, '0'))
4809
4845
  .join('');
4810
4846
  }
4811
4847
  }
@@ -5135,6 +5171,26 @@ const unmarshal = async (response, type) => {
5135
5171
  }
5136
5172
  };
5137
5173
 
5174
+ /**
5175
+ * Yields the entries of a headers or params initializer preserving nullish values:
5176
+ * normalizing through `Headers`/`URLSearchParams` first would stringify them to
5177
+ * "null"/"undefined" instead of removing the key.
5178
+ * @param {any} source
5179
+ * @returns {Iterable<[string, any]>}
5180
+ */
5181
+ const rawEntries = (source) => {
5182
+ if (source == null) {
5183
+ return [];
5184
+ }
5185
+ if (typeof source === 'string') {
5186
+ return new URLSearchParams(source);
5187
+ }
5188
+ if (typeof source[Symbol.iterator] === 'function') {
5189
+ return source;
5190
+ }
5191
+ return Object.entries(source);
5192
+ };
5193
+
5138
5194
  class HttpRequestBuilder {
5139
5195
  #client;
5140
5196
  #method;
@@ -5187,11 +5243,11 @@ class HttpRequestBuilder {
5187
5243
  }
5188
5244
  /**
5189
5245
  * 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
5246
+ * @param {HeadersInit|Record<string,string|null|undefined>} hs
5191
5247
  * @returns {HttpRequestBuilder} this builder
5192
5248
  */
5193
5249
  headers(hs) {
5194
- for (const [k, v] of new Headers(hs).entries()) {
5250
+ for (const [k, v] of rawEntries(hs)) {
5195
5251
  if (v == null) {
5196
5252
  this.#headers.delete(k);
5197
5253
  } else {
@@ -5216,11 +5272,11 @@ class HttpRequestBuilder {
5216
5272
  }
5217
5273
  /**
5218
5274
  * 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
5275
+ * @param {URLSearchParams|Record<string,string|null|undefined>|string[][]|string} ps
5220
5276
  * @returns {HttpRequestBuilder} this builder
5221
5277
  */
5222
5278
  params(ps) {
5223
- for (const [k, v] of new URLSearchParams(ps).entries()) {
5279
+ for (const [k, v] of rawEntries(ps)) {
5224
5280
  if (v == null) {
5225
5281
  this.#params.delete(k);
5226
5282
  } else {
@@ -5236,8 +5292,10 @@ class HttpRequestBuilder {
5236
5292
  * @returns {HttpRequestBuilder} this builder
5237
5293
  */
5238
5294
  param(k, ...vs) {
5295
+ //overriding, as header, headers and params all do: pass every value in one
5296
+ //call to get a multi valued parameter
5297
+ this.#params.delete(k);
5239
5298
  if (vs.length === 0 || vs[0] == null) {
5240
- this.#params.delete(k);
5241
5299
  return this;
5242
5300
  }
5243
5301
  for (const v of vs) {
@@ -5270,7 +5328,7 @@ class HttpRequestBuilder {
5270
5328
  /**
5271
5329
  * Sets the request body as a FormData configured using the callback.
5272
5330
  * `Content-Type: multipart/form-data` header is automatically added by fetch if not explicitly set.
5273
- * @param {function(HttpMultipartRequestCustomizer):void} callback
5331
+ * @param {(c: HttpMultipartRequestCustomizer) => void} callback
5274
5332
  */
5275
5333
  multipart(callback) {
5276
5334
  const formData = new FormData();
@@ -5435,7 +5493,7 @@ class HttpMultipartRequestCustomizer {
5435
5493
  * @returns this builder
5436
5494
  */
5437
5495
  blobs(name, values) {
5438
- for (let v of values) {
5496
+ for (const v of values) {
5439
5497
  this.#formData.append(name, v);
5440
5498
  }
5441
5499
  return this;
@@ -5470,7 +5528,16 @@ class LocalStorage extends Storage {
5470
5528
  }
5471
5529
  static load(k) {
5472
5530
  const got = localStorage.getItem(k);
5473
- return got === null ? undefined : JSON.parse(got);
5531
+ if (got === null) {
5532
+ return undefined;
5533
+ }
5534
+ try {
5535
+ return JSON.parse(got);
5536
+ } catch {
5537
+ //not what save wrote: drop it, otherwise every later read fails the same way
5538
+ localStorage.removeItem(k);
5539
+ return undefined;
5540
+ }
5474
5541
  }
5475
5542
  static remove(k) {
5476
5543
  localStorage.removeItem(k);
@@ -5488,7 +5555,16 @@ class SessionStorage extends Storage {
5488
5555
  }
5489
5556
  static load(k) {
5490
5557
  const got = sessionStorage.getItem(k);
5491
- return got === null ? undefined : JSON.parse(got);
5558
+ if (got === null) {
5559
+ return undefined;
5560
+ }
5561
+ try {
5562
+ return JSON.parse(got);
5563
+ } catch {
5564
+ //not what save wrote: drop it, otherwise every later read fails the same way
5565
+ sessionStorage.removeItem(k);
5566
+ return undefined;
5567
+ }
5492
5568
  }
5493
5569
  static remove(k) {
5494
5570
  sessionStorage.removeItem(k);
@@ -5510,7 +5586,7 @@ class VersionedLocalStorage {
5510
5586
  return undefined;
5511
5587
  }
5512
5588
  if (stored.revision !== revision) {
5513
- localStorage.removeItem(key);
5589
+ LocalStorage.remove(key);
5514
5590
  return undefined;
5515
5591
  }
5516
5592
  return stored.data;
@@ -5527,7 +5603,7 @@ class VersionedSessionStorage {
5527
5603
  return undefined;
5528
5604
  }
5529
5605
  if (stored.revision !== revision) {
5530
- localStorage.removeItem(key);
5606
+ SessionStorage.remove(key);
5531
5607
  return undefined;
5532
5608
  }
5533
5609
  return stored.data;
@@ -5662,7 +5738,7 @@ class Timing {
5662
5738
  */
5663
5739
  static debounce(timeoutMs, func, options) {
5664
5740
  const opts = options ?? Timing.DEBOUNCE_DEFAULT;
5665
- let tid = null;
5741
+ let tid = /** @type {number | null} */ (null);
5666
5742
  let args = [];
5667
5743
  let previousTimestamp = 0;
5668
5744
 
@@ -5682,8 +5758,8 @@ class Timing {
5682
5758
  }
5683
5759
  };
5684
5760
 
5685
- const debounced = function () {
5686
- args = [...arguments];
5761
+ const debounced = (...called) => {
5762
+ args = called;
5687
5763
  previousTimestamp = performance.now();
5688
5764
  if (tid === null) {
5689
5765
  tid = setTimeout(later, timeoutMs);
@@ -5692,7 +5768,11 @@ class Timing {
5692
5768
  }
5693
5769
  }
5694
5770
  };
5695
- const abort = () => clearTimeout(tid);
5771
+ const abort = () => {
5772
+ clearTimeout(tid ?? undefined);
5773
+ tid = null;
5774
+ args = [];
5775
+ };
5696
5776
  return [debounced, abort];
5697
5777
  }
5698
5778
  static THROTTLE_DEFAULT = 0;
@@ -5707,7 +5787,7 @@ class Timing {
5707
5787
  */
5708
5788
  static throttle(timeoutMs, func, options) {
5709
5789
  const opts = options ?? Timing.THROTTLE_DEFAULT;
5710
- let tid = null;
5790
+ let tid = /** @type {number | null} */ (null);
5711
5791
  let args = [];
5712
5792
  let previousTimestamp = 0;
5713
5793
 
@@ -5719,13 +5799,13 @@ class Timing {
5719
5799
  args = [];
5720
5800
  }
5721
5801
  };
5722
- const throttled = function () {
5802
+ const throttled = (...called) => {
5723
5803
  const now = performance.now();
5724
5804
  if (!previousTimestamp && opts & Timing.THROTTLE_NO_LEADING) {
5725
5805
  previousTimestamp = now;
5726
5806
  }
5727
5807
  const remaining = previousTimestamp === 0 ? 0 : timeoutMs - (now - previousTimestamp);
5728
- args = [...arguments];
5808
+ args = called;
5729
5809
  if (remaining <= 0 || remaining > timeoutMs) {
5730
5810
  if (tid !== null) {
5731
5811
  clearTimeout(tid);
@@ -5740,7 +5820,11 @@ class Timing {
5740
5820
  tid = setTimeout(later, remaining);
5741
5821
  }
5742
5822
  };
5743
- const abort = () => clearTimeout(tid);
5823
+ const abort = () => {
5824
+ clearTimeout(tid ?? undefined);
5825
+ tid = null;
5826
+ args = [];
5827
+ };
5744
5828
  return [throttled, abort];
5745
5829
  }
5746
5830
  }
@@ -5754,7 +5838,7 @@ class Bindings {
5754
5838
  */
5755
5839
  static flatten(obj, prefix, stops) {
5756
5840
  return Object.keys(obj).reduce((acc, k) => {
5757
- const pre = prefix.length ? prefix + '.' + k : k;
5841
+ const pre = prefix.length ? `${prefix}.${k}` : k;
5758
5842
  if (!stops.has(pre) && typeof obj[k] === 'object' && obj[k] !== null) {
5759
5843
  Object.assign(acc, Bindings.flatten(obj[k], pre, stops));
5760
5844
  } else {
@@ -5772,7 +5856,7 @@ class Bindings {
5772
5856
  static providePath(result, path, value) {
5773
5857
  const keys = path.split('.').map((k) => (/^[0-9]+$/.test(k) ? +k : k));
5774
5858
  let current = result ?? {};
5775
- let previous = null;
5859
+ let previous = /** @type {any} */ (null);
5776
5860
  for (let i = 0; ; ++i) {
5777
5861
  const ckey = keys[i];
5778
5862
  const pkey = keys[i - 1];
@@ -5805,12 +5889,12 @@ class Bindings {
5805
5889
  if (!el.checked) {
5806
5890
  return undefined;
5807
5891
  }
5808
- return el.dataset['fulBindType'] === 'boolean' ? el.value === 'true' : el.value;
5892
+ return el.dataset.fulBindType === 'boolean' ? el.value === 'true' : el.value;
5809
5893
  }
5810
5894
  if (el.getAttribute('type') === 'checkbox') {
5811
5895
  return el.checked;
5812
5896
  }
5813
- if (el.dataset['fulBindType'] === 'boolean') {
5897
+ if (el.dataset.fulBindType === 'boolean') {
5814
5898
  return !el.value ? null : el.value === 'true';
5815
5899
  }
5816
5900
  if (el.tagName === 'INPUT' || el.tagName === 'SELECT' || el.tagName === 'TEXTAREA') {
@@ -5843,12 +5927,14 @@ class Bindings {
5843
5927
 
5844
5928
  /**
5845
5929
  *
5846
- * @param {Element & {checked?: boolean} & {value?: any}} el
5930
+ * @param {Element & {dataset?: any} & {checked?: boolean} & {value?: any}} el
5847
5931
  * @returns
5848
5932
  */
5849
5933
  static mutate(el, raw) {
5850
5934
  if (el.getAttribute('type') === 'radio') {
5851
- el.checked = el.getAttribute('value') === raw;
5935
+ //values are matched as strings, as ful-radio-group does: extract decodes
5936
+ //boolean radios, and payloads carry numbers where the attribute is text
5937
+ el.checked = raw != null && el.getAttribute('value') === String(raw);
5852
5938
  return;
5853
5939
  }
5854
5940
  if (el.getAttribute('type') === 'checkbox') {
@@ -5872,7 +5958,9 @@ class Bindings {
5872
5958
  static errors(form, es, scrollOnError) {
5873
5959
  const fieldErrors = es.filter((e) => e.type === 'FIELD_ERROR' || e.type === 'INVALID_FORMAT');
5874
5960
  const globalErrors = es.filter((e) => e.type !== 'FIELD_ERROR' && e.type !== 'INVALID_FORMAT');
5875
- form.querySelectorAll(`[name]`).forEach((el) => el.setCustomValidity?.(''));
5961
+ form.querySelectorAll(`[name]`).forEach((el) => {
5962
+ el.setCustomValidity?.('');
5963
+ });
5876
5964
  form.querySelectorAll('ful-errors').forEach((el) => {
5877
5965
  el.replaceChildren();
5878
5966
  el.setAttribute('hidden', '');
@@ -5880,12 +5968,12 @@ class Bindings {
5880
5968
  fieldErrors.forEach((e) => {
5881
5969
  const name = e.context.replace(/\[/g, '.').replace(/\]\./g, '.').replace(/\]/g, '');
5882
5970
  const parts = name.split('.');
5883
- for (let i = parts.length; i != 0; --i) {
5971
+ for (let i = parts.length; i !== 0; --i) {
5884
5972
  const prefix = parts.slice(0, i).join('.');
5885
5973
  const suffix = parts.slice(i, parts.length).join('.');
5886
- form.querySelectorAll(`[name='${CSS.escape(prefix)}']`).forEach((input) =>
5887
- input.setCustomValidity?.(e.reason, suffix),
5888
- );
5974
+ form.querySelectorAll(`[name='${CSS.escape(prefix)}']`).forEach((input) => {
5975
+ input.setCustomValidity?.(e.reason, suffix);
5976
+ });
5889
5977
  }
5890
5978
  });
5891
5979
  form.querySelectorAll('ful-errors').forEach((el) => {
@@ -5895,7 +5983,7 @@ class Bindings {
5895
5983
  el.removeAttribute('hidden');
5896
5984
  }
5897
5985
  });
5898
- if (es.length == 0 || !scrollOnError) {
5986
+ if (es.length === 0 || !scrollOnError) {
5899
5987
  return;
5900
5988
  }
5901
5989
  Array.from(form.querySelectorAll(`:invalid`))
@@ -5967,7 +6055,8 @@ class FormLoader {
5967
6055
  class Form extends ParsedElement {
5968
6056
  form;
5969
6057
  render() {
5970
- const form = (this.form = document.createElement('form'));
6058
+ const form = document.createElement('form');
6059
+ this.form = form;
5971
6060
  form.setAttribute('novalidate', '');
5972
6061
  Attributes.forward('form-', this, form);
5973
6062
  form.replaceChildren(...this.childNodes);
@@ -5990,50 +6079,53 @@ class Form extends ParsedElement {
5990
6079
  */
5991
6080
  async submit(submitter) {
5992
6081
  this.spinner(true);
6082
+ //one try: building the loader and preparing the request are as much part of a
6083
+ //submit as sending it, and a mapper that throws is how a caller reports a
6084
+ //problem with the values
6085
+ let values;
6086
+ let request;
5993
6087
  try {
5994
6088
  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', {
6089
+ values = Bindings.extractFrom(this.form, submitter);
6090
+ request = await loader.prepare(values, this);
6091
+ const se = new CustomEvent('submit', {
6092
+ bubbles: true,
6093
+ cancelable: true,
6094
+ detail: { submitter, values, request },
6095
+ });
6096
+ if (!this.dispatchEvent(se)) {
6097
+ return;
6098
+ }
6099
+ this.errors = [];
6100
+ const sre = new CustomEvent('submit:requested', {
6101
+ bubbles: true,
6102
+ cancelable: false,
6103
+ detail: { submitter, values: se.detail.values, request: se.detail.request },
6104
+ });
6105
+ let response = await AsyncEvents.fireAsync(this, sre, { mode: 'pipeline' });
6106
+ request = sre.detail.request;
6107
+
6108
+ response = await loader.submit(request, this, response);
6109
+ const mapped = await loader.transform(response, this);
6110
+ this.dispatchEvent(
6111
+ new CustomEvent('submit:success', {
5999
6112
  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', {
6113
+ cancelable: false,
6114
+ detail: { submitter, values, request, response: mapped },
6115
+ }),
6116
+ );
6117
+ } catch (e) {
6118
+ this.dispatchEvent(
6119
+ new CustomEvent('submit:failure', {
6008
6120
  bubbles: true,
6009
6121
  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);
6122
+ detail: { submitter, values, request, exception: e },
6123
+ }),
6124
+ );
6125
+ if (e instanceof Failure) {
6126
+ this.errors = e.problems;
6036
6127
  }
6128
+ console.warn('failed to submit form', this, 'reason:', e);
6037
6129
  } finally {
6038
6130
  this.spinner(false);
6039
6131
  }
@@ -6041,7 +6133,20 @@ class Form extends ParsedElement {
6041
6133
  reset() {
6042
6134
  this.form.reset();
6043
6135
  }
6136
+ #spinning = 0;
6044
6137
  spinner(spin) {
6138
+ //submits can overlap: only the outermost one saves and restores the button states
6139
+ if (spin) {
6140
+ ++this.#spinning;
6141
+ if (this.#spinning !== 1) {
6142
+ return;
6143
+ }
6144
+ } else {
6145
+ this.#spinning = Math.max(0, this.#spinning - 1);
6146
+ if (this.#spinning !== 0) {
6147
+ return;
6148
+ }
6149
+ }
6045
6150
  this.querySelectorAll('ful-spinner').forEach((el) => {
6046
6151
  const hel = /** @type HTMLElement */ (el);
6047
6152
  hel.hidden = !spin;
@@ -6072,7 +6177,7 @@ class Form extends ParsedElement {
6072
6177
  }
6073
6178
 
6074
6179
  class Input extends ParsedElement {
6075
- static observed = ['value', 'readonly:presence', 'required:presence'];
6180
+ static observed = ['value', 'readonly:presence', 'required:presence', 'placeholder'];
6076
6181
  static slots = true;
6077
6182
  static template = `
6078
6183
  <div class="form-label">
@@ -6109,12 +6214,6 @@ class Input extends ParsedElement {
6109
6214
  this._input = fragment.querySelector('input,textarea');
6110
6215
 
6111
6216
  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
6217
  this._input.addEventListener('keydown', (evt) => {
6119
6218
  if (evt.key !== 'Enter' || this._type() === 'textarea') {
6120
6219
  return;
@@ -6130,19 +6229,26 @@ class Input extends ParsedElement {
6130
6229
  form.requestSubmit(submitter);
6131
6230
  });
6132
6231
  this._input.addEventListener('input', (evt) => {
6133
- const re = this.getAttribute('mask');
6134
- if (!re) {
6232
+ const mask = this.getAttribute('mask');
6233
+ if (!mask) {
6135
6234
  return;
6136
6235
  }
6236
+ const strip = (v) => v.replace(new RegExp(mask, 'g'), '');
6137
6237
  const before = evt.target.value;
6138
- const after = before.replace(new RegExp(re, 'g'), '');
6238
+ const after = strip(before);
6139
6239
  if (before === after) {
6140
6240
  return;
6141
6241
  }
6142
6242
  const start = evt.target.selectionStart;
6143
- const offset = before.length - after.length;
6144
6243
  evt.target.value = after;
6145
- evt.target.setSelectionRange(start - offset, start - offset);
6244
+ if (start === null) {
6245
+ //email, number and the date types have no selection to restore
6246
+ return;
6247
+ }
6248
+ //the caret keeps its place among the characters that survived, so only the
6249
+ //ones stripped before it count
6250
+ const caret = strip(before.slice(0, start)).length;
6251
+ evt.target.setSelectionRange(caret, caret);
6146
6252
  });
6147
6253
  this._input.addEventListener('change', (evt) => {
6148
6254
  evt.stopPropagation();
@@ -6162,6 +6268,15 @@ class Input extends ParsedElement {
6162
6268
  this._input.ariaDescribedByElements = [this._fieldError];
6163
6269
  this._input.ariaLabelledByElements = [label];
6164
6270
  this.replaceChildren(fragment);
6271
+ if (!skipObservedSetup) {
6272
+ // biome-ignore lint/complexity/noUselessThisAlias: keeps checkJs from seeing these as class fields
6273
+ const el = this;
6274
+ el.disabled = disabled;
6275
+ el.readonly = observed.readonly;
6276
+ el.required = observed.required;
6277
+ el.placeholder = observed.placeholder;
6278
+ el.value = observed.value;
6279
+ }
6165
6280
  }
6166
6281
  get value() {
6167
6282
  const uppercase = this.hasAttribute('uppercase');
@@ -6188,6 +6303,11 @@ class Input extends ParsedElement {
6188
6303
  }
6189
6304
  set disabled(d) {
6190
6305
  Attributes.toggle(this._input, 'disabled', d);
6306
+ //also on the host: a form associated element only matches :disabled through its
6307
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
6308
+ //is needed, disabled is deliberately not observed: the platform delivers it
6309
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
6310
+ Attributes.toggle(this, 'disabled', d);
6191
6311
  }
6192
6312
  get required() {
6193
6313
  return this._input.getAttribute('aria-required') === 'true';
@@ -6198,6 +6318,18 @@ class Input extends ParsedElement {
6198
6318
  Attributes.toggle(this, 'required', d);
6199
6319
  });
6200
6320
  }
6321
+ get placeholder() {
6322
+ const v = this._input.getAttribute('placeholder');
6323
+ return v === ' ' ? null : v;
6324
+ }
6325
+ set placeholder(d) {
6326
+ //without a placeholder :placeholder-shown never matches, and floating labels
6327
+ //rely on it, so a blank one stands in for none
6328
+ Attributes.set(this._input, 'placeholder', d ?? ' ');
6329
+ this.reflect(() => {
6330
+ Attributes.set(this, 'placeholder', d);
6331
+ });
6332
+ }
6201
6333
  focus(options) {
6202
6334
  this._input.focus(options);
6203
6335
  }
@@ -6217,23 +6349,23 @@ class Input extends ParsedElement {
6217
6349
 
6218
6350
  class LocalDate extends ParsedElement {
6219
6351
  render() {
6220
- const content = this.innerHTML.trim();
6352
+ const content = this.textContent.trim();
6221
6353
  if (content === '') {
6222
- this.innerHTML = this.getAttribute('default') ?? '';
6354
+ this.replaceChildren(this.getAttribute('default') ?? '');
6223
6355
  return;
6224
6356
  }
6225
6357
  const locale = this.getAttribute('locale') ?? Intl.DateTimeFormat().resolvedOptions().locale;
6226
6358
  const formatter = new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'numeric', day: 'numeric' });
6227
6359
  const [y, m, d] = content.split('-').map(Number);
6228
- this.innerHTML = formatter.format(new Date(y, m - 1, d));
6360
+ this.replaceChildren(formatter.format(new Date(y, m - 1, d)));
6229
6361
  }
6230
6362
  }
6231
6363
 
6232
6364
  class Instant extends ParsedElement {
6233
6365
  render() {
6234
- const content = this.innerHTML.trim();
6366
+ const content = this.textContent.trim();
6235
6367
  if (content === '') {
6236
- this.innerHTML = this.getAttribute('default') ?? '';
6368
+ this.replaceChildren(this.getAttribute('default') ?? '');
6237
6369
  return;
6238
6370
  }
6239
6371
  const locale = this.getAttribute('locale') ?? Intl.DateTimeFormat().resolvedOptions().locale;
@@ -6246,7 +6378,7 @@ class Instant extends ParsedElement {
6246
6378
  second: 'numeric',
6247
6379
  hour12: false,
6248
6380
  });
6249
- this.innerHTML = format.format(new Date(Instant.isoToLocal(content)));
6381
+ this.replaceChildren(format.format(new Date(Instant.isoToLocal(content))));
6250
6382
  }
6251
6383
  static isoToLocal(iso) {
6252
6384
  //this is so sad
@@ -6259,16 +6391,17 @@ class Instant extends ParsedElement {
6259
6391
  }
6260
6392
 
6261
6393
  class InputLocalDate extends Input {
6262
- static observed = ['value', 'readonly:presence', 'required:presence', 'min', 'max', 'step'];
6394
+ static observed = ['value', 'readonly:presence', 'required:presence', 'placeholder', 'min', 'max', 'step'];
6263
6395
  _type() {
6264
6396
  return 'date';
6265
6397
  }
6266
6398
  render(conf) {
6267
6399
  const { observed } = conf;
6268
6400
  super.render(conf);
6401
+ //step first: on a time input min and max are snapped to its grid
6402
+ this.step = observed.step;
6269
6403
  this.min = observed.min;
6270
6404
  this.max = observed.max;
6271
- this.step = observed.step;
6272
6405
  }
6273
6406
  get min() {
6274
6407
  const v = this._input.min;
@@ -6314,13 +6447,14 @@ class InputLocalDate extends Input {
6314
6447
  case 'd':
6315
6448
  r.setDate(r.getDate() + offset * sign);
6316
6449
  break;
6317
- case 'm':
6450
+ case 'm': {
6318
6451
  const originalDay = r.getDate();
6319
6452
  r.setMonth(r.getMonth() + offset * sign);
6320
6453
  if (r.getDate() !== originalDay) {
6321
6454
  r.setDate(0);
6322
6455
  }
6323
6456
  break;
6457
+ }
6324
6458
  case 'y':
6325
6459
  r.setFullYear(r.getFullYear() + offset * sign);
6326
6460
  break;
@@ -6333,10 +6467,62 @@ class InputLocalTime extends InputLocalDate {
6333
6467
  _type() {
6334
6468
  return 'time';
6335
6469
  }
6470
+ get min() {
6471
+ const v = this._input.min;
6472
+ return v === '' ? null : v;
6473
+ }
6474
+ set min(v) {
6475
+ this._input.min = this.#fromNowOrOffset(v);
6476
+ }
6477
+ get max() {
6478
+ const v = this._input.max;
6479
+ return v === '' ? null : v;
6480
+ }
6481
+ set max(v) {
6482
+ this._input.max = this.#fromNowOrOffset(v);
6483
+ }
6484
+ /**
6485
+ * Resolves `now` and hour or minute offsets against the current time, wrapping
6486
+ * around midnight. `m` is minutes here, unlike the date offsets of the parent where
6487
+ * it is months: months mean nothing on a time. Anything else is passed through.
6488
+ */
6489
+ #fromNowOrOffset(v) {
6490
+ if (!v) {
6491
+ return '';
6492
+ }
6493
+ const resolved = new Date();
6494
+ if (v !== 'now') {
6495
+ const re = /^([+-])(\d+)([hm])$/;
6496
+ const match = re.exec(v);
6497
+ if (!match) {
6498
+ return v;
6499
+ }
6500
+ const sign = match[1] === '-' ? -1 : 1;
6501
+ const offset = +match[2] * sign;
6502
+ if (match[3] === 'h') {
6503
+ resolved.setHours(resolved.getHours() + offset);
6504
+ } else {
6505
+ resolved.setMinutes(resolved.getMinutes() + offset);
6506
+ }
6507
+ }
6508
+ return InputLocalTime.#snapped(resolved, Number(this._input.step) || 60);
6509
+ }
6510
+ /**
6511
+ * Truncates a time to the step grid: min anchors that grid, so a bound that is not
6512
+ * on it makes every value on it invalid.
6513
+ */
6514
+ static #snapped(date, stepSeconds) {
6515
+ const pad = (n) => String(n).padStart(2, '0');
6516
+ const seconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
6517
+ const snapped = Math.floor(seconds / stepSeconds) * stepSeconds;
6518
+ const hh = pad(Math.floor(snapped / 3600));
6519
+ const mm = pad(Math.floor((snapped % 3600) / 60));
6520
+ return stepSeconds % 60 === 0 ? `${hh}:${mm}` : `${hh}:${mm}:${pad(snapped % 60)}`;
6521
+ }
6336
6522
  }
6337
6523
 
6338
6524
  class InputInstant extends Input {
6339
- static observed = ['value', 'readonly:presence', 'required:presence', 'min', 'max', 'step'];
6525
+ static observed = ['value', 'readonly:presence', 'required:presence', 'placeholder', 'min', 'max', 'step'];
6340
6526
  _type() {
6341
6527
  return 'datetime-local';
6342
6528
  }
@@ -6381,28 +6567,28 @@ class InputFile extends Input {
6381
6567
  static l10n = {
6382
6568
  en: {
6383
6569
  dropzonelabel: 'Click or drop your files here',
6384
- unaccepptablefiletype: 'Only files of type {0} are supported',
6570
+ unacceptablefiletype: 'Only files of type {0} are supported',
6385
6571
  maxfilesizeexceeded: 'Maximum supported file size is {0}',
6386
6572
  maxtotalsizeexceeded: 'Maximum supported total file size is {0}',
6387
6573
  maxfilesexceeded: 'Maximum files count exceeded',
6388
6574
  },
6389
6575
  it: {
6390
6576
  dropzonelabel: 'Clicca o trascina i file qui',
6391
- unaccepptablefiletype: 'Solo i file di tipo {0} sono supportati',
6577
+ unacceptablefiletype: 'Solo i file di tipo {0} sono supportati',
6392
6578
  maxfilesizeexceeded: 'La dimensione massima di un file è di {0}',
6393
6579
  maxtotalsizeexceeded: 'La dimensione massima complessiva dei file è di {0}',
6394
6580
  maxfilesexceeded: 'Numero massimo di file superato',
6395
6581
  },
6396
6582
  es: {
6397
6583
  dropzonelabel: 'Haz clic o arrastra tus archivos aquí',
6398
- unaccepptablefiletype: 'Solo se admiten archivos de tipo {0}',
6584
+ unacceptablefiletype: 'Solo se admiten archivos de tipo {0}',
6399
6585
  maxfilesizeexceeded: 'El tamaño máximo de archivo admitido es {0}',
6400
6586
  maxtotalsizeexceeded: 'El tamaño total máximo admitido es {0}',
6401
6587
  maxfilesexceeded: 'Se ha superado el número máximo de archivos',
6402
6588
  },
6403
6589
  fr: {
6404
6590
  dropzonelabel: 'Cliquez ou déposez vos fichiers ici',
6405
- unaccepptablefiletype: 'Seuls les fichiers de type {0} sont pris en charge',
6591
+ unacceptablefiletype: 'Seuls les fichiers de type {0} sont pris en charge',
6406
6592
  maxfilesizeexceeded: 'La taille maximale de fichier prise en charge est {0}',
6407
6593
  maxtotalsizeexceeded: 'La taille totale maximale prise en charge est {0}',
6408
6594
  maxfilesexceeded: 'Nombre maximal de fichiers dépassé',
@@ -6412,6 +6598,7 @@ class InputFile extends Input {
6412
6598
  'value',
6413
6599
  'readonly:presence',
6414
6600
  'required:presence',
6601
+ 'placeholder',
6415
6602
  'accept:csv',
6416
6603
  'multiple:presence',
6417
6604
  'itemlist:presence',
@@ -6461,7 +6648,7 @@ class InputFile extends Input {
6461
6648
  };
6462
6649
  render(conf) {
6463
6650
  const { observed } = conf;
6464
- super.render(conf);
6651
+ super.render({ ...conf, skipObservedSetup: true });
6465
6652
  this.#items = this.querySelector('ful-item-list');
6466
6653
  this.#dropzone = this.querySelector('[data-ref=dropzone]');
6467
6654
  this.#warnings = this.querySelector('ful-field-warnings');
@@ -6472,6 +6659,12 @@ class InputFile extends Input {
6472
6659
  this.maxfiles = observed.maxfiles;
6473
6660
  this.maxfilesize = observed.maxfilesize;
6474
6661
  this.maxtotalsize = observed.maxtotalsize;
6662
+
6663
+ this.disabled = conf.disabled;
6664
+ this.readonly = observed.readonly;
6665
+ this.required = observed.required;
6666
+ this.placeholder = observed.placeholder;
6667
+ this.value = observed.value;
6475
6668
  this.#warnings.addEventListener('animationend', (e) => {
6476
6669
  e.target.remove();
6477
6670
  });
@@ -6479,11 +6672,17 @@ class InputFile extends Input {
6479
6672
  if (!e.target.closest('button')) {
6480
6673
  return;
6481
6674
  }
6482
- const fileName = e.target.closest('ful-item').dataset.name;
6675
+ const idx = [...this.#items.children].indexOf(e.target.closest('ful-item'));
6676
+ if (idx === -1) {
6677
+ return;
6678
+ }
6483
6679
  const dt = new DataTransfer();
6484
- [...this.files].filter((f) => f.name !== fileName).forEach((f) => dt.items.add(f));
6680
+ [...this.files]
6681
+ .filter((f, i) => i !== idx)
6682
+ .forEach((f) => {
6683
+ dt.items.add(f);
6684
+ });
6485
6685
  this.files = dt.files;
6486
- this.#update();
6487
6686
  });
6488
6687
  this.#dropzone.addEventListener('click', (e) => {
6489
6688
  this.querySelector('input')?.click();
@@ -6494,10 +6693,15 @@ class InputFile extends Input {
6494
6693
  });
6495
6694
  this.#dropzone.addEventListener('drop', (e) => {
6496
6695
  e.preventDefault();
6696
+ const dropped = [...e.dataTransfer.items].filter((i) => i.kind === 'file');
6697
+ if (dropped.length === 0) {
6698
+ return;
6699
+ }
6497
6700
  const dt = new DataTransfer();
6498
- [...e.dataTransfer.items].filter((i) => i.kind === 'file').forEach((i) => dt.items.add(i.getAsFile()));
6701
+ dropped.forEach((i) => {
6702
+ dt.items.add(i.getAsFile());
6703
+ });
6499
6704
  this.files = dt.files;
6500
- this.#update();
6501
6705
  });
6502
6706
  this._input.addEventListener('change', (e) => {
6503
6707
  this.#update();
@@ -6512,6 +6716,7 @@ class InputFile extends Input {
6512
6716
  }
6513
6717
  #update() {
6514
6718
  this.setCustomValidity();
6719
+ this.#warnings.replaceChildren();
6515
6720
  this.#ensureAcceptable();
6516
6721
  this.#ensureFileSizes();
6517
6722
  this.#ensureTotalSize();
@@ -6522,7 +6727,7 @@ class InputFile extends Input {
6522
6727
  .renderTo(this.#items);
6523
6728
  }
6524
6729
  warning(key, args) {
6525
- this.template('warning').withOverlay({ key, args }).renderTo(this.#warnings);
6730
+ this.template('warning').withOverlay({ key, args }).appendTo(this.#warnings);
6526
6731
  }
6527
6732
  #ensureAcceptable() {
6528
6733
  if (!this.#accept.length) {
@@ -6535,10 +6740,14 @@ class InputFile extends Input {
6535
6740
  if (unacceptable.length === 0) {
6536
6741
  return;
6537
6742
  }
6538
- this.warning('unaccepptablefiletype', this.#accept.join(', '));
6743
+ this.warning('unacceptablefiletype', this.#accept.join(', '));
6539
6744
  const dt = new DataTransfer();
6540
- [...this.files].filter((f) => !unacceptable.includes(f)).forEach((f) => dt.items.add(f));
6541
- this.files = dt.files;
6745
+ [...this.files]
6746
+ .filter((f) => !unacceptable.includes(f))
6747
+ .forEach((f) => {
6748
+ dt.items.add(f);
6749
+ });
6750
+ this._input.files = dt.files;
6542
6751
  }
6543
6752
  #ensureFilesCount() {
6544
6753
  if (this.#maxfiles === null) {
@@ -6548,8 +6757,7 @@ class InputFile extends Input {
6548
6757
  return;
6549
6758
  }
6550
6759
  this.warning('maxfilesexceeded');
6551
- const dt = new DataTransfer();
6552
- this.files = dt.files;
6760
+ this._input.files = new DataTransfer().files;
6553
6761
  }
6554
6762
 
6555
6763
  #ensureFileSizes() {
@@ -6562,8 +6770,12 @@ class InputFile extends Input {
6562
6770
  }
6563
6771
  this.warning('maxfilesizeexceeded', this.#formatByteSize(this.#maxfilesize));
6564
6772
  const dt = new DataTransfer();
6565
- [...this.files].filter((f) => !oversized.includes(f)).forEach((f) => dt.items.add(f));
6566
- this.files = dt.files;
6773
+ [...this.files]
6774
+ .filter((f) => !oversized.includes(f))
6775
+ .forEach((f) => {
6776
+ dt.items.add(f);
6777
+ });
6778
+ this._input.files = dt.files;
6567
6779
  }
6568
6780
  #ensureTotalSize() {
6569
6781
  if (this.#maxtotalsize === null) {
@@ -6574,7 +6786,7 @@ class InputFile extends Input {
6574
6786
  return;
6575
6787
  }
6576
6788
  this.warning('maxtotalsizeexceeded', this.#formatByteSize(this.#maxtotalsize));
6577
- this.files = new DataTransfer().files;
6789
+ this._input.files = new DataTransfer().files;
6578
6790
  }
6579
6791
  get accept() {
6580
6792
  return this.#accept;
@@ -6600,6 +6812,7 @@ class InputFile extends Input {
6600
6812
  }
6601
6813
  set files(vs) {
6602
6814
  this._input.files = vs;
6815
+ this.#update();
6603
6816
  }
6604
6817
  get file() {
6605
6818
  return this.files[0] ?? null;
@@ -6620,7 +6833,6 @@ class InputFile extends Input {
6620
6833
  return;
6621
6834
  }
6622
6835
  this.files = new DataTransfer().files;
6623
- this.#update();
6624
6836
  }
6625
6837
  get totalsize() {
6626
6838
  return Array.from(this.files).reduce((a, f) => a + f.size, 0);
@@ -6787,7 +6999,7 @@ class SelectLoader {
6787
6999
  const http = registry.component('http-client');
6788
7000
  const responseMapper = SelectLoader.#responseMapperFrom(el);
6789
7001
 
6790
- if ('chunked' == el.getAttribute('mode')) {
7002
+ if ('chunked' === el.getAttribute('mode')) {
6791
7003
  return new PartialRemoteLoader({
6792
7004
  http,
6793
7005
  url: el.getAttribute('src'),
@@ -6863,8 +7075,14 @@ class Dropdown extends ParsedElement {
6863
7075
  });
6864
7076
  this.replaceChildren(fragment);
6865
7077
  }
7078
+ #selected() {
7079
+ return this.#menu?.querySelector('[selected]') ?? this.#menu?.firstElementChild ?? null;
7080
+ }
6866
7081
  acceptSelection() {
6867
- const selected = this.#menu.querySelector('[selected]') ?? this.#menu.firstElementChild;
7082
+ const selected = this.#selected();
7083
+ if (!selected) {
7084
+ return;
7085
+ }
6868
7086
  this.#change(selected);
6869
7087
  }
6870
7088
  update(values) {
@@ -6900,6 +7118,9 @@ class Dropdown extends ParsedElement {
6900
7118
  try {
6901
7119
  const data = await loader();
6902
7120
  this.update(data);
7121
+ } catch (e) {
7122
+ this.hide();
7123
+ throw e;
6903
7124
  } finally {
6904
7125
  this.#spinner.setAttribute('hidden', '');
6905
7126
  this.#menu.removeAttribute('hidden');
@@ -6907,9 +7128,9 @@ class Dropdown extends ParsedElement {
6907
7128
  }
6908
7129
  async moveOrShow(forward, loader) {
6909
7130
  if (this.shown) {
6910
- const selected = this.#menu.querySelector('[selected]') ?? this.#menu.firstElementChild;
6911
- const candidate = selected[`${forward ? 'next' : 'previous'}ElementSibling`];
6912
- if (candidate) {
7131
+ const selected = this.#selected();
7132
+ const candidate = selected?.[`${forward ? 'next' : 'previous'}ElementSibling`];
7133
+ if (selected && candidate) {
6913
7134
  selected.removeAttribute('selected');
6914
7135
  candidate.setAttribute('selected', '');
6915
7136
  candidate.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
@@ -6962,6 +7183,7 @@ class Select extends ParsedElement {
6962
7183
  #multiple;
6963
7184
  #fieldError;
6964
7185
  #values = new Map();
7186
+ #token = 0;
6965
7187
  constructor() {
6966
7188
  super();
6967
7189
  this.internals = this.attachInternals();
@@ -6974,7 +7196,11 @@ class Select extends ParsedElement {
6974
7196
  .create(this, { options: slots.options });
6975
7197
 
6976
7198
  this.#multiple = this.hasAttribute('multiple');
6977
- await this.#loader.prefetch?.();
7199
+ try {
7200
+ await this.#loader.prefetch?.();
7201
+ } catch (/** @type any */ e) {
7202
+ console.warn('failed to prefetch select options', this, 'reason:', e);
7203
+ }
6978
7204
  const fragment = this.template().withOverlay({ slots, name }).render();
6979
7205
  this.#input = fragment.querySelector('input');
6980
7206
  this.#items = fragment.querySelector('ful-item-list');
@@ -6993,11 +7219,9 @@ class Select extends ParsedElement {
6993
7219
  this.#fieldError = fragment.querySelector('ful-field-error');
6994
7220
  this.#input.ariaDescribedByElements = [this.#fieldError];
6995
7221
  this.#input.ariaLabelledByElements = [label];
6996
-
6997
- const self = this;
6998
7222
  const [dload, abortdload] = Timing.throttle(400, () => {
6999
- self.#input.setAttribute('aria-expanded', 'true');
7000
- self.#ddmenu.show(() => self.#loader.load(self.#input.value));
7223
+ this.#input.setAttribute('aria-expanded', 'true');
7224
+ this.#ddmenu.show(() => this.#loader.load(this.#input.value));
7001
7225
  });
7002
7226
  this.addEventListener('click', (/** @type any */ e) => {
7003
7227
  if (e.target.matches('input')) {
@@ -7064,13 +7288,13 @@ class Select extends ParsedElement {
7064
7288
  case 'ArrowUp': {
7065
7289
  e.preventDefault();
7066
7290
  this.#input.setAttribute('aria-expanded', 'true');
7067
- this.#ddmenu.moveOrShow(false, () => self.#loader.load(self.#input.value));
7291
+ this.#ddmenu.moveOrShow(false, () => this.#loader.load(this.#input.value));
7068
7292
  break;
7069
7293
  }
7070
7294
  case 'ArrowDown': {
7071
7295
  e.preventDefault();
7072
7296
  this.#input.setAttribute('aria-expanded', 'true');
7073
- this.#ddmenu.moveOrShow(true, () => self.#loader.load(self.#input.value));
7297
+ this.#ddmenu.moveOrShow(true, () => this.#loader.load(this.#input.value));
7074
7298
  break;
7075
7299
  }
7076
7300
  case 'Escape': {
@@ -7079,6 +7303,12 @@ class Select extends ParsedElement {
7079
7303
  break;
7080
7304
  }
7081
7305
  case 'Enter': {
7306
+ if (!this.#ddmenu.shown) {
7307
+ //nothing to accept: submit the form as ful-input does. the inner
7308
+ //input carries form="" so it never submits one on its own
7309
+ this.#requestSubmit();
7310
+ return;
7311
+ }
7082
7312
  e.preventDefault();
7083
7313
  this.#input.setAttribute('aria-expanded', 'false');
7084
7314
  this.#ddmenu.acceptSelection();
@@ -7127,6 +7357,16 @@ class Select extends ParsedElement {
7127
7357
  async withLoader(fn) {
7128
7358
  return await fn(this.#loader);
7129
7359
  }
7360
+ #requestSubmit() {
7361
+ const form = this.internals.form;
7362
+ if (!form) {
7363
+ return;
7364
+ }
7365
+ const candidates = /** @type [HTMLButtonElement|HTMLInputElement] */ (
7366
+ Array.from(form.querySelectorAll('button:not(:disabled), input:not(:disabled)'))
7367
+ );
7368
+ form.requestSubmit(candidates.find((el) => el.type === 'submit'));
7369
+ }
7130
7370
  #changed() {
7131
7371
  const selection = [...this.#values.entries()].map((e) => ({
7132
7372
  key: e[0],
@@ -7156,16 +7396,43 @@ class Select extends ParsedElement {
7156
7396
  this.template('items').withOverlay({ entries: this.#values.entries() }).renderTo(this.#items);
7157
7397
  }
7158
7398
  set value(vs) {
7159
- if (vs === null) {
7160
- this.#values = new Map();
7161
- this.#syncBadges();
7399
+ //the csvm mapper yields [] for a missing multiple value, an empty string is
7400
+ //left alone: it is a usable key for an <option value="">
7401
+ const keys = vs == null ? [] : Array.isArray(vs) ? vs : [vs];
7402
+ //the keys are known synchronously and are all `value` reads, so they are applied
7403
+ //now: only the labels need the loader, until then a key stands in for its own
7404
+ this.#values = new Map(keys.map((k) => [k, [k]]));
7405
+ this.#syncBadges();
7406
+ const token = ++this.#token;
7407
+ if (keys.length === 0) {
7162
7408
  return;
7163
7409
  }
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
- })();
7410
+ this.#resolve(keys, token);
7411
+ }
7412
+ /**
7413
+ * Resolves the labels of the assigned keys. A failed lookup is left to reject so
7414
+ * that it is reported like any other failure: the keys stay applied either way.
7415
+ */
7416
+ async #resolve(keys, token) {
7417
+ const entries = await this.#loader.exact(...keys);
7418
+ if (token !== this.#token) {
7419
+ //a newer assignment has been made in the meantime
7420
+ return;
7421
+ }
7422
+ //label the keys that are still selected: a removal made while the lookup was in
7423
+ //flight must not be undone by it, and a key the loader does not know is dropped
7424
+ const resolved = new Map(entries.map((e) => [e[0], e.slice(1)]));
7425
+ for (const key of keys) {
7426
+ if (!this.#values.has(key)) {
7427
+ continue;
7428
+ }
7429
+ if (resolved.has(key)) {
7430
+ this.#values.set(key, resolved.get(key));
7431
+ } else {
7432
+ this.#values.delete(key);
7433
+ }
7434
+ }
7435
+ this.#syncBadges();
7169
7436
  }
7170
7437
  get value() {
7171
7438
  if (this.#multiple) {
@@ -7184,6 +7451,11 @@ class Select extends ParsedElement {
7184
7451
  }
7185
7452
  set disabled(d) {
7186
7453
  Attributes.toggle(this.#input, 'disabled', d);
7454
+ //also on the host: a form associated element only matches :disabled through its
7455
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
7456
+ //is needed, disabled is deliberately not observed: the platform delivers it
7457
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
7458
+ Attributes.toggle(this, 'disabled', d);
7187
7459
  }
7188
7460
  get readonly() {
7189
7461
  return this.#input.readOnly;
@@ -7289,7 +7561,9 @@ class RadioGroup extends ParsedElement {
7289
7561
  return [input, label];
7290
7562
  });
7291
7563
 
7292
- radioEls.forEach((el) => el.remove());
7564
+ radioEls.forEach((el) => {
7565
+ el.remove();
7566
+ });
7293
7567
  this.template().withOverlay({ name, slots, inputsAndLabels }).renderTo(this);
7294
7568
  this.#fieldset = this.firstElementChild;
7295
7569
  this.disabled = disabled;
@@ -7333,6 +7607,11 @@ class RadioGroup extends ParsedElement {
7333
7607
  }
7334
7608
  set disabled(d) {
7335
7609
  Attributes.toggle(this.#fieldset, 'disabled', d);
7610
+ //also on the host: a form associated element only matches :disabled through its
7611
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
7612
+ //is needed, disabled is deliberately not observed: the platform delivers it
7613
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
7614
+ Attributes.toggle(this, 'disabled', d);
7336
7615
  }
7337
7616
  get required() {
7338
7617
  return this.#fieldset.getAttribute('aria-required') === 'true';
@@ -7382,7 +7661,7 @@ class Checkbox extends ParsedElement {
7382
7661
  this.internals.role = 'presentation';
7383
7662
  }
7384
7663
  render({ slots, observed, disabled }) {
7385
- const isSwitch = this.getAttribute('type') == 'switch';
7664
+ const isSwitch = this.getAttribute('type') === 'switch';
7386
7665
  const klass = isSwitch ? 'form-check form-switch' : 'form-check';
7387
7666
  const fragment = this.template().withOverlay({ slots, klass, isSwitch }).render();
7388
7667
  this.#container = fragment.firstElementChild;
@@ -7446,6 +7725,11 @@ class Checkbox extends ParsedElement {
7446
7725
  }
7447
7726
  set disabled(d) {
7448
7727
  Attributes.toggle(this.#input, 'disabled', d);
7728
+ //also on the host: a form associated element only matches :disabled through its
7729
+ //own attribute, and that is what keeps it out of the submitted values. no reflect
7730
+ //is needed, disabled is deliberately not observed: the platform delivers it
7731
+ //through formDisabledCallback, which also covers a disabled ancestor fieldset
7732
+ Attributes.toggle(this, 'disabled', d);
7449
7733
  }
7450
7734
  get required() {
7451
7735
  return this.#input.getAttribute('aria-required') === 'true';
@@ -7486,9 +7770,10 @@ class Spinner extends ParsedElement {
7486
7770
  class SortButton extends ParsedElement {
7487
7771
  static observed = ['order'];
7488
7772
  #order;
7489
- render() {
7773
+ render({ observed }) {
7490
7774
  const sorter = this.getAttribute('sorter');
7491
7775
  const orders = ['asc', 'desc', null];
7776
+ this.order = observed.order;
7492
7777
  this.addEventListener('click', () => {
7493
7778
  const nextOrder = orders[(orders.indexOf(this.order) + 1) % 3];
7494
7779
  this.dispatchEvent(
@@ -7578,10 +7863,12 @@ class Pagination extends ParsedElement {
7578
7863
  #total = 0;
7579
7864
  #current = 0;
7580
7865
  render({ observed }) {
7581
- this.update(observed.current ?? 0, observed.total ?? 0);
7866
+ this.total = observed.total ?? 0;
7867
+ this.current = observed.current ?? 0;
7582
7868
  this.addEventListener('click', (/** @type any */ evt) => {
7583
7869
  const el = evt.target.closest('a');
7584
- if (!el) {
7870
+ if (!el || el.classList.contains('disabled')) {
7871
+ //a disabled link leads nowhere: the page it would ask for does not exist
7585
7872
  return;
7586
7873
  }
7587
7874
  this.dispatchEvent(
@@ -7597,25 +7884,20 @@ class Pagination extends ParsedElement {
7597
7884
  }
7598
7885
  update(current, total) {
7599
7886
  const maxRender = Number(this.getAttribute('pages') ?? '5');
7600
- const prev = { index: Math.max(0, current - 1), enabled: current > 0 };
7887
+ const hasPrev = current > 0;
7888
+ const hasNext = current + 1 < total;
7889
+ //a disabled arrow carries no page: there is nothing valid for it to point at
7890
+ const prev = { index: hasPrev ? current - 1 : null, enabled: hasPrev };
7601
7891
  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
- }
7892
+ const next = { index: hasNext ? current + 1 : null, enabled: hasNext };
7893
+ //the window holds at most maxRender pages, centered on the current one and slid
7894
+ //back towards the end so it stays full on the last pages
7895
+ const rendered = Math.max(1, Math.min(maxRender, total));
7896
+ const first = Math.max(0, Math.min(current - Math.floor((rendered - 1) / 2), total - rendered));
7897
+ const pages = Array.from({ length: rendered }, (_, offset) => ({
7898
+ index: first + offset,
7899
+ label: first + offset + 1,
7900
+ }));
7619
7901
  this.template().withOverlay({ total, prev, curr, next, pages }).renderTo(this);
7620
7902
  }
7621
7903
  get total() {
@@ -7642,9 +7924,10 @@ class Pagination extends ParsedElement {
7642
7924
 
7643
7925
  class TableSchemaParser {
7644
7926
  static parse(nodeOrFragment, template) {
7645
- const schema = Nodes.queryChildren(nodeOrFragment, 'schema');
7927
+ //nodeOrFragment is undefined when the slot is missing altogether
7928
+ const schema = nodeOrFragment ? Nodes.queryChildren(nodeOrFragment, 'schema') : null;
7646
7929
  if (!schema) {
7647
- throw new Error(`missing expected <schema> in ${nodeOrFragment}`);
7930
+ throw new Error('missing expected <schema>: ful-table needs a <template slot="schema"> holding one');
7648
7931
  }
7649
7932
  const headersTr = document.createElement('tr');
7650
7933
  const rowsTr = document.createElement('tr');
@@ -7863,9 +8146,10 @@ class Table extends ParsedElement {
7863
8146
  this.#noAutoload = table.querySelector(':scope > tbody[data-ref=initial]');
7864
8147
  this.#feedback = table.querySelector(':scope > tbody[data-ref=feedback]');
7865
8148
  this.#paginator = Nodes.queryChildren(fragment, 'ful-pagination');
7866
- this.#sorters = table.querySelectorAll(':scope > thead ful-sorter') ?? [];
7867
8149
  this.replaceChildren(fragment);
7868
- schema.headersTemplate.renderTo(this.querySelector('thead'));
8150
+ const thead = /** @type HTMLTableSectionElement */ (this.querySelector('thead'));
8151
+ schema.headersTemplate.renderTo(thead);
8152
+ this.#sorters = thead.querySelectorAll('ful-sorter');
7869
8153
  await Rendering.waitForChildren(this);
7870
8154
 
7871
8155
  const maybeForm = /** @type any */ (Nodes.queryChildren(this, 'ful-form'));
@@ -7900,11 +8184,16 @@ class Table extends ParsedElement {
7900
8184
  this.addEventListener('sort-requested', async (/** @type any */ e) => {
7901
8185
  const sortRequest = e.detail.value.order ? e.detail.value : null;
7902
8186
  await this.load(this.#latestRequest.pageRequest, sortRequest, this.#latestRequest.filterRequest);
7903
- this.#sorters.forEach((s) => (s.order = null));
8187
+ this.#sorters.forEach((s) => {
8188
+ s.order = null;
8189
+ });
7904
8190
  e.target.order = e.detail.value.order;
7905
8191
  });
7906
8192
  if (this.hasAttribute('autoload')) {
7907
- await this.reload();
8193
+ //not awaited: the first load must not hold up the upgrade, and a loader that
8194
+ //fails or never answers must not keep ftl:ready from firing for the page.
8195
+ //load renders its own error state and lets the failure reject, so it is reported
8196
+ this.reload();
7908
8197
  }
7909
8198
  }
7910
8199
 
@@ -7917,7 +8206,7 @@ class Table extends ParsedElement {
7917
8206
  }
7918
8207
  async load(pageRequest, sortRequest, filterRequest) {
7919
8208
  this.#body.replaceChildren();
7920
- this.#loading.removeAttribute('hidden', '');
8209
+ this.#loading.removeAttribute('hidden');
7921
8210
  this.#feedback.setAttribute('hidden', '');
7922
8211
  this.#noAutoload.setAttribute('hidden', '');
7923
8212
  try {
@@ -7926,7 +8215,7 @@ class Table extends ParsedElement {
7926
8215
  this.#update(pageRequest, sortRequest, filterRequest, pageResponse);
7927
8216
  } catch (/** @type any */ error) {
7928
8217
  this.#loading.setAttribute('hidden', '');
7929
- this.#feedback.removeAttribute('hidden', '');
8218
+ this.#feedback.removeAttribute('hidden');
7930
8219
  if (!error.problems) {
7931
8220
  this.#feedback.querySelector('[data-ref=feedback-error]').textContent = error;
7932
8221
  } else {
@@ -7968,7 +8257,7 @@ class Table extends ParsedElement {
7968
8257
  }
7969
8258
 
7970
8259
  class InstantFilter extends Input {
7971
- static observed = ['value:json', 'readonly:presence', 'required:presence'];
8260
+ static observed = ['value:json', 'readonly:presence', 'required:presence', 'placeholder'];
7972
8261
  static template = `
7973
8262
  <div class="form-label">
7974
8263
  <label>{{{{ slots.default }}}}</label>
@@ -8002,10 +8291,16 @@ class InstantFilter extends Input {
8002
8291
  this.#operator = this.querySelector('[data-ref=operator]');
8003
8292
  this.#value1 = this.querySelector('[data-ref=value1]');
8004
8293
  this.#value2 = this.querySelector('[data-ref=value2]');
8294
+ //Input.render only re-dispatches changes coming from the first operand
8295
+ this.#value2.addEventListener('change', (evt) => {
8296
+ evt.stopPropagation();
8297
+ this.#notifyChange();
8298
+ });
8005
8299
 
8006
8300
  this.disabled = conf.disabled;
8007
8301
  this.readonly = conf.observed.readonly;
8008
8302
  this.required = conf.observed.required;
8303
+ this.placeholder = conf.observed.placeholder;
8009
8304
  this.value = conf.observed.value;
8010
8305
 
8011
8306
  this.addEventListener('click', (evt) => {
@@ -8015,9 +8310,13 @@ class InstantFilter extends Input {
8015
8310
  }
8016
8311
  const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
8017
8312
  const value = /** @type String */ (target.getAttribute('value'));
8313
+ const previous = btn.getAttribute('value');
8018
8314
  Attributes.toggle(this.#value2, 'hidden', value !== 'BETWEEN');
8019
8315
  btn.setAttribute('value', value);
8020
8316
  btn.innerHTML = target.innerHTML;
8317
+ if (previous !== value) {
8318
+ this.#notifyChange();
8319
+ }
8021
8320
  });
8022
8321
  }
8023
8322
 
@@ -8033,14 +8332,40 @@ class InstantFilter extends Input {
8033
8332
  return;
8034
8333
  }
8035
8334
  const [operator, ...values] = v;
8036
- this.#operator.setAttribute('value', operator);
8335
+ this.#showOperator(operator);
8037
8336
  this.#value1.value = values[0] ? Instant.isoToLocal(values[0]) : values[0];
8038
8337
  this.#value2.value = values[1] ? Instant.isoToLocal(values[1]) : values[1];
8039
8338
  }
8339
+ #showOperator(operator) {
8340
+ this.#operator.setAttribute('value', operator);
8341
+ const items = Array.from(this.#operator.nextElementSibling?.querySelectorAll('li > a[value]') ?? []);
8342
+ const item = items.find((a) => a.getAttribute('value') === operator);
8343
+ if (item) {
8344
+ this.#operator.innerHTML = item.innerHTML;
8345
+ }
8346
+ Attributes.toggle(this.#value2, 'hidden', operator !== 'BETWEEN');
8347
+ }
8348
+ #notifyChange() {
8349
+ this.dispatchEvent(
8350
+ new CustomEvent('change', {
8351
+ bubbles: true,
8352
+ cancelable: false,
8353
+ detail: {
8354
+ value: this.value,
8355
+ },
8356
+ }),
8357
+ );
8358
+ }
8359
+ get readonly() {
8360
+ return super.readonly;
8361
+ }
8040
8362
  set readonly(v) {
8041
8363
  this.#value2.readOnly = v;
8042
8364
  super.readonly = v;
8043
8365
  }
8366
+ get disabled() {
8367
+ return super.disabled;
8368
+ }
8044
8369
  set disabled(d) {
8045
8370
  Attributes.toggle(this.#value2, 'disabled', d);
8046
8371
  super.disabled = d;
@@ -8048,7 +8373,7 @@ class InstantFilter extends Input {
8048
8373
  }
8049
8374
 
8050
8375
  class LocalDateFilter extends Input {
8051
- static observed = ['value:json', 'readonly:presence', 'required:presence'];
8376
+ static observed = ['value:json', 'readonly:presence', 'required:presence', 'placeholder'];
8052
8377
  static template = `
8053
8378
  <div class="form-label">
8054
8379
  <label>{{{{ slots.default }}}}</label>
@@ -8083,10 +8408,16 @@ class LocalDateFilter extends Input {
8083
8408
  this.#operator = this.querySelector('[data-ref=operator]');
8084
8409
  this.#value1 = this.querySelector('[data-ref=value1]');
8085
8410
  this.#value2 = this.querySelector('[data-ref=value2]');
8411
+ //Input.render only re-dispatches changes coming from the first operand
8412
+ this.#value2.addEventListener('change', (evt) => {
8413
+ evt.stopPropagation();
8414
+ this.#notifyChange();
8415
+ });
8086
8416
 
8087
8417
  this.disabled = conf.disabled;
8088
8418
  this.readonly = conf.observed.readonly;
8089
8419
  this.required = conf.observed.required;
8420
+ this.placeholder = conf.observed.placeholder;
8090
8421
  this.value = conf.observed.value;
8091
8422
 
8092
8423
  this.addEventListener('click', (evt) => {
@@ -8096,14 +8427,18 @@ class LocalDateFilter extends Input {
8096
8427
  }
8097
8428
  const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
8098
8429
  const value = /** @type String */ (target.getAttribute('value'));
8430
+ const previous = btn.getAttribute('value');
8099
8431
  Attributes.toggle(this.#value2, 'hidden', value !== 'BETWEEN');
8100
8432
  btn.setAttribute('value', value);
8101
8433
  btn.innerHTML = target.innerHTML;
8434
+ if (previous !== value) {
8435
+ this.#notifyChange();
8436
+ }
8102
8437
  });
8103
8438
  }
8104
8439
  get value() {
8105
8440
  const operator = this.#operator.getAttribute('value');
8106
- const values = operator == 'BETWEEN' ? [this.#value1.value, this.#value2.value] : [this.#value1.value];
8441
+ const values = operator === 'BETWEEN' ? [this.#value1.value, this.#value2.value] : [this.#value1.value];
8107
8442
  return values.some((v) => v === '') ? undefined : [operator, ...values];
8108
8443
  }
8109
8444
  set value(v) {
@@ -8113,14 +8448,40 @@ class LocalDateFilter extends Input {
8113
8448
  return;
8114
8449
  }
8115
8450
  const [operator, ...values] = v;
8116
- this.#operator.setAttribute('value', operator);
8451
+ this.#showOperator(operator);
8117
8452
  this.#value1.value = values[0];
8118
8453
  this.#value2.value = values[1];
8119
8454
  }
8455
+ #showOperator(operator) {
8456
+ this.#operator.setAttribute('value', operator);
8457
+ const items = Array.from(this.#operator.nextElementSibling?.querySelectorAll('li > a[value]') ?? []);
8458
+ const item = items.find((a) => a.getAttribute('value') === operator);
8459
+ if (item) {
8460
+ this.#operator.innerHTML = item.innerHTML;
8461
+ }
8462
+ Attributes.toggle(this.#value2, 'hidden', operator !== 'BETWEEN');
8463
+ }
8464
+ #notifyChange() {
8465
+ this.dispatchEvent(
8466
+ new CustomEvent('change', {
8467
+ bubbles: true,
8468
+ cancelable: false,
8469
+ detail: {
8470
+ value: this.value,
8471
+ },
8472
+ }),
8473
+ );
8474
+ }
8475
+ get readonly() {
8476
+ return super.readonly;
8477
+ }
8120
8478
  set readonly(v) {
8121
8479
  this.#value2.readOnly = v;
8122
8480
  super.readonly = v;
8123
8481
  }
8482
+ get disabled() {
8483
+ return super.disabled;
8484
+ }
8124
8485
  set disabled(d) {
8125
8486
  Attributes.toggle(this.#value2, 'disabled', d);
8126
8487
  super.disabled = d;
@@ -8128,7 +8489,7 @@ class LocalDateFilter extends Input {
8128
8489
  }
8129
8490
 
8130
8491
  class TextFilter extends Input {
8131
- static observed = ['value:json', 'readonly:presence', 'required:presence'];
8492
+ static observed = ['value:json', 'readonly:presence', 'required:presence', 'placeholder'];
8132
8493
  static template = `
8133
8494
  <div class="form-label">
8134
8495
  <label>{{{{ slots.default }}}}</label>
@@ -8152,6 +8513,8 @@ class TextFilter extends Input {
8152
8513
  `;
8153
8514
  #operator;
8154
8515
  #value;
8516
+ //the sensitivity has no control of its own: it is carried through from whoever set the value
8517
+ #sensitivity = 'IGNORE_CASE';
8155
8518
  render(conf) {
8156
8519
  super.render({ ...conf, skipObservedSetup: true });
8157
8520
 
@@ -8161,6 +8524,7 @@ class TextFilter extends Input {
8161
8524
  this.disabled = conf.disabled;
8162
8525
  this.readonly = conf.observed.readonly;
8163
8526
  this.required = conf.observed.required;
8527
+ this.placeholder = conf.observed.placeholder;
8164
8528
  this.value = conf.observed.value;
8165
8529
 
8166
8530
  this.addEventListener('click', (evt) => {
@@ -8170,13 +8534,17 @@ class TextFilter extends Input {
8170
8534
  }
8171
8535
  const btn = /** @type HTMLButtonElement */ (target.closest('ul')?.previousElementSibling);
8172
8536
  const value = /** @type String */ (target.getAttribute('value'));
8537
+ const previous = btn.getAttribute('value');
8173
8538
  btn.setAttribute('value', value);
8174
8539
  btn.innerHTML = target.innerHTML;
8540
+ if (previous !== value) {
8541
+ this.#notifyChange();
8542
+ }
8175
8543
  });
8176
8544
  }
8177
8545
  get value() {
8178
8546
  const operator = this.#operator.getAttribute('value');
8179
- return this.#value.value === '' ? undefined : [operator, 'IGNORE_CASE', this.#value.value];
8547
+ return this.#value.value === '' ? undefined : [operator, this.#sensitivity, this.#value.value];
8180
8548
  }
8181
8549
  set value(v) {
8182
8550
  if (v == null) {
@@ -8184,15 +8552,35 @@ class TextFilter extends Input {
8184
8552
  return;
8185
8553
  }
8186
8554
  const [operator, sensitivity, value] = v;
8187
- this.#operator.setAttribute('value', operator);
8555
+ this.#showOperator(operator);
8556
+ this.#sensitivity = sensitivity ?? 'IGNORE_CASE';
8188
8557
  this.#value.value = value;
8189
8558
  }
8559
+ #showOperator(operator) {
8560
+ this.#operator.setAttribute('value', operator);
8561
+ const items = Array.from(this.#operator.nextElementSibling?.querySelectorAll('li > a[value]') ?? []);
8562
+ const item = items.find((a) => a.getAttribute('value') === operator);
8563
+ if (item) {
8564
+ this.#operator.innerHTML = item.innerHTML;
8565
+ }
8566
+ }
8567
+ #notifyChange() {
8568
+ this.dispatchEvent(
8569
+ new CustomEvent('change', {
8570
+ bubbles: true,
8571
+ cancelable: false,
8572
+ detail: {
8573
+ value: this.value,
8574
+ },
8575
+ }),
8576
+ );
8577
+ }
8190
8578
  }
8191
8579
 
8192
8580
  class LocalizationModule {
8193
8581
  static t(k, ...args) {
8194
- //@ts-ignore
8195
- const format = this.l10n?.[this.language]?.[k] ?? this.l10n?.['en']?.[k] ?? k;
8582
+ //@ts-expect-error l10n and language come from the element class and the data stack
8583
+ const format = this.l10n?.[this.language]?.[k] ?? this.l10n?.en?.[k] ?? k;
8196
8584
  if (args.length === 0) {
8197
8585
  return format;
8198
8586
  }
@@ -8275,9 +8663,11 @@ var ful = /*#__PURE__*/Object.freeze({
8275
8663
  });
8276
8664
 
8277
8665
  if (typeof window !== 'undefined') {
8666
+ // biome-ignore-start lint/complexity/useLiteralKeys: checkJs rejects unknown properties on Window
8278
8667
  window['ftl'] = ftl;
8279
8668
  window['httpc'] = httpc;
8280
8669
  window['ful'] = ful;
8670
+ // biome-ignore-end lint/complexity/useLiteralKeys: checkJs rejects unknown properties on Window
8281
8671
  }
8282
8672
 
8283
8673
  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 };