feat(combobox): option showAllOnEmpty

This commit is contained in:
Thijs Louisse 2020-10-06 17:53:07 +02:00 committed by Thomas Allmer
parent 942ba65d8d
commit 928a673a2f
5 changed files with 153 additions and 50 deletions

View 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

View file

@ -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>`).

View file

@ -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');

View file

@ -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`

View file

@ -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;