From 928a673a2fdadb058b13ca402389715159b1120b Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Tue, 6 Oct 2020 17:53:07 +0200 Subject: [PATCH] feat(combobox): option showAllOnEmpty --- .changeset/shaggy-pans-kiss.md | 5 ++ packages/combobox/README.md | 19 ++++- packages/combobox/src/LionCombobox.js | 75 +++++++++++-------- packages/combobox/test/lion-combobox.test.js | 69 +++++++++++++++++ .../listbox/test-suites/ListboxMixin.suite.js | 35 +++++---- 5 files changed, 153 insertions(+), 50 deletions(-) create mode 100644 .changeset/shaggy-pans-kiss.md diff --git a/.changeset/shaggy-pans-kiss.md b/.changeset/shaggy-pans-kiss.md new file mode 100644 index 000000000..16519dbf2 --- /dev/null +++ b/.changeset/shaggy-pans-kiss.md @@ -0,0 +1,5 @@ +--- +'@lion/combobox': patch +--- + +Add a new option showAllOnEmpty which shows the full list if the input has an empty value diff --git a/packages/combobox/README.md b/packages/combobox/README.md index 273510ae9..b5210901b 100644 --- a/packages/combobox/README.md +++ b/packages/combobox/README.md @@ -160,7 +160,24 @@ export const customMatchCondition = () => html` `; ``` -## Changing defaults +## Options + +```js preview-story +export const showAllOnEmpty = () => html` + + ${lazyRender( + listboxData.map(entry => html` ${entry} `), + )} + +`; +``` + +### Changing defaults By default `selection-follows-focus` will be true (aligned with the wai-aria examples and the natve ``). diff --git a/packages/combobox/src/LionCombobox.js b/packages/combobox/src/LionCombobox.js index 4faff9625..3447caf76 100644 --- a/packages/combobox/src/LionCombobox.js +++ b/packages/combobox/src/LionCombobox.js @@ -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` - @@ -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 + */ + this.showAllOnEmpty = false; + /** * @configure ListboxMixin: the wai-aria pattern and 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'); diff --git a/packages/combobox/test/lion-combobox.test.js b/packages/combobox/test/lion-combobox.test.js index e46bf7f02..fd487f1c9 100644 --- a/packages/combobox/test/lion-combobox.test.js +++ b/packages/combobox/test/lion-combobox.test.js @@ -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` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + 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` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + 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` diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js index 02536d192..2daadf1d7 100644 --- a/packages/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/listbox/test-suites/ListboxMixin.suite.js @@ -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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 - `); - 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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 - `); + `)); 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} .choiceValue=${'Item 2'}>Item 2 <${optionTag} .choiceValue=${'Item 3'}>Item 3 - `); + `)); // 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} .choiceValue="${'Chard'}">Chard @@ -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} .choiceValue="${'Chard'}">Chard @@ -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} .choiceValue=${20}>Item 2 <${optionTag} .choiceValue=${30}>Item 3 - `); + `)); const options = Array.from(el.querySelectorAll('lion-option')); // Normalize start values between listbox, slect and combobox and test interaction below el.activeIndex = 0;