Skip to content

Commit c29663e

Browse files
committed
feat: merge CustomChoiceGroupMixin with ChoiceGroupMixin; enhance code readibility
1 parent 64a1b65 commit c29663e

21 files changed

+835
-945
lines changed

.changeset/thirty-frogs-refuse.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@lion/ui': patch
3+
---
4+
5+
merge CustomChoiceGroupMixin functionality into ChoiceGroupMixin

docs/components/combobox/use-cases.md

+18-20
Original file line numberDiff line numberDiff line change
@@ -26,22 +26,6 @@ import { loadDefaultFeedbackMessages } from '@lion/ui/validate-messages.js';
2626
loadDefaultFeedbackMessages();
2727
```
2828

29-
## Require option match
30-
31-
By default `requireOptionMatch` is set to true, which means that the listbox is leading. The textbox is a helping aid to quickly select an option/options. Unmatching input values become Unparseable, with the `MatchesOption` set as a default validator.
32-
33-
When `requireOptionMatch` is set to false the textbox is leading, with the listbox as an aid to supply suggestions, e.g. a search input. This means that all input values are allowed.
34-
35-
```js preview-story
36-
export const optionMatch = () => html`
37-
<lion-combobox name="search" label="Search" .requireOptionMatch=${false}>
38-
${lazyRender(
39-
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
40-
)}
41-
</lion-combobox>
42-
`;
43-
```
44-
4529
## Autocomplete
4630

4731
Below you will find an overview of all possible `autocomplete` behaviors and how they correspond
@@ -257,7 +241,7 @@ Alternatively, the multi-choice flag can be combined with .requireMultipleMatch=
257241
258242
```js preview-story
259243
export const multipleCustomizableChoice = () => html`
260-
<lion-combobox name="combo" label="Multiple" .requireOptionMatch=${false} multiple-choice>
244+
<lion-combobox name="combo" label="Multiple" allow-custom-choice multiple-choice>
261245
<demo-selection-display
262246
slot="selection-display"
263247
style="display: contents;"
@@ -272,11 +256,25 @@ export const multipleCustomizableChoice = () => html`
272256
`;
273257
```
274258

275-
## Validation
259+
## Allow custom choice
260+
261+
By default `allow-custom-choice` is set to false.
262+
This means that the textbox value must correspond with one of the options in the listbox.
263+
When set to true, the textbox value will be leading.
264+
265+
```js preview-story
266+
export const optionMatch = () => html`
267+
<lion-combobox name="search" label="Search" allow-custom-choice>
268+
${lazyRender(
269+
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
270+
)}
271+
</lion-combobox>
272+
`;
273+
```
276274

277-
The combobox works with a `Required` validator to check if it is empty.
275+
## Validation
278276

279-
By default the a check is made which makes sure the value matches an option. This only works if `requireOptionMatch` is set to true.
277+
The combobox works with a `Required` validator to check if it is empty. By default a check is made which makes sure the value matches an option.
280278

281279
```js preview-story
282280
export const validation = () => html`

packages/ui/components/combobox/src/LionCombobox.js

+34-69
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { LocalizeMixin } from '@lion/ui/localize-no-side-effects.js';
55
import { OverlayMixin, withDropdownConfig } from '@lion/ui/overlays.js';
66
import { css, html } from 'lit';
77
import { makeMatchingTextBold, unmakeMatchingTextBold } from './utils/makeMatchingTextBold.js';
8+
import {
9+
fixOptionA11yForSafari,
10+
cleanupOptionA11yForSafari,
11+
} from './utils/fixOptionA11yForSafari.js';
812
import { MatchesOption } from './validators.js';
9-
import { CustomChoiceGroupMixin } from '../../form-core/src/choice-group/CustomChoiceGroupMixin.js';
10-
11-
const matchA11ySpanReverseFns = new WeakMap();
1213

1314
// TODO: make ListboxOverlayMixin that is shared between SelectRich and Combobox
1415
// TODO: extract option matching based on 'typed character cache' and share that logic
@@ -28,29 +29,17 @@ const matchA11ySpanReverseFns = new WeakMap();
2829
* LionCombobox: implements the wai-aria combobox design pattern and integrates it as a Lion
2930
* FormControl
3031
*/
31-
export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMixin(LionListbox))) {
32+
export class LionCombobox extends LocalizeMixin(OverlayMixin(LionListbox)) {
3233
/** @type {any} */
33-
static get properties() {
34-
return {
35-
autocomplete: { type: String, reflect: true },
36-
matchMode: {
37-
type: String,
38-
attribute: 'match-mode',
39-
},
40-
showAllOnEmpty: {
41-
type: Boolean,
42-
attribute: 'show-all-on-empty',
43-
},
44-
requireOptionMatch: {
45-
type: Boolean,
46-
},
47-
allowCustomChoice: {
48-
type: Boolean,
49-
attribute: 'allow-custom-choice',
50-
},
51-
__shouldAutocompleteNextUpdate: Boolean,
52-
};
53-
}
34+
static properties = {
35+
autocomplete: { type: String, reflect: true },
36+
matchMode: { type: String, attribute: 'match-mode' },
37+
showAllOnEmpty: { type: Boolean, attribute: 'show-all-on-empty' },
38+
// N.B.: deprecated: use allowCustomChoice instead
39+
requireOptionMatch: { type: Boolean },
40+
allowCustomChoice: { type: Boolean, attribute: 'allow-custom-choice' },
41+
__shouldAutocompleteNextUpdate: { type: Boolean, state: true },
42+
};
5443

5544
static get styles() {
5645
return [
@@ -358,7 +347,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
358347
*/
359348
get _listboxNode() {
360349
return /** @type {LionOptions} */ (
361-
(this._overlayCtrl && this._overlayCtrl.contentNode) ||
350+
this._overlayCtrl?.contentNode ||
362351
Array.from(this.children).find(child => child.slot === 'listbox')
363352
);
364353
}
@@ -372,7 +361,8 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
372361
}
373362

374363
/**
375-
* @returns {boolean}
364+
* @type {boolean}
365+
* @deprecated
376366
*/
377367
get requireOptionMatch() {
378368
return !this.allowCustomChoice;
@@ -407,11 +397,6 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
407397
* By default, the listbox closes on empty, similar to wai-aria example and <datalist>
408398
*/
409399
this.showAllOnEmpty = false;
410-
/**
411-
* If set to false, the value is allowed to not match any of the options.
412-
* We set the default to true for backwards compatibility
413-
*/
414-
this.requireOptionMatch = true;
415400
/**
416401
* @configure ListboxMixin: the wai-aria pattern and <datalist> rotate
417402
*/
@@ -490,7 +475,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
490475
}
491476
if (name === 'modelValue' && this.modelValue && this.modelValue !== oldValue) {
492477
if (this._syncToTextboxCondition(this.modelValue, this._oldModelValue)) {
493-
if (!this.multipleChoice) {
478+
if (this._isSingleChoice) {
494479
const textboxValue = this._getTextboxValueFromOption(
495480
this.formElements[/** @type {number} */ (this.checkedIndex)],
496481
);
@@ -507,13 +492,13 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
507492

508493
/**
509494
* Converts viewValue to modelValue
510-
* @override CustomChoiceGroupMixin
495+
* @override ChoiceGroupMixin
511496
* @param {string|string[]} value - viewValue: the formatted value inside <input>
512-
* @returns {*} modelValue
497+
* @returns {any} modelValue
513498
*/
514499
parser(value) {
515500
if (
516-
this.requireOptionMatch &&
501+
!this.allowCustomChoice &&
517502
this.checkedIndex === -1 &&
518503
value !== '' &&
519504
!Array.isArray(value)
@@ -525,7 +510,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
525510

526511
/**
527512
* When textbox value doesn't match checkedIndex anymore, update accordingly...
528-
* @protected
513+
* @private
529514
*/
530515
__unsyncCheckedIndexOnInputChange() {
531516
const autoselect = this._autoSelectCondition();
@@ -782,25 +767,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
782767
// eslint-disable-next-line class-methods-use-this
783768
_highlightMatchedOption(option, matchingString) {
784769
makeMatchingTextBold(option, matchingString);
785-
786-
// For Safari, we need to add a label to the element
787-
if (option.textContent) {
788-
const a11ySpan = document.createElement('span');
789-
a11ySpan.setAttribute('aria-label', option.textContent.replace(/\s+/g, ' '));
790-
Array.from(option.childNodes).forEach(childNode => {
791-
a11ySpan.appendChild(childNode);
792-
});
793-
option.appendChild(a11ySpan);
794-
795-
matchA11ySpanReverseFns.set(option, () => {
796-
Array.from(a11ySpan.childNodes).forEach(childNode => {
797-
option.appendChild(childNode);
798-
});
799-
if (option.contains(a11ySpan)) {
800-
option.removeChild(a11ySpan);
801-
}
802-
});
803-
}
770+
fixOptionA11yForSafari(option);
804771
}
805772

806773
/**
@@ -826,10 +793,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
826793
// eslint-disable-next-line class-methods-use-this
827794
_unhighlightMatchedOption(option) {
828795
unmakeMatchingTextBold(option);
829-
830-
if (matchA11ySpanReverseFns.has(option)) {
831-
matchA11ySpanReverseFns.get(option)();
832-
}
796+
cleanupOptionA11yForSafari(option);
833797
}
834798
/* eslint-enable no-param-reassign */
835799

@@ -1095,7 +1059,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
10951059
break;
10961060
case 'Backspace':
10971061
case 'Delete':
1098-
if (this.requireOptionMatch) {
1062+
if (!this.allowCustomChoice) {
10991063
super._listboxOnKeyDown(ev);
11001064
} else {
11011065
this.opened = false;
@@ -1107,7 +1071,7 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
11071071
}
11081072

11091073
if (
1110-
!this.requireOptionMatch &&
1074+
this.allowCustomChoice &&
11111075
this.multipleChoice &&
11121076
(!this.formElements[this.activeIndex] ||
11131077
this.formElements[this.activeIndex].hasAttribute('aria-hidden') ||
@@ -1158,14 +1122,15 @@ export class LionCombobox extends LocalizeMixin(OverlayMixin(CustomChoiceGroupMi
11581122
*/
11591123
// eslint-disable-next-line no-unused-vars
11601124
_syncToTextboxMultiple(modelValue, oldModelValue = []) {
1161-
if (this.requireOptionMatch) {
1162-
const diff = modelValue.filter(x => !oldModelValue.includes(x));
1163-
const newValue = this.formElements
1164-
.filter(option => diff.includes(option.choiceValue))
1165-
.map(option => this._getTextboxValueFromOption(option))
1166-
.join(' ');
1167-
this._setTextboxValue(newValue); // or last selected value?
1125+
if (this.allowCustomChoice) {
1126+
return;
11681127
}
1128+
const diff = modelValue.filter(x => !oldModelValue.includes(x));
1129+
const newValue = this.formElements
1130+
.filter(option => diff.includes(option.choiceValue))
1131+
.map(option => this._getTextboxValueFromOption(option))
1132+
.join(' ');
1133+
this._setTextboxValue(newValue); // or last selected value?
11691134
}
11701135

11711136
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* @typedef {import('@lion/ui/listbox.js').LionOption} LionOption
3+
*/
4+
5+
const matchA11ySpanReverseFns = new WeakMap();
6+
7+
/**
8+
* For Safari, we need to add a label to the element
9+
* Create a wrapping span: => `<my-option><b>my</b> text</my-option>`
10+
* becomes `<my-option><span aria-label="my text"><b>my</b> text</span></my-option>`
11+
* @param {Element} option
12+
*/
13+
export function fixOptionA11yForSafari(option) {
14+
if (!option.textContent) {
15+
return;
16+
}
17+
18+
// [1] Wrap the content in a span with an aria-label
19+
// `<my-option><b>my</b> text</my-option>` =>
20+
// `<my-option><span aria-label="my text"><b>my</b> text</span></my-option>`
21+
const a11ySpan = document.createElement('span');
22+
a11ySpan.setAttribute('aria-label', option.textContent.replace(/\s+/g, ' '));
23+
for (const childNode of Array.from(option.childNodes)) {
24+
a11ySpan.appendChild(childNode);
25+
}
26+
option.appendChild(a11ySpan);
27+
28+
// [2] Store a function to call later, that does:
29+
// `<my-option><span aria-label="my text"><b>my</b> text</span></my-option>` =>
30+
// `<my-option><b>my</b> text</my-option>`
31+
matchA11ySpanReverseFns.set(option, () => {
32+
for (const childNode of Array.from(a11ySpan.childNodes)) {
33+
option.appendChild(childNode);
34+
}
35+
if (option.contains(a11ySpan)) {
36+
option.removeChild(a11ySpan);
37+
}
38+
});
39+
}
40+
41+
/**
42+
* @param {Element} option
43+
*/
44+
export function cleanupOptionA11yForSafari(option) {
45+
if (matchA11ySpanReverseFns.has(option)) {
46+
matchA11ySpanReverseFns.get(option)();
47+
}
48+
matchA11ySpanReverseFns.delete(option);
49+
}
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { runListboxMixinSuite } from '@lion/ui/listbox-test-suites.js';
2+
import { runChoiceGroupMixinSuite } from '@lion/ui/form-core-test-suites.js';
23
import '@lion/ui/define/lion-combobox.js';
3-
import { runCustomChoiceGroupMixinSuite } from '../../form-core/test-suites/choice-group/CustomChoiceGroupMixin.suite.js';
44

55
runListboxMixinSuite({ tagString: 'lion-combobox' });
6-
runCustomChoiceGroupMixinSuite({
6+
runChoiceGroupMixinSuite({
77
parentTagString: 'lion-combobox',
88
childTagString: 'lion-option',
99
});

0 commit comments

Comments
 (0)