@optionfactory/fml 8.0.1 → 8.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/fml.mjs CHANGED
@@ -4378,25 +4378,38 @@ class UpgradeQueue {
4378
4378
  );
4379
4379
  });
4380
4380
  }
4381
+ #finished = new Map();
4381
4382
  enqueue(el) {
4382
4383
  if (this.#q.has(el)) {
4383
4384
  //already upgrading, can happen when disconnecting an element
4384
4385
  //while it's already queued for upgrade (e.g.: ful-form)
4385
4386
  return;
4386
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
+ );
4387
4394
  const promise = Nodes.waitParsed(el)
4388
4395
  .then(() => el.upgrade())
4389
- .finally(() => this.#q.delete(el));
4396
+ .finally(() => {
4397
+ this.#q.delete(el);
4398
+ this.#finished.delete(el);
4399
+ markFinished();
4400
+ });
4390
4401
  this.#q.set(el, promise);
4402
+ this.#finished.set(el, finished);
4391
4403
  }
4392
4404
  /**
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.
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.
4396
4409
  */
4397
4410
  async settle() {
4398
- while (this.#q.size !== 0) {
4399
- await Promise.all(Array.from(this.#q.values()));
4411
+ while (this.#finished.size !== 0) {
4412
+ await Promise.all(Array.from(this.#finished.values()));
4400
4413
  }
4401
4414
  }
4402
4415
  get entries() {
@@ -4681,14 +4694,30 @@ class ParsedElement extends HTMLElement {
4681
4694
  }
4682
4695
  this[attr] = this.unmarshal(attr, newValue);
4683
4696
  }
4684
- #disabledBeforeParsed = null;
4685
- formDisabledCallback(disabled) {
4686
- if (!this.#parsed) {
4687
- this.#disabledBeforeParsed = disabled;
4688
- return;
4689
- }
4690
- Reflect.set(this, 'disabled', disabled);
4691
- }
4697
+ /**
4698
+ * The disabled protocol follows the semantics of a native form control:
4699
+ *
4700
+ * - the `disabled` attribute on the host is the field's own claim, and nothing
4701
+ * but its author ever writes or removes it, in markup or through the property.
4702
+ * The framework never claims on the form's behalf, so there is nothing to
4703
+ * unclaim and nothing to lose: a field declared disabled inside a disabled
4704
+ * `<fieldset>` stays disabled when the fieldset comes back, exactly like a
4705
+ * native input keeps its attribute.
4706
+ * - the effective state is the claim OR a disabled fieldset ancestry, which the
4707
+ * platform maintains on its own: `:disabled` matches both, a disabled field is
4708
+ * left out of the submitted values, and the inner native controls are reached
4709
+ * by the ancestry as descendants of the fieldset.
4710
+ * - the `disabled` property reflects the claim only, like a native input's: a
4711
+ * field disabled by its ancestry reads `false` while `matches(':disabled')`
4712
+ * tells the effective state. Un-claiming inside a disabled fieldset cannot
4713
+ * enable the field.
4714
+ * - the inner controls mirror the claim and nothing else: the ancestry state is
4715
+ * never written anywhere, so it can never go stale, and the browser composes
4716
+ * the two on its own when it disables and re-enables a fieldset's descendants.
4717
+ *
4718
+ * Because of this, formDisabledCallback carries nothing the framework needs to
4719
+ * apply, and the protocol does not define it.
4720
+ */
4692
4721
  async upgrade() {
4693
4722
  if (this.#parsed) {
4694
4723
  return;
@@ -4701,8 +4730,9 @@ class ParsedElement extends HTMLElement {
4701
4730
  this.unmarshal(attribute, this.getAttribute(attribute)),
4702
4731
  ]),
4703
4732
  );
4704
- const disabled = this.#disabledBeforeParsed ?? false;
4705
- await this.render({ slots, observed, disabled });
4733
+ //the declared claim is what render receives: the ancestry state is not
4734
+ //passed around, it is already where it needs to be
4735
+ await this.render({ slots, observed, disabled: this.hasAttribute('disabled') });
4706
4736
  }
4707
4737
  render(c) {}
4708
4738
  reflect(fn) {
@@ -6299,15 +6329,17 @@ class Input extends ParsedElement {
6299
6329
  });
6300
6330
  }
6301
6331
  get disabled() {
6302
- return this._input.hasAttribute('disabled');
6332
+ //the claim only, like a native input: the effective state, claim or disabled
6333
+ //ancestry, is what :disabled matches
6334
+ return this.hasAttribute('disabled');
6303
6335
  }
6304
6336
  set disabled(d) {
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
6337
+ //the claim belongs to the author alone, nothing else ever writes it
6310
6338
  Attributes.toggle(this, 'disabled', d);
6339
+ //the inner control carries the claim as a native input would: a disabled
6340
+ //fieldset ancestry is left to the browser, which reaches the inner control
6341
+ //as a descendant of the fieldset and re-enables it on its own
6342
+ Attributes.toggle(this._input, 'disabled', d);
6311
6343
  }
6312
6344
  get required() {
6313
6345
  return this._input.getAttribute('aria-required') === 'true';
@@ -7227,7 +7259,9 @@ class Select extends ParsedElement {
7227
7259
  if (e.target.matches('input')) {
7228
7260
  return;
7229
7261
  }
7230
- if (this.disabled || this.readonly) {
7262
+ //badges and other chrome are not form controls, the guard must ask the
7263
+ //effective state
7264
+ if (this.matches(':disabled') || this.readonly) {
7231
7265
  return;
7232
7266
  }
7233
7267
  if (this.#ddmenu.shown) {
@@ -7243,7 +7277,7 @@ class Select extends ParsedElement {
7243
7277
  if (!e.target.closest('button')) {
7244
7278
  return;
7245
7279
  }
7246
- if (this.disabled || this.readonly) {
7280
+ if (this.matches(':disabled') || this.readonly) {
7247
7281
  return;
7248
7282
  }
7249
7283
  const idx = [...this.#items.children].indexOf(e.target.closest('ful-item'));
@@ -7256,7 +7290,7 @@ class Select extends ParsedElement {
7256
7290
  });
7257
7291
  this.#badges.addEventListener('click', (e) => {
7258
7292
  e.stopPropagation();
7259
- if (this.disabled || this.readonly) {
7293
+ if (this.matches(':disabled') || this.readonly) {
7260
7294
  return;
7261
7295
  }
7262
7296
  const idx = [...this.#badges.children].indexOf(e.target);
@@ -7281,7 +7315,7 @@ class Select extends ParsedElement {
7281
7315
  this.#input.value = '';
7282
7316
  });
7283
7317
  this.#input.addEventListener('keydown', (e) => {
7284
- if (this.disabled || this.readonly) {
7318
+ if (this.matches(':disabled') || this.readonly) {
7285
7319
  return;
7286
7320
  }
7287
7321
  switch (e.code) {
@@ -7334,7 +7368,7 @@ class Select extends ParsedElement {
7334
7368
  });
7335
7369
  this.#input.addEventListener('input', (e) => {
7336
7370
  e.stopPropagation();
7337
- if (this.disabled || this.readonly) {
7371
+ if (this.matches(':disabled') || this.readonly) {
7338
7372
  return;
7339
7373
  }
7340
7374
  dload();
@@ -7344,7 +7378,7 @@ class Select extends ParsedElement {
7344
7378
  if (!this.#multiple) {
7345
7379
  this.#values.clear();
7346
7380
  }
7347
- this.#values.set(e.detail.data[0], e.detail.data.slice(1));
7381
+ this.#values.set(this.#coerceKey(e.detail.data[0]), e.detail.data.slice(1));
7348
7382
  this.#changed();
7349
7383
  this.#syncBadges();
7350
7384
  this.#input.focus();
@@ -7395,10 +7429,36 @@ class Select extends ParsedElement {
7395
7429
  this.#items.replaceChildren();
7396
7430
  this.template('items').withOverlay({ entries: this.#values.entries() }).renderTo(this.#items);
7397
7431
  }
7432
+ /**
7433
+ * Coerces a key to the type declared by `k-type`. Keys reach the element from
7434
+ * both worlds: the `value` attribute is text, a loader returns whatever its
7435
+ * endpoint carries. One canonical type keeps the internal Map, which compares
7436
+ * keys strictly, consistent. A key that does not decode is left as it is.
7437
+ */
7438
+ #coerceKey(k) {
7439
+ switch (this.getAttribute('k-type')) {
7440
+ case 'number': {
7441
+ const n = k === '' ? Number.NaN : Number(k);
7442
+ return Number.isNaN(n) ? k : n;
7443
+ }
7444
+ case 'boolean': {
7445
+ if (k === true || k === 'true') {
7446
+ return true;
7447
+ }
7448
+ if (k === false || k === 'false') {
7449
+ return false;
7450
+ }
7451
+ return k;
7452
+ }
7453
+ default:
7454
+ return String(k);
7455
+ }
7456
+ }
7457
+
7398
7458
  set value(vs) {
7399
7459
  //the csvm mapper yields [] for a missing multiple value, an empty string is
7400
7460
  //left alone: it is a usable key for an <option value="">
7401
- const keys = vs == null ? [] : Array.isArray(vs) ? vs : [vs];
7461
+ const keys = (vs == null ? [] : Array.isArray(vs) ? vs : [vs]).map((k) => this.#coerceKey(k));
7402
7462
  //the keys are known synchronously and are all `value` reads, so they are applied
7403
7463
  //now: only the labels need the loader, until then a key stands in for its own
7404
7464
  this.#values = new Map(keys.map((k) => [k, [k]]));
@@ -7421,7 +7481,8 @@ class Select extends ParsedElement {
7421
7481
  }
7422
7482
  //label the keys that are still selected: a removal made while the lookup was in
7423
7483
  //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)]));
7484
+ //the loader keys are coerced too, so they line up with the assigned ones
7485
+ const resolved = new Map(entries.map((e) => [this.#coerceKey(e[0]), e.slice(1)]));
7425
7486
  for (const key of keys) {
7426
7487
  if (!this.#values.has(key)) {
7427
7488
  continue;
@@ -7447,15 +7508,17 @@ class Select extends ParsedElement {
7447
7508
  return [...this.#values.entries()][0] ?? null;
7448
7509
  }
7449
7510
  get disabled() {
7450
- return this.#input.hasAttribute('disabled');
7511
+ //the claim only, like a native input: the effective state, claim or disabled
7512
+ //ancestry, is what :disabled matches
7513
+ return this.hasAttribute('disabled');
7451
7514
  }
7452
7515
  set disabled(d) {
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
7516
+ //the claim belongs to the author alone, nothing else ever writes it
7458
7517
  Attributes.toggle(this, 'disabled', d);
7518
+ //the inner control carries the claim as a native input would: a disabled
7519
+ //fieldset ancestry is left to the browser, which reaches the inner control
7520
+ //as a descendant of the fieldset and re-enables it on its own
7521
+ Attributes.toggle(this.#input, 'disabled', d);
7459
7522
  }
7460
7523
  get readonly() {
7461
7524
  return this.#input.readOnly;
@@ -7603,15 +7666,17 @@ class RadioGroup extends ParsedElement {
7603
7666
  });
7604
7667
  }
7605
7668
  get disabled() {
7606
- return this.#fieldset.hasAttribute('disabled');
7669
+ //the claim only, like a native input: the effective state, claim or disabled
7670
+ //ancestry, is what :disabled matches
7671
+ return this.hasAttribute('disabled');
7607
7672
  }
7608
7673
  set disabled(d) {
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
7674
+ //the claim belongs to the author alone, nothing else ever writes it
7614
7675
  Attributes.toggle(this, 'disabled', d);
7676
+ //the group disables through its own fieldset, which carries the claim like
7677
+ //a native input would: a disabled outer ancestry is left to the browser,
7678
+ //which reaches the radios as descendants and re-enables them on its own
7679
+ this.#fieldset.disabled = d;
7615
7680
  }
7616
7681
  get required() {
7617
7682
  return this.#fieldset.getAttribute('aria-required') === 'true';
@@ -7686,7 +7751,8 @@ class Checkbox extends ParsedElement {
7686
7751
  const label = fragment.querySelector('label');
7687
7752
  label.addEventListener('click', () => {
7688
7753
  this.focus();
7689
- if (this.disabled || this.readonly) {
7754
+ //a label is not a form control, the guard must ask the effective state
7755
+ if (this.matches(':disabled') || this.readonly) {
7690
7756
  return;
7691
7757
  }
7692
7758
  this.value = !this.value;
@@ -7721,15 +7787,17 @@ class Checkbox extends ParsedElement {
7721
7787
  });
7722
7788
  }
7723
7789
  get disabled() {
7724
- return this.#input.hasAttribute('disabled');
7790
+ //the claim only, like a native input: the effective state, claim or disabled
7791
+ //ancestry, is what :disabled matches
7792
+ return this.hasAttribute('disabled');
7725
7793
  }
7726
7794
  set disabled(d) {
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
7795
+ //the claim belongs to the author alone, nothing else ever writes it
7732
7796
  Attributes.toggle(this, 'disabled', d);
7797
+ //the inner control carries the claim as a native input would: a disabled
7798
+ //fieldset ancestry is left to the browser, which reaches the inner control
7799
+ //as a descendant of the fieldset and re-enables it on its own
7800
+ Attributes.toggle(this.#input, 'disabled', d);
7733
7801
  }
7734
7802
  get required() {
7735
7803
  return this.#input.getAttribute('aria-required') === 'true';
@@ -8367,8 +8435,10 @@ class InstantFilter extends Input {
8367
8435
  return super.disabled;
8368
8436
  }
8369
8437
  set disabled(d) {
8370
- Attributes.toggle(this.#value2, 'disabled', d);
8438
+ //the claim and the first operand are the base's, the second operand mirrors
8439
+ //the claim like the first one does
8371
8440
  super.disabled = d;
8441
+ Attributes.toggle(this.#value2, 'disabled', d);
8372
8442
  }
8373
8443
  }
8374
8444
 
@@ -8483,8 +8553,10 @@ class LocalDateFilter extends Input {
8483
8553
  return super.disabled;
8484
8554
  }
8485
8555
  set disabled(d) {
8486
- Attributes.toggle(this.#value2, 'disabled', d);
8556
+ //the claim and the first operand are the base's, the second operand mirrors
8557
+ //the claim like the first one does
8487
8558
  super.disabled = d;
8559
+ Attributes.toggle(this.#value2, 'disabled', d);
8488
8560
  }
8489
8561
  }
8490
8562