feat(combobox): option showAllOnEmpty
This commit is contained in:
parent
942ba65d8d
commit
928a673a2f
5 changed files with 153 additions and 50 deletions
5
.changeset/shaggy-pans-kiss.md
Normal file
5
.changeset/shaggy-pans-kiss.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/combobox': patch
|
||||
---
|
||||
|
||||
Add a new option showAllOnEmpty which shows the full list if the input has an empty value
|
||||
|
|
@ -160,7 +160,24 @@ export const customMatchCondition = () => html`
|
|||
`;
|
||||
```
|
||||
|
||||
## Changing defaults
|
||||
## Options
|
||||
|
||||
```js preview-story
|
||||
export const showAllOnEmpty = () => html`
|
||||
<lion-combobox
|
||||
name="combo"
|
||||
label="Show all on empty"
|
||||
help-text="Shows all (options) on empty (textbox has no value)"
|
||||
show-all-on-empty
|
||||
>
|
||||
${lazyRender(
|
||||
listboxData.map(entry => html` <lion-option .choiceValue="${entry}">${entry}</lion-option> `),
|
||||
)}
|
||||
</lion-combobox>
|
||||
`;
|
||||
```
|
||||
|
||||
### Changing defaults
|
||||
|
||||
By default `selection-follows-focus` will be true (aligned with the
|
||||
wai-aria examples and the natve `<datalist>`).
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
type: String,
|
||||
attribute: 'match-mode',
|
||||
},
|
||||
showAllOnEmpty: {
|
||||
type: Boolean,
|
||||
attribute: 'show-all-on-empty',
|
||||
},
|
||||
__shouldAutocompleteNextUpdate: Boolean,
|
||||
};
|
||||
}
|
||||
|
|
@ -65,7 +69,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @override FormControlMixin
|
||||
* @enhance FormControlMixin - add slot[name=selection-display]
|
||||
*/
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
_inputGroupInputTemplate() {
|
||||
|
|
@ -80,7 +84,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
// eslint-disable-next-line class-methods-use-this
|
||||
_overlayListboxTemplate() {
|
||||
return html`
|
||||
<slot name="_overlay-shadow-outlet"></slot>
|
||||
<div id="overlay-content-node-wrapper" role="dialog">
|
||||
<slot name="listbox"></slot>
|
||||
</div>
|
||||
|
|
@ -121,7 +124,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
outline: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
font-size: inherit;
|
||||
box-sizing: border-box;
|
||||
padding: 0;`;
|
||||
|
||||
|
|
@ -225,6 +228,12 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
*/
|
||||
this.matchMode = 'all';
|
||||
|
||||
/**
|
||||
* When true, the listbox is open and textbox goes from a value to empty, all options are shown.
|
||||
* By default, the listbox closes on empty, similar to wai-aria example and <datalist>
|
||||
*/
|
||||
this.showAllOnEmpty = false;
|
||||
|
||||
/**
|
||||
* @configure ListboxMixin: the wai-aria pattern and <datalist> rotate
|
||||
*/
|
||||
|
|
@ -288,7 +297,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
if (changedProperties.has('opened')) {
|
||||
if (this.opened) {
|
||||
// Note we always start with -1 as a 'fundament'
|
||||
// For [autocomplete="inline|both"] activeIndex might be changed by
|
||||
// For [autocomplete="inline|both"] activeIndex might be changed by a match
|
||||
this.activeIndex = -1;
|
||||
}
|
||||
|
||||
|
|
@ -342,7 +351,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
_textboxOnInput(ev) {
|
||||
// this.__cboxInputValue = /** @type {LionOption} */ (ev.target).value;
|
||||
// Schedules autocompletion of options
|
||||
this.__shouldAutocompleteNextUpdate = true;
|
||||
}
|
||||
|
|
@ -427,8 +435,8 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @overridable whether a user int
|
||||
* Computes whether a user intends to autofill (inline autocomplete textbox)
|
||||
* @overridable
|
||||
*/
|
||||
_computeUserIntendsAutoFill({ prevValue, curValue }) {
|
||||
const userIsAddingChars = prevValue.length < curValue.length;
|
||||
|
|
@ -445,12 +453,16 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
* Matches visibility of listbox options against current ._inputNode contents
|
||||
*/
|
||||
_handleAutocompletion() {
|
||||
// TODO: this is captured by 'noFilter'
|
||||
// It should be removed and failing tests should be fixed. Currently, this line causes
|
||||
// an empty box to keep showing its options when autocomplete is 'none'.
|
||||
if (this.autocomplete === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
const curValue = this._inputNode.value;
|
||||
const prevValue = this.__hasSelection ? this.__prevCboxValueNonSelected : this.__prevCboxValue;
|
||||
const isEmpty = !curValue;
|
||||
|
||||
/**
|
||||
* The filtered list of options that will match in this autocompletion cycle
|
||||
|
|
@ -459,21 +471,26 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
const visibleOptions = [];
|
||||
let hasAutoFilled = false;
|
||||
const userIntendsAutoFill = this._computeUserIntendsAutoFill({ prevValue, curValue });
|
||||
const isAutoFillCandidate = this.autocomplete === 'both' || this.autocomplete === 'inline';
|
||||
const isCandidate = this.autocomplete === 'both' || this.autocomplete === 'inline';
|
||||
const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none';
|
||||
|
||||
/** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */
|
||||
this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => {
|
||||
const show = this.autocomplete === 'inline' ? true : this.matchCondition(option, curValue);
|
||||
// [1]. Decide whether otion should be shown
|
||||
let show = false;
|
||||
if (isEmpty) {
|
||||
show = this.showAllOnEmpty;
|
||||
} else {
|
||||
show = noFilter ? true : this.matchCondition(option, curValue);
|
||||
}
|
||||
|
||||
// [1]. Synchronize ._inputNode value and active descendant with closest match
|
||||
if (isAutoFillCandidate) {
|
||||
// [2]. Synchronize ._inputNode value and active descendant with closest match
|
||||
if (isCandidate && !hasAutoFilled && show && userIntendsAutoFill && !option.disabled) {
|
||||
const stringValues = typeof option.choiceValue === 'string' && typeof curValue === 'string';
|
||||
const beginsWith =
|
||||
stringValues && option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0;
|
||||
const shouldAutoFill =
|
||||
beginsWith && !hasAutoFilled && show && userIntendsAutoFill && !option.disabled;
|
||||
|
||||
if (shouldAutoFill) {
|
||||
if (beginsWith) {
|
||||
const prevLen = this._inputNode.value.length;
|
||||
this._inputNode.value = option.choiceValue;
|
||||
this._inputNode.selectionStart = prevLen;
|
||||
|
|
@ -486,19 +503,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
}
|
||||
}
|
||||
|
||||
// [2]. Cleanup previous matching states
|
||||
// [3]. Cleanup previous matching states
|
||||
if (option.onFilterUnmatch) {
|
||||
option.onFilterUnmatch(curValue, prevValue);
|
||||
} else {
|
||||
this._onFilterUnmatch(option, curValue, prevValue);
|
||||
}
|
||||
|
||||
// [3]. If ._inputNode is empty, no filtering will be applied
|
||||
if (!curValue) {
|
||||
visibleOptions.push(option);
|
||||
return;
|
||||
}
|
||||
|
||||
// [4]. Cleanup previous visibility and a11y states
|
||||
option.setAttribute('aria-hidden', 'true');
|
||||
option.removeAttribute('aria-posinset');
|
||||
|
|
@ -515,28 +526,30 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
}
|
||||
});
|
||||
|
||||
// [6]. enable a11y, visibility and user interaction for visible options
|
||||
// [6]. Enable a11y, visibility and user interaction for visible options
|
||||
const setSize = visibleOptions.length;
|
||||
visibleOptions.forEach((option, idx) => {
|
||||
option.setAttribute('aria-posinset', `${idx + 1}`);
|
||||
option.setAttribute('aria-setsize', `${setSize}`);
|
||||
option.removeAttribute('aria-hidden');
|
||||
});
|
||||
/** @type {number} */
|
||||
|
||||
// [7]. If no autofill took place, we are left with the previously matched option; correct this
|
||||
if (!hasAutoFilled && isCandidate && !this.multipleChoice) {
|
||||
// This means there is no match for checkedIndex
|
||||
this.checkedIndex = -1;
|
||||
}
|
||||
|
||||
// [8]. These values will help computing autofill intentions next autocomplete cycle
|
||||
this.__prevCboxValueNonSelected = curValue;
|
||||
// See test "computation of "user intends autofill" works correctly afer autofill"
|
||||
// See test 'computation of "user intends autofill" works correctly afer autofill'
|
||||
this.__prevCboxValue = this._inputNode.value;
|
||||
this.__hasSelection = hasAutoFilled;
|
||||
|
||||
// [9]. Reposition overlay
|
||||
if (this._overlayCtrl && this._overlayCtrl._popper) {
|
||||
this._overlayCtrl._popper.update();
|
||||
}
|
||||
|
||||
if (!hasAutoFilled && isAutoFillCandidate && !this.multipleChoice) {
|
||||
// This means there is no match for checkedIndex
|
||||
this.checkedIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -585,6 +598,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
}
|
||||
|
||||
/**
|
||||
* @enhance ListboxMixin
|
||||
* @param {KeyboardEvent} ev
|
||||
*/
|
||||
_listboxOnKeyDown(ev) {
|
||||
|
|
@ -601,7 +615,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
if (!this.formElements[this.activeIndex]) {
|
||||
return;
|
||||
}
|
||||
// this._syncCheckedWithTextboxOnInteraction();
|
||||
if (!this.multipleChoice) {
|
||||
this.opened = false;
|
||||
}
|
||||
|
|
@ -623,7 +636,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) {
|
|||
|
||||
__setupCombobox() {
|
||||
// With regard to accessibility: aria-expanded and -labelledby will
|
||||
// be handled by OverlatMixin and FormControlMixin respectively.
|
||||
// be handled by OverlayMixin and FormControlMixin respectively.
|
||||
|
||||
this._comboboxNode.setAttribute('role', 'combobox');
|
||||
this._comboboxNode.setAttribute('aria-haspopup', 'listbox');
|
||||
|
|
|
|||
|
|
@ -98,6 +98,75 @@ async function fruitFixture({ autocomplete, matchMode } = {}) {
|
|||
}
|
||||
|
||||
describe('lion-combobox', () => {
|
||||
describe('Options', () => {
|
||||
describe('showAllOnEmpty', () => {
|
||||
it('hides options when text in input node is cleared after typing something by default', async () => {
|
||||
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||
<lion-combobox name="foo">
|
||||
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
||||
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
|
||||
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
||||
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
|
||||
</lion-combobox>
|
||||
`));
|
||||
|
||||
const options = el.formElements;
|
||||
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
|
||||
|
||||
async function performChecks() {
|
||||
mimicUserTyping(el, 'c');
|
||||
await el.updateComplete;
|
||||
expect(visibleOptions().length).to.equal(4);
|
||||
mimicUserTyping(el, '');
|
||||
await el.updateComplete;
|
||||
expect(visibleOptions().length).to.equal(0);
|
||||
}
|
||||
|
||||
// FIXME: autocomplete 'none' should have this behavior as well
|
||||
// el.autocomplete = 'none';
|
||||
// await performChecks();
|
||||
el.autocomplete = 'list';
|
||||
await performChecks();
|
||||
el.autocomplete = 'inline';
|
||||
await performChecks();
|
||||
el.autocomplete = 'both';
|
||||
await performChecks();
|
||||
});
|
||||
|
||||
it('keeps showing options when text in input node is cleared after typing something', async () => {
|
||||
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||
<lion-combobox name="foo" autocomplete="list" show-all-on-empty>
|
||||
<lion-option .choiceValue="${'Artichoke'}">Artichoke</lion-option>
|
||||
<lion-option .choiceValue="${'Chard'}">Chard</lion-option>
|
||||
<lion-option .choiceValue="${'Chicory'}">Chicory</lion-option>
|
||||
<lion-option .choiceValue="${'Victoria Plum'}">Victoria Plum</lion-option>
|
||||
</lion-combobox>
|
||||
`));
|
||||
|
||||
const options = el.formElements;
|
||||
const visibleOptions = () => options.filter(o => o.getAttribute('aria-hidden') !== 'true');
|
||||
|
||||
async function performChecks() {
|
||||
mimicUserTyping(el, 'c');
|
||||
await el.updateComplete;
|
||||
expect(visibleOptions().length).to.equal(4);
|
||||
mimicUserTyping(el, '');
|
||||
await el.updateComplete;
|
||||
expect(visibleOptions().length).to.equal(options.length);
|
||||
}
|
||||
|
||||
el.autocomplete = 'none';
|
||||
await performChecks();
|
||||
el.autocomplete = 'list';
|
||||
await performChecks();
|
||||
el.autocomplete = 'inline';
|
||||
await performChecks();
|
||||
el.autocomplete = 'both';
|
||||
await performChecks();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Structure', () => {
|
||||
it('has a listbox node', async () => {
|
||||
const el = /** @type {LionCombobox} */ (await fixture(html`
|
||||
|
|
|
|||
|
|
@ -275,27 +275,26 @@ export function runListboxMixinSuite(customConfig = {}) {
|
|||
});
|
||||
|
||||
it('puts "aria-setsize" on all options to indicate the total amount of options', async () => {
|
||||
const el = await fixture(html`
|
||||
<${tag}>
|
||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
||||
<${tag} autocomplete="none">
|
||||
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
||||
</${tag}>
|
||||
`);
|
||||
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
|
||||
optionEls.forEach(optionEl => {
|
||||
`));
|
||||
el.formElements.forEach(optionEl => {
|
||||
expect(optionEl.getAttribute('aria-setsize')).to.equal('3');
|
||||
});
|
||||
});
|
||||
|
||||
it('puts "aria-posinset" on all options to indicate their position in the listbox', async () => {
|
||||
const el = await fixture(html`
|
||||
<${tag}>
|
||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
||||
<${tag} autocomplete="none">
|
||||
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
||||
</${tag}>
|
||||
`);
|
||||
`));
|
||||
const optionEls = [].slice.call(el.querySelectorAll('lion-option'));
|
||||
optionEls.forEach((oEl, i) => {
|
||||
expect(oEl.getAttribute('aria-posinset')).to.equal(`${i + 1}`);
|
||||
|
|
@ -550,13 +549,13 @@ export function runListboxMixinSuite(customConfig = {}) {
|
|||
expect(el.activeIndex).to.equal(3);
|
||||
});
|
||||
it('navigates through open lists with [ArrowDown] [ArrowUp] keys activates the option', async () => {
|
||||
const el = await fixture(html`
|
||||
<${tag} opened has-no-default-selected>
|
||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
||||
<${tag} opened has-no-default-selected autocomplete="none">
|
||||
<${optionTag} .choiceValue=${'Item 1'}>Item 1</${optionTag}>
|
||||
<${optionTag} .choiceValue=${'Item 2'}>Item 2</${optionTag}>
|
||||
<${optionTag} .choiceValue=${'Item 3'}>Item 3</${optionTag}>
|
||||
</${tag}>
|
||||
`);
|
||||
`));
|
||||
// Normalize across listbox/select-rich/combobox
|
||||
el.activeIndex = 0;
|
||||
// selectionFollowsFocus will be true by default on combobox (running this suite),
|
||||
|
|
@ -575,8 +574,8 @@ export function runListboxMixinSuite(customConfig = {}) {
|
|||
|
||||
describe('Orientation', () => {
|
||||
it('has a default value of "vertical"', async () => {
|
||||
const el = /** @type {Listbox} */ (await fixture(html`
|
||||
<${tag} opened name="foo" autocomplete="list">
|
||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
||||
<${tag} opened name="foo" autocomplete="none">
|
||||
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
||||
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
||||
</${tag}>
|
||||
|
|
@ -610,8 +609,8 @@ export function runListboxMixinSuite(customConfig = {}) {
|
|||
});
|
||||
|
||||
it('uses [ArrowLeft] and [ArrowRight] keys when "horizontal"', async () => {
|
||||
const el = /** @type {Listbox} */ (await fixture(html`
|
||||
<${tag} opened name="foo" orientation="horizontal" autocomplete="list">
|
||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
||||
<${tag} opened name="foo" orientation="horizontal" autocomplete="none">
|
||||
<${optionTag} .choiceValue="${'Artichoke'}">Artichoke</${optionTag}>
|
||||
<${optionTag} .choiceValue="${'Chard'}">Chard</${optionTag}>
|
||||
</${tag}>
|
||||
|
|
@ -755,13 +754,13 @@ export function runListboxMixinSuite(customConfig = {}) {
|
|||
}
|
||||
});
|
||||
}
|
||||
const el = await fixture(html`
|
||||
<${tag} opened selection-follows-focus>
|
||||
const el = /** @type {LionListbox} */ (await fixture(html`
|
||||
<${tag} opened selection-follows-focus autocomplete="none">
|
||||
<${optionTag} .choiceValue=${10}>Item 1</${optionTag}>
|
||||
<${optionTag} .choiceValue=${20}>Item 2</${optionTag}>
|
||||
<${optionTag} .choiceValue=${30}>Item 3</${optionTag}>
|
||||
</${tag}>
|
||||
`);
|
||||
`));
|
||||
const options = Array.from(el.querySelectorAll('lion-option'));
|
||||
// Normalize start values between listbox, slect and combobox and test interaction below
|
||||
el.activeIndex = 0;
|
||||
|
|
|
|||
Loading…
Reference in a new issue