@lion/ui 0.12.0 → 0.13.0

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 (98) hide show
  1. package/components/combobox/src/LionCombobox.js +26 -8
  2. package/components/combobox/test/lion-combobox.test.js +206 -57
  3. package/components/form-core/src/FocusMixin.js +1 -0
  4. package/components/form-core/src/FormControlMixin.js +23 -18
  5. package/components/form-core/src/validate/ValidateMixin.js +17 -1
  6. package/components/form-core/test/FormControlMixin.test.js +68 -0
  7. package/components/form-core/test-suites/ValidateMixin.suite.js +19 -3
  8. package/components/overlays/src/OverlayController.js +32 -5
  9. package/components/overlays/src/OverlayMixin.js +1 -0
  10. package/components/overlays/src/configurations/withDropdownConfig.js +0 -1
  11. package/components/overlays/src/configurations/withTooltipConfig.js +0 -1
  12. package/components/overlays/test/OverlayController.test.js +16 -4
  13. package/components/overlays/test-suites/OverlayMixin.suite.js +71 -0
  14. package/custom-elements.json +1144 -327
  15. package/dist-types/components/combobox/src/LionCombobox.d.ts.map +1 -1
  16. package/dist-types/components/core/src/ScopedElementsMixin.d.ts.map +1 -1
  17. package/dist-types/components/form-core/src/FocusMixin.d.ts.map +1 -1
  18. package/dist-types/components/form-core/src/FormControlMixin.d.ts.map +1 -1
  19. package/dist-types/components/form-core/src/validate/ValidateMixin.d.ts.map +1 -1
  20. package/dist-types/components/form-core/test-suites/ValidateMixin.suite.d.ts.map +1 -1
  21. package/dist-types/components/overlays/src/OverlayController.d.ts.map +1 -1
  22. package/dist-types/components/overlays/src/OverlayMixin.d.ts.map +1 -1
  23. package/dist-types/components/overlays/src/configurations/withDropdownConfig.d.ts.map +1 -1
  24. package/dist-types/components/overlays/src/configurations/withTooltipConfig.d.ts.map +1 -1
  25. package/dist-types/components/overlays/test/OverlayController.test.d.ts.map +1 -1
  26. package/dist-types/components/overlays/test-suites/OverlayMixin.suite.d.ts.map +1 -1
  27. package/dist-types/tsconfig.tsbuildinfo +1 -1
  28. package/docs/components/checkbox-group/api-table.md +6 -6
  29. package/docs/components/checkbox-group/overview.md +2 -2
  30. package/docs/components/collapsible/overview.md +1 -1
  31. package/docs/components/combobox/api-table.md +2 -2
  32. package/docs/components/combobox/overview.md +1 -1
  33. package/docs/components/combobox/use-cases.md +90 -1
  34. package/docs/components/dialog/overview.md +1 -1
  35. package/docs/components/fieldset/api-table.md +2 -2
  36. package/docs/components/fieldset/overview.md +5 -5
  37. package/docs/components/form/overview.md +3 -3
  38. package/docs/components/form/use-cases.md +2 -2
  39. package/docs/components/icon/use-cases.md +1 -1
  40. package/docs/components/input/api-table.md +2 -2
  41. package/docs/components/input/overview.md +1 -1
  42. package/docs/components/input-amount/api-table.md +2 -2
  43. package/docs/components/input-amount/overview.md +3 -3
  44. package/docs/components/input-date/api-table.md +2 -2
  45. package/docs/components/input-date/overview.md +3 -3
  46. package/docs/components/input-datepicker/api-table.md +2 -2
  47. package/docs/components/input-datepicker/overview.md +5 -5
  48. package/docs/components/input-email/api-table.md +2 -2
  49. package/docs/components/input-email/overview.md +2 -2
  50. package/docs/components/input-file/api-table.md +2 -2
  51. package/docs/components/input-file/overview.md +1 -1
  52. package/docs/components/input-iban/api-table.md +2 -2
  53. package/docs/components/input-iban/overview.md +2 -2
  54. package/docs/components/input-range/api-table.md +2 -2
  55. package/docs/components/input-range/overview.md +2 -2
  56. package/docs/components/input-stepper/api-table.md +2 -2
  57. package/docs/components/input-stepper/overview.md +1 -1
  58. package/docs/components/input-tel/api-table.md +2 -2
  59. package/docs/components/input-tel/overview.md +1 -1
  60. package/docs/components/input-tel/use-cases.md +6 -6
  61. package/docs/components/input-tel-dropdown/api-table.md +2 -2
  62. package/docs/components/input-tel-dropdown/overview.md +1 -1
  63. package/docs/components/listbox/api-table.md +2 -2
  64. package/docs/components/listbox/use-cases.md +1 -1
  65. package/docs/components/radio-group/api-table.md +4 -4
  66. package/docs/components/radio-group/overview.md +3 -3
  67. package/docs/components/select/api-table.md +2 -2
  68. package/docs/components/select/use-cases.md +1 -1
  69. package/docs/components/select-rich/api-table.md +2 -2
  70. package/docs/components/select-rich/use-cases.md +1 -1
  71. package/docs/components/switch/api-table.md +2 -2
  72. package/docs/components/tabs/examples.md +1 -1
  73. package/docs/components/textarea/api-table.md +2 -2
  74. package/docs/fundamentals/index.md +4 -4
  75. package/docs/fundamentals/node-tools/publish-docs/overview.md +3 -3
  76. package/docs/fundamentals/node-tools/rocket-preset-extend-lion-docs/overview.md +2 -2
  77. package/docs/fundamentals/rationales/accessibility.md +1 -1
  78. package/docs/fundamentals/systems/core/SlotMixin.md +1 -1
  79. package/docs/fundamentals/systems/core/overview.md +1 -1
  80. package/docs/fundamentals/systems/form/api-table.md +2 -2
  81. package/docs/fundamentals/systems/form/formatting-and-parsing.md +4 -4
  82. package/docs/fundamentals/systems/form/model-value.md +1 -1
  83. package/docs/fundamentals/systems/form/overview.md +28 -28
  84. package/docs/fundamentals/systems/form/styling.md +3 -20
  85. package/docs/fundamentals/systems/form/validate.md +2 -2
  86. package/docs/fundamentals/systems/localize/overview.md +4 -4
  87. package/docs/fundamentals/systems/overlays/configuration.md +2 -2
  88. package/docs/fundamentals/systems/overlays/overview.md +5 -5
  89. package/docs/fundamentals/systems/overlays/use-cases.md +2 -2
  90. package/docs/fundamentals/tools/helpers/overview.md +1 -1
  91. package/docs/fundamentals/tools/singleton-manager/overview.md +4 -4
  92. package/docs/guides/how-to/create-a-custom-field.md +5 -5
  93. package/docs/guides/how-to/create-a-form.md +4 -4
  94. package/docs/guides/how-to/extend-a-native-input.md +2 -2
  95. package/docs/guides/index.md +8 -8
  96. package/docs/guides/principles/index.md +5 -5
  97. package/docs/guides/principles/styling.md +1 -1
  98. package/package.json +3 -3
@@ -502,6 +502,10 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
502
502
  if (this._selectionDisplayNode) {
503
503
  this._selectionDisplayNode.comboboxElement = this;
504
504
  }
505
+
506
+ if (this.disabled || this.readOnly) {
507
+ this.__setComboboxDisabledAndReadOnly();
508
+ }
505
509
  }
506
510
 
507
511
  /**
@@ -657,9 +661,11 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
657
661
  const alwaysHideOn = ['Tab', 'Escape'];
658
662
  const notMultipleChoiceHideOn = ['Enter'];
659
663
  if (
660
- lastKey &&
661
- (alwaysHideOn.includes(lastKey) ||
662
- (!this.multipleChoice && notMultipleChoiceHideOn.includes(lastKey)))
664
+ this.disabled ||
665
+ this.readOnly ||
666
+ (lastKey &&
667
+ (alwaysHideOn.includes(lastKey) ||
668
+ (!this.multipleChoice && notMultipleChoiceHideOn.includes(lastKey))))
663
669
  ) {
664
670
  return false;
665
671
  }
@@ -1174,9 +1180,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
1174
1180
  */
1175
1181
  // eslint-disable-next-line no-unused-vars
1176
1182
  _syncToTextboxCondition(modelValue, oldModelValue, { phase } = {}) {
1177
- return (
1178
- this.autocomplete === 'inline' || this.autocomplete === 'both' || phase === 'overlay-close'
1179
- );
1183
+ return this.autocomplete === 'both' || this.autocomplete === 'inline' || !this.focused;
1180
1184
  }
1181
1185
 
1182
1186
  /**
@@ -1213,8 +1217,22 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
1213
1217
  */
1214
1218
  __setComboboxDisabledAndReadOnly() {
1215
1219
  if (this._comboboxNode) {
1216
- this._comboboxNode.setAttribute('disabled', `${this.disabled}`);
1217
- this._comboboxNode.setAttribute('readonly', `${this.readOnly}`);
1220
+ // Since `._comboboxNode` can either be `<div "role=combobox">` or `<input>`,
1221
+ // we need to cater for both scenarios (aria for semantics, "native attr" for operability)
1222
+ this._comboboxNode.toggleAttribute('disabled', this.disabled);
1223
+ this._comboboxNode.setAttribute('aria-disabled', `${this.disabled}`);
1224
+ this._comboboxNode.toggleAttribute('readonly', this.readOnly);
1225
+ this._comboboxNode.setAttribute('aria-readonly', `${this.readOnly}`);
1226
+ }
1227
+
1228
+ if (this._inputNode) {
1229
+ // N.B. in case ._inputNode === ._comboboxNode (we have <input role="combobox">)
1230
+ // this value has already been set above. This is fine, as a toggle with boolean flag is idempotent.
1231
+ this._inputNode.toggleAttribute('disabled', this.disabled);
1232
+ this._inputNode.toggleAttribute('readOnly', this.readOnly);
1233
+
1234
+ this._inputNode.setAttribute('aria-readonly', `${this.readOnly}`);
1235
+ this._inputNode.tabIndex = this.disabled ? -1 : 0;
1218
1236
  }
1219
1237
  }
1220
1238
 
@@ -24,12 +24,12 @@ import { isActiveElement } from '../../core/test-helpers/isActiveElement.js';
24
24
  */
25
25
 
26
26
  /**
27
- * @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all' }} config
27
+ * @param {{ autocomplete?:'none'|'list'|'both', matchMode?:'begin'|'all', disabled?: boolean, readonly?: boolean }} config
28
28
  */
29
- async function fruitFixture({ autocomplete, matchMode } = {}) {
29
+ async function fruitFixture({ autocomplete, matchMode, disabled, readonly } = {}) {
30
30
  const el = /** @type {LionCombobox} */ (
31
31
  await fixture(html`
32
- <lion-combobox name="foo">
32
+ <lion-combobox label="Search" name="foo">
33
33
  <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
34
34
  <lion-option .choiceValue="${'Chard'}">Chard</lion-option>
35
35
  <lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
@@ -43,6 +43,12 @@ async function fruitFixture({ autocomplete, matchMode } = {}) {
43
43
  if (matchMode) {
44
44
  el.matchMode = matchMode;
45
45
  }
46
+ if (disabled) {
47
+ el.disabled = disabled;
48
+ }
49
+ if (readonly) {
50
+ el.readOnly = readonly;
51
+ }
46
52
  await el.updateComplete;
47
53
  return [el, el.formElements];
48
54
  }
@@ -634,38 +640,39 @@ describe('lion-combobox', () => {
634
640
  });
635
641
 
636
642
  it('syncs textbox to modelValue', async () => {
637
- const el = /** @type {LionCombobox} */ (
638
- await fixture(html`
639
- <lion-combobox name="foo" show-all-on-empty>
640
- <lion-option .choiceValue="${'Aha'}" checked>Aha</lion-option>
641
- <lion-option .choiceValue="${'Bhb'}">Bhb</lion-option>
642
- </lion-combobox>
643
- `)
644
- );
645
- const { _inputNode } = getComboboxMembers(el);
643
+ for (const autocompleteMode of ['none', 'list', 'inline', 'both']) {
644
+ const el = /** @type {LionCombobox} */ (
645
+ await fixture(html`
646
+ <lion-combobox name="foo" show-all-on-empty .autocomplete="${autocompleteMode}">
647
+ <lion-option .choiceValue="${'Aa'}" checked>Aa</lion-option>
648
+ <lion-option .choiceValue="${'Bb'}">Bb</lion-option>
649
+ </lion-combobox>
650
+ `)
651
+ );
652
+ const { _inputNode } = getComboboxMembers(el);
653
+
654
+ expect(_inputNode.value).to.equal('Aa', `autocompleteMode is ${autocompleteMode}`);
646
655
 
647
- /** @param {string} autocompleteMode */
648
- async function performChecks(autocompleteMode) {
649
656
  el.formElements[0].click();
650
657
  await el.updateComplete;
651
658
 
652
- // FIXME: fix properly for Webkit
653
- // expect(_inputNode.value).to.equal('Aha', `autocompleteMode is ${autocompleteMode}`);
659
+ expect(_inputNode.value).to.equal('Aa', `autocompleteMode is ${autocompleteMode}`);
654
660
  expect(el.checkedIndex).to.equal(0, `autocompleteMode is ${autocompleteMode}`);
655
661
 
656
- await mimicUserTyping(el, 'Ah');
662
+ await mimicUserTyping(el, 'B');
657
663
  await el.updateComplete;
658
- expect(_inputNode.value).to.equal('Ah', `autocompleteMode is ${autocompleteMode}`);
659
-
660
- await el.updateComplete;
661
- expect(el.checkedIndex).to.equal(-1, `autocompleteMode is ${autocompleteMode}`);
664
+ if (autocompleteMode === 'none' || autocompleteMode === 'list') {
665
+ expect(el.modelValue).to.deep.equal(
666
+ { type: 'unparseable', viewValue: 'B' },
667
+ `autocompleteMode is ${autocompleteMode}`,
668
+ );
669
+ await el.updateComplete;
670
+ expect(el.checkedIndex).to.equal(-1, `autocompleteMode is ${autocompleteMode}`);
671
+ } else {
672
+ expect(el.modelValue).to.equal('Bb', `autocompleteMode is ${autocompleteMode}`);
673
+ expect(el.checkedIndex).to.equal(1, `autocompleteMode is ${autocompleteMode}`);
674
+ }
662
675
  }
663
-
664
- el.autocomplete = 'none';
665
- await performChecks('none');
666
-
667
- el.autocomplete = 'list';
668
- await performChecks('list');
669
676
  });
670
677
 
671
678
  it('works with Required validator', async () => {
@@ -986,6 +993,27 @@ describe('lion-combobox', () => {
986
993
  expect(el.modelValue).to.eql(['Art']);
987
994
  });
988
995
 
996
+ it('allows prefilling the combobox', async () => {
997
+ const autocompleteValues = ['inline', 'none', 'both', 'list'];
998
+
999
+ for (const autocompleteValue of autocompleteValues) {
1000
+ const el = /** @type {LionCombobox} */ (
1001
+ await fixture(html`
1002
+ <lion-combobox autocomplete="${autocompleteValue}" .modelValue="${'Chard'}" name="foo">
1003
+ <lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
1004
+ <lion-option .choiceValue="${'Chard'}">Chard</lion-option>
1005
+ <lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
1006
+ <lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
1007
+ </lion-combobox>
1008
+ `)
1009
+ );
1010
+
1011
+ expect(el.modelValue).to.equal('Chard');
1012
+ // @ts-expect-error
1013
+ expect(el._inputNode.value).to.equal('Chard');
1014
+ }
1015
+ });
1016
+
989
1017
  it('submits form on [Enter] when listbox is closed', async () => {
990
1018
  const submitSpy = sinon.spy(e => e.preventDefault());
991
1019
  const el = /** @type {HTMLFormElement} */ (
@@ -2312,8 +2340,7 @@ describe('lion-combobox', () => {
2312
2340
  </${tag}>
2313
2341
  `)
2314
2342
  );
2315
- // This ensures autocomplete would be off originally
2316
- el.autocomplete = 'list';
2343
+
2317
2344
  await mimicUserTypingAdvanced(el, ['v', 'i']); // so we have options ['Victoria Plum']
2318
2345
  await el.updateComplete;
2319
2346
  expect(el.checkedIndex).to.equal(3);
@@ -2440,23 +2467,26 @@ describe('lion-combobox', () => {
2440
2467
  expect(_inputNode.value).to.equal('');
2441
2468
 
2442
2469
  el.setCheckedIndex(-1);
2443
- el.autocomplete = 'none';
2444
- el.setCheckedIndex(0);
2445
- expect(_inputNode.value).to.equal('');
2470
+ el.autocomplete = 'list';
2471
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2472
+ expect(_inputNode.value).to.equal('art');
2446
2473
 
2447
2474
  el.setCheckedIndex(-1);
2448
- el.autocomplete = 'list';
2449
- el.setCheckedIndex(0);
2450
- expect(_inputNode.value).to.equal('');
2475
+ el.value = '';
2476
+ el.autocomplete = 'none';
2477
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2478
+ expect(_inputNode.value).to.equal('art');
2451
2479
 
2452
2480
  el.setCheckedIndex(-1);
2481
+ el.value = '';
2453
2482
  el.autocomplete = 'inline';
2454
- el.setCheckedIndex(0);
2483
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2455
2484
  expect(_inputNode.value).to.equal('Artichoke');
2456
2485
 
2457
2486
  el.setCheckedIndex(-1);
2487
+ el.value = '';
2458
2488
  el.autocomplete = 'both';
2459
- el.setCheckedIndex(0);
2489
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2460
2490
  expect(_inputNode.value).to.equal('Artichoke');
2461
2491
  });
2462
2492
 
@@ -2476,30 +2506,28 @@ describe('lion-combobox', () => {
2476
2506
  expect(_inputNode.value).to.eql('');
2477
2507
 
2478
2508
  el.setCheckedIndex(-1);
2479
- el.autocomplete = 'none';
2480
- el.setCheckedIndex([0]);
2481
- el.setCheckedIndex([1]);
2482
- expect(_inputNode.value).to.equal('');
2483
-
2484
2509
  el.setCheckedIndex(-1);
2485
2510
  el.autocomplete = 'list';
2486
- el.setCheckedIndex([0]);
2487
- el.setCheckedIndex([1]);
2488
- expect(_inputNode.value).to.equal('');
2511
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2512
+ expect(_inputNode.value).to.equal('art');
2513
+
2514
+ el.setCheckedIndex(-1);
2515
+ el.value = '';
2516
+ el.autocomplete = 'none';
2517
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2518
+ expect(_inputNode.value).to.equal('art');
2489
2519
 
2490
2520
  el.setCheckedIndex(-1);
2521
+ el.value = '';
2491
2522
  el.autocomplete = 'inline';
2492
- el.setCheckedIndex([0]);
2523
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2493
2524
  expect(_inputNode.value).to.equal('Artichoke');
2494
- el.setCheckedIndex([1]);
2495
- expect(_inputNode.value).to.equal('Chard');
2496
2525
 
2497
2526
  el.setCheckedIndex(-1);
2527
+ el.value = '';
2498
2528
  el.autocomplete = 'both';
2499
- el.setCheckedIndex([0]);
2529
+ await mimicUserTypingAdvanced(el, ['a', 'r', 't']);
2500
2530
  expect(_inputNode.value).to.equal('Artichoke');
2501
- el.setCheckedIndex([1]);
2502
- expect(_inputNode.value).to.equal('Chard');
2503
2531
  });
2504
2532
 
2505
2533
  describe('Subclassers', () => {
@@ -2748,12 +2776,6 @@ describe('lion-combobox', () => {
2748
2776
  async function performChecks(autocompleteMode) {
2749
2777
  await el.updateComplete;
2750
2778
 
2751
- el.formElements[0].click();
2752
-
2753
- // FIXME: fix properly for Webkit
2754
- // expect(_inputNode.value).to.equal('Aha', autocompleteMode);
2755
- expect(el.checkedIndex).to.equal(0, autocompleteMode);
2756
-
2757
2779
  await mimicUserTypingAdvanced(el, ['A', 'r', 't', 'i']);
2758
2780
  await el.updateComplete;
2759
2781
  expect(_inputNode.value).to.equal('Arti', `autocompleteMode is ${autocompleteMode}`);
@@ -3431,4 +3453,131 @@ describe('lion-combobox', () => {
3431
3453
  });
3432
3454
  });
3433
3455
  });
3456
+
3457
+ describe('Disabled state', () => {
3458
+ it('does not open overlay or allow input when disabled', async () => {
3459
+ const [el] = await fruitFixture({ disabled: true });
3460
+ expect(el.disabled).to.equal(true);
3461
+
3462
+ const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
3463
+
3464
+ expect(el.disabled).to.be.true;
3465
+ expect(_inputNode.disabled).to.be.true;
3466
+
3467
+ // Try to open overlay by clicking input
3468
+ _inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
3469
+ await el.updateComplete;
3470
+ expect(el.opened).to.be.false;
3471
+ });
3472
+
3473
+ it('does open overlay or allow input when disabled state is removed after it was previously disabled', async () => {
3474
+ const [el] = await fruitFixture({ disabled: true });
3475
+ expect(el.disabled).to.equal(true);
3476
+
3477
+ const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
3478
+
3479
+ expect(el.disabled).to.be.true;
3480
+ expect(_inputNode.disabled).to.be.true;
3481
+
3482
+ // Try to open overlay by clicking input
3483
+ _inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
3484
+ await el.updateComplete;
3485
+ expect(el.opened).to.be.false;
3486
+
3487
+ el.disabled = false;
3488
+ await el.updateComplete;
3489
+
3490
+ expect(el.disabled).to.be.false;
3491
+ expect(_inputNode.disabled).to.be.false;
3492
+
3493
+ // Try to open overlay by clicking input
3494
+ async function open() {
3495
+ await mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
3496
+ return el.updateComplete;
3497
+ }
3498
+
3499
+ await open();
3500
+ expect(el.opened).to.be.true;
3501
+ });
3502
+
3503
+ it('sets aria-disabled and disables focus when combobox is disabled', async () => {
3504
+ const [el] = await fruitFixture({ disabled: true });
3505
+ const { _comboboxNode, _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
3506
+
3507
+ // input should not be focusable
3508
+ expect(_comboboxNode.tabIndex).to.equal(-1);
3509
+ expect(_inputNode.tabIndex).to.equal(-1);
3510
+
3511
+ // aria-disabled should be set
3512
+ expect(el.getAttribute('aria-disabled')).to.equal('true');
3513
+
3514
+ // aria-disabled should be set
3515
+ expect(_inputNode.getAttribute('aria-disabled')).to.equal('true');
3516
+
3517
+ await expect(el).to.be.accessible();
3518
+ });
3519
+
3520
+ it('ensure focus when combobox is readonly', async () => {
3521
+ const [el] = await fruitFixture({ readonly: true });
3522
+ const { _comboboxNode, _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
3523
+
3524
+ // input should be focusable
3525
+ expect(_inputNode.tabIndex).to.equal(0);
3526
+
3527
+ // aria-disabled should be set
3528
+ expect(_comboboxNode.getAttribute('aria-readonly')).to.equal('true');
3529
+
3530
+ // aria-disabled should be set
3531
+ expect(_inputNode.getAttribute('aria-readonly')).to.equal('true');
3532
+
3533
+ await expect(el).to.be.accessible();
3534
+ });
3535
+ });
3536
+
3537
+ describe('Readonly state', () => {
3538
+ it('does not open overlay or allow input when readonly', async () => {
3539
+ const [el] = await fruitFixture({ readonly: true });
3540
+
3541
+ const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
3542
+ expect(el.readOnly).to.equal(true);
3543
+
3544
+ expect(el.readOnly).to.be.true;
3545
+ expect(_inputNode.readOnly).to.be.true;
3546
+
3547
+ // Try to open overlay by clicking input
3548
+ _inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
3549
+ await el.updateComplete;
3550
+ expect(el.opened).to.be.false;
3551
+ });
3552
+
3553
+ it('does open overlay or allow input when readonly state is removed after it was previously set', async () => {
3554
+ const [el] = await fruitFixture({ readonly: true });
3555
+ expect(el.readOnly).to.equal(true);
3556
+
3557
+ const { _inputNode } = getComboboxMembers(/** @type {LionCombobox} */ (el));
3558
+
3559
+ expect(el.readOnly).to.be.true;
3560
+ expect(_inputNode.readOnly).to.be.true;
3561
+
3562
+ // Try to open overlay by clicking input
3563
+ _inputNode.dispatchEvent(new Event('click', { bubbles: true, composed: true }));
3564
+ await el.updateComplete;
3565
+ expect(el.opened).to.be.false;
3566
+
3567
+ el.readOnly = false;
3568
+ await el.updateComplete;
3569
+
3570
+ expect(el.readOnly).to.be.false;
3571
+ expect(_inputNode.readOnly).to.be.false;
3572
+
3573
+ // Try to open overlay by clicking input
3574
+ async function open() {
3575
+ await mimicUserTyping(/** @type {LionCombobox} */ (el), 'ch');
3576
+ return el.updateComplete;
3577
+ }
3578
+
3579
+ await open();
3580
+ expect(el.opened).to.be.true;
3581
+ });
3582
+ });
3434
3583
  });
@@ -198,6 +198,7 @@ const FocusMixinImplementation = superclass =>
198
198
  * @private
199
199
  */
200
200
  __teardownEventsForFocusMixin() {
201
+ if (!this._focusableNode) return;
201
202
  this._focusableNode?.removeEventListener(
202
203
  'focus',
203
204
  /** @type {EventListenerOrEventListenerObject} */ (this.__redispatchFocus),
@@ -52,7 +52,7 @@ const FormControlMixinImplementation = superclass =>
52
52
  * @type {string}
53
53
  */
54
54
  get label() {
55
- return this.__label || (this._labelNode && this._labelNode.textContent) || '';
55
+ return this.__label ?? (this._labelNode?.textContent || '');
56
56
  }
57
57
 
58
58
  /**
@@ -71,7 +71,7 @@ const FormControlMixinImplementation = superclass =>
71
71
  * @type {string}
72
72
  */
73
73
  get helpText() {
74
- return this.__helpText || (this._helpTextNode && this._helpTextNode.textContent) || '';
74
+ return this.__helpText ?? (this._helpTextNode?.textContent || '');
75
75
  }
76
76
 
77
77
  /**
@@ -158,6 +158,21 @@ const FormControlMixinImplementation = superclass =>
158
158
  */
159
159
  static enabledWarnings = super.enabledWarnings?.filter(w => w !== 'change-in-update') || [];
160
160
 
161
+ // N.B. add these label/helpText props for types + ce manifest output
162
+ // (explicity setting them to undefined does not have desired result)
163
+
164
+ /**
165
+ * The label text for the input node.
166
+ * When no value is defined, textContent of [slot=label] will be used
167
+ * @property {string} label
168
+ */
169
+
170
+ /**
171
+ * The helpt text for the input node.
172
+ * When no value is defined, textContent of [slot=help-text] will be used
173
+ * @property {string} helpText
174
+ */
175
+
161
176
  constructor() {
162
177
  super();
163
178
 
@@ -171,26 +186,12 @@ const FormControlMixinImplementation = superclass =>
171
186
  */
172
187
  this.readOnly = false;
173
188
 
174
- /**
175
- * The label text for the input node.
176
- * When no value is defined, textContent of [slot=label] will be used
177
- * @type {string}
178
- */
179
- this.label = '';
180
-
181
189
  /**
182
190
  * The label will only be visible for srceen readers when true
183
191
  * @type {boolean}
184
192
  */
185
193
  this.labelSrOnly = false;
186
194
 
187
- /**
188
- * The helpt text for the input node.
189
- * When no value is defined, textContent of [slot=help-text] will be used
190
- * @type {string}
191
- */
192
- this.helpText = '';
193
-
194
195
  /**
195
196
  * The model value is the result of the parser function(when available).
196
197
  * It should be considered as the internal value used for validation and reasoning/logic.
@@ -293,11 +294,15 @@ const FormControlMixinImplementation = superclass =>
293
294
  );
294
295
  }
295
296
 
296
- if (changedProperties.has('label') && this.__label && this._labelNode) {
297
+ if (changedProperties.has('label') && this.__label !== undefined && this._labelNode) {
297
298
  this._labelNode.textContent = this.label;
298
299
  }
299
300
 
300
- if (changedProperties.has('helpText') && this.__helpText && this._helpTextNode) {
301
+ if (
302
+ changedProperties.has('helpText') &&
303
+ this.__helpText !== undefined &&
304
+ this._helpTextNode
305
+ ) {
301
306
  this._helpTextNode.textContent = this.helpText;
302
307
  }
303
308
 
@@ -420,6 +420,22 @@ export const ValidateMixinImplementation = superclass =>
420
420
  await this.__executeValidators();
421
421
  }
422
422
 
423
+ /**
424
+ * @param {Validator} validator
425
+ * @returns {boolean}
426
+ */
427
+ #isInstanceOfRequiredValidator(validator) {
428
+ // walk prototype and check if validatorName is 'Required'
429
+ let validatorLvlInPrototype = validator;
430
+ while (validatorLvlInPrototype) {
431
+ const ctor = /** @type {typeof Validator} */ (validatorLvlInPrototype.constructor);
432
+ if (ctor.validatorName === 'Required') return true;
433
+
434
+ validatorLvlInPrototype = Object.getPrototypeOf(validatorLvlInPrototype);
435
+ }
436
+ return false;
437
+ }
438
+
423
439
  /**
424
440
  * @desc step a1-3 + b (as explained in `validate()`)
425
441
  */
@@ -482,7 +498,7 @@ export const ValidateMixinImplementation = superclass =>
482
498
  for (const v of this._allValidators) {
483
499
  if (/** @type {MetaValidator} */ (v)?.executeOnResults) {
484
500
  metaValidators.push(/** @type {MetaValidator} */ (v));
485
- } else if (/** @type {typeof Validator} */ (v.constructor)?.validatorName === 'Required') {
501
+ } else if (this.#isInstanceOfRequiredValidator(v)) {
486
502
  // Required validator was already handled
487
503
  } else if (/** @type {typeof Validator} */ (v.constructor).async) {
488
504
  asyncValidators.push(v);
@@ -86,6 +86,40 @@ describe('FormControlMixin', () => {
86
86
  expect(el.label).to.equal('');
87
87
  });
88
88
 
89
+ it('supports empty string as label', async () => {
90
+ const el = /** @type {FormControlMixinClass} */ (
91
+ await fixture(html`
92
+ <${tag} label="With Value">${inputSlot}</${tag}>
93
+ `)
94
+ );
95
+
96
+ expect(el.label).to.equal('With Value');
97
+ // @ts-expect-error
98
+ expect(el._labelNode.innerHTML).to.equal('With Value');
99
+
100
+ el.label = '';
101
+ await el.updateComplete;
102
+
103
+ expect(el.label).to.equal('');
104
+ // @ts-expect-error
105
+ expect(el._labelNode.innerHTML).to.equal('');
106
+
107
+ // Reset
108
+ el.label = 'With Value';
109
+ await el.updateComplete;
110
+
111
+ expect(el.label).to.equal('With Value');
112
+ // @ts-expect-error
113
+ expect(el._labelNode.innerHTML).to.equal('With Value');
114
+
115
+ el.setAttribute('label', '');
116
+ await el.updateComplete;
117
+
118
+ expect(el.label).to.equal('');
119
+ // @ts-expect-error
120
+ expect(el._labelNode.innerHTML).to.equal('');
121
+ });
122
+
89
123
  /**
90
124
  * N.B. For platform controls, the same would be achieved with <input aria-label="My label">
91
125
  * However, since FormControl is usually not the activeElement (_inputNode is), this
@@ -176,6 +210,40 @@ describe('FormControlMixin', () => {
176
210
  );
177
211
  expect(el.helpText).to.equal('');
178
212
  });
213
+
214
+ it('supports empty string as help-text', async () => {
215
+ const el = /** @type {FormControlMixinClass} */ (
216
+ await fixture(html`
217
+ <${tag} help-text="With Value">${inputSlot}</${tag}>
218
+ `)
219
+ );
220
+
221
+ expect(el.helpText).to.equal('With Value');
222
+ // @ts-expect-error
223
+ expect(el._helpTextNode.innerHTML).to.equal('With Value');
224
+
225
+ el.helpText = '';
226
+ await el.updateComplete;
227
+
228
+ expect(el.helpText).to.equal('');
229
+ // @ts-expect-error
230
+ expect(el._helpTextNode.innerHTML).to.equal('');
231
+
232
+ // Reset
233
+ el.helpText = 'With Value';
234
+ await el.updateComplete;
235
+
236
+ expect(el.helpText).to.equal('With Value');
237
+ // @ts-expect-error
238
+ expect(el._helpTextNode.innerHTML).to.equal('With Value');
239
+
240
+ el.setAttribute('help-text', '');
241
+ await el.updateComplete;
242
+
243
+ expect(el.helpText).to.equal('');
244
+ // @ts-expect-error
245
+ expect(el._helpTextNode.innerHTML).to.equal('');
246
+ });
179
247
  });
180
248
 
181
249
  describe('Accessibility', () => {
@@ -130,6 +130,7 @@ export function runValidateMixinSuite(customConfig) {
130
130
  expect(el.hasFeedbackFor).to.deep.equal(['error']);
131
131
  });
132
132
 
133
+ // TODO: keep this use case for backwards compatibility or just required extending Required?
133
134
  it('determines whether the "Required" validator was already handled by judging the validatorName', async () => {
134
135
  class BundledValidator extends EventTarget {
135
136
  static ['_$isValidator$'] = true;
@@ -163,9 +164,7 @@ export function runValidateMixinSuite(customConfig) {
163
164
  }
164
165
 
165
166
  class BundledRequired extends BundledValidator {
166
- static get validatorName() {
167
- return 'Required';
168
- }
167
+ static validatorName = 'Required';
169
168
  }
170
169
 
171
170
  const el = /** @type {ValidateElement} */ (
@@ -180,6 +179,23 @@ export function runValidateMixinSuite(customConfig) {
180
179
  expect(el.hasFeedbackFor).to.deep.equal([]);
181
180
  });
182
181
 
182
+ it('determines whether the "Required" validator was already handled by judging the validatorName', async () => {
183
+ class MyRequired extends Required {
184
+ static validatorName = 'SomethingOtherThanRequired';
185
+ }
186
+
187
+ const el = /** @type {ValidateElement} */ (
188
+ await fixture(html`
189
+ <${tag}
190
+ .validators=${[new MyRequired()]}
191
+ .modelValue=${'myValue'}
192
+ >${lightDom}</${tag}>
193
+ `)
194
+ );
195
+
196
+ expect(el.hasFeedbackFor).to.deep.equal([]);
197
+ });
198
+
183
199
  it('determines whether the passed Validators are ResultValidators judging by the presence of "executeOnResults"', async () => {
184
200
  class ValidateElementWithSuccessType extends ValidateElement {
185
201
  static get validationTypes() {