From 8cd22107ea52454426552a3f6b6462b3cf804237 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 28 Oct 2020 11:57:21 +0100 Subject: [PATCH 1/4] fix(listbox): enable deselection on Enter/Space for multiselect --- packages/listbox/src/ListboxMixin.js | 8 ++++++-- .../listbox/test-suites/ListboxMixin.suite.js | 18 +++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/listbox/src/ListboxMixin.js b/packages/listbox/src/ListboxMixin.js index 4fe0ca26f..20f0d370e 100644 --- a/packages/listbox/src/ListboxMixin.js +++ b/packages/listbox/src/ListboxMixin.js @@ -348,7 +348,7 @@ const ListboxMixinImplementation = superclass => this._uncheckChildren(this.formElements.filter(i => i === index)); index.forEach(i => { if (this.formElements[i]) { - this.formElements[i].checked = true; + this.formElements[i].checked = !this.formElements[i].checked; } }); return; @@ -359,7 +359,11 @@ const ListboxMixinImplementation = superclass => this._uncheckChildren(); } if (this.formElements[index]) { - this.formElements[index].checked = true; + if (this.multipleChoice) { + this.formElements[index].checked = !this.formElements[index].checked; + } else { + this.formElements[index].checked = true; + } } } } diff --git a/packages/listbox/test-suites/ListboxMixin.suite.js b/packages/listbox/test-suites/ListboxMixin.suite.js index 59816fd60..7da50a908 100644 --- a/packages/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/listbox/test-suites/ListboxMixin.suite.js @@ -370,7 +370,7 @@ export function runListboxMixinSuite(customConfig = {}) { it('has a reference to the active option', async () => { const el = await fixture(html` - <${tag} opened has-no-default-selected autocomplete="list"> + <${tag} opened has-no-default-selected autocomplete="none"> <${optionTag} .choiceValue=${'10'} id="first">Item 1 <${optionTag} .choiceValue=${'20'} checked id="second">Item 2 @@ -382,10 +382,6 @@ export function runListboxMixinSuite(customConfig = {}) { // Normalize el.activeIndex = 0; - - // el._activeDescendantOwnerNode.dispatchEvent( - // new KeyboardEvent('keydown', { key: 'ArrowDown' }), - // ); await el.updateComplete; expect(activeDescendantOwner.getAttribute('aria-activedescendant')).to.equal('first'); activeDescendantOwner.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); @@ -829,6 +825,10 @@ export function runListboxMixinSuite(customConfig = {}) { options[1].click(); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); + // also deselect + options[1].click(); + expect(options[0].checked).to.equal(true); + expect(el.modelValue).to.eql(['Artichoke']); // Reset // @ts-ignore allow protected members in tests @@ -841,6 +841,10 @@ export function runListboxMixinSuite(customConfig = {}) { listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); + // also deselect + listbox.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(options[0].checked).to.equal(true); + expect(el.modelValue).to.eql(['Artichoke']); // @ts-ignore allow protected if (el._listboxReceivesNoFocus) { @@ -858,6 +862,10 @@ export function runListboxMixinSuite(customConfig = {}) { listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); expect(options[0].checked).to.equal(true); expect(el.modelValue).to.eql(['Artichoke', 'Chard']); + // also deselect + listbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })); + expect(options[0].checked).to.equal(true); + expect(el.modelValue).to.eql(['Artichoke']); }); describe('Accessibility', () => { From 143cdb5ac638dfe92278406ad655aa8209768321 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 28 Oct 2020 12:01:00 +0100 Subject: [PATCH 2/4] feat(combobox): subclasser features and fixes --- packages/combobox/README.md | 2 +- packages/combobox/package.json | 3 +- packages/combobox/src/LionCombobox.js | 228 ++++++++++---- packages/combobox/test/lion-combobox.test.js | 296 ++++++++++++++++--- 4 files changed, 427 insertions(+), 102 deletions(-) diff --git a/packages/combobox/README.md b/packages/combobox/README.md index 583989a55..1d41e8446 100644 --- a/packages/combobox/README.md +++ b/packages/combobox/README.md @@ -67,7 +67,7 @@ to the configurable values `none`, `list`, `inline` and `both`. | | list | filter | focus | check | complete | | -----: | :--: | :----: | :---: | :---: | :------: | | none | ✓ | | | | | -| list | ✓ | ✓ | ✓ | ✓ | | +| list | ✓ | ✓ | | | | | inline | ✓ | | ✓ | ✓ | ✓ | | both | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/packages/combobox/package.json b/packages/combobox/package.json index 98c419e83..ade680fd7 100644 --- a/packages/combobox/package.json +++ b/packages/combobox/package.json @@ -38,7 +38,8 @@ "docs/md-combobox/md-combobox.js", "docs/md-combobox/md-input.js", "docs/md-combobox/style/md-ripple.js", - "docs/md-combobox/style/load-roboto.js" + "docs/md-combobox/style/load-roboto.js", + "docs/google-combobox/google-combobox.js" ], "dependencies": { "@lion/core": "0.13.2", diff --git a/packages/combobox/src/LionCombobox.js b/packages/combobox/src/LionCombobox.js index 06d49e418..d73528d81 100644 --- a/packages/combobox/src/LionCombobox.js +++ b/packages/combobox/src/LionCombobox.js @@ -70,15 +70,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { ]; } - /** - * @enhance FormControlMixin - add form-control to [slot=input] instead of _inputNode - */ - _enhanceLightDomClasses() { - if (this.querySelector('[slot=input]')) { - this.querySelector('[slot=input]').classList.add('form-control'); - } - } - /** * @enhance FormControlMixin - add slot[name=selection-display] */ @@ -104,7 +95,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } /** - * @enhance FormControlMixin + * @enhance FormControlMixin - add overlay */ _groupTwoTemplate() { return html` ${super._groupTwoTemplate()} ${this._overlayListboxTemplate()}`; @@ -274,7 +265,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { this.__prevCboxValue = ''; /** @type {EventListener} */ - this.__showOverlay = this.__showOverlay.bind(this); + this.__requestShowOverlay = this.__requestShowOverlay.bind(this); /** @type {EventListener} */ this._textboxOnInput = this._textboxOnInput.bind(this); /** @type {EventListener} */ @@ -297,9 +288,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) { if (name === 'disabled' || name === 'readOnly') { this.__setComboboxDisabledAndReadOnly(); } - if (name === 'modelValue' && this.modelValue !== oldValue) { - if (this.modelValue) { - this._setTextboxValue(this.modelValue); + if (name === 'modelValue' && this.modelValue && this.modelValue !== oldValue) { + if (this._syncToTextboxCondition(this.modelValue, this.__oldModelValue)) { + if (!this.multipleChoice) { + this._setTextboxValue(this.modelValue); + } else { + this._syncToTextboxMultiple(this.modelValue, this.__oldModelValue); + } } } } @@ -309,6 +304,11 @@ export class LionCombobox extends OverlayMixin(LionListbox) { */ updated(changedProperties) { super.updated(changedProperties); + if (changedProperties.has('focused')) { + if (this.focused) { + this.__requestShowOverlay(); + } + } if (changedProperties.has('opened')) { if (this.opened) { @@ -318,7 +318,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } if (!this.opened && changedProperties.get('opened') !== undefined) { - this._syncCheckedWithTextboxOnInteraction(); + this.__onOverlayClose(); this.activeIndex = -1; } } @@ -362,6 +362,38 @@ export class LionCombobox extends OverlayMixin(LionListbox) { return idx === 0; // matches beginning of value } + /** + * @overridable + * Allows Sub Classer to control when the overlay should become visible + * Note that this condition is separate from whether the option listbox is + * shown (use 'showAllOnEmpty, matchMode and autocomplete configurations for this') + * + * Separating these conditions allows the user to show different content in the dialog/overlay + * that wraps the listbox with options + * + * @example + * _showOverlayCondition(options) { + * return this.focused || super.showOverlayCondition(options); + * } + * + * @example + * _showOverlayCondition({ lastKey }) { + * return lastKey === 'ArrowDown'; + * } + * + * @example + * _showOverlayCondition(options) { + * return options.currentValue.length > 4 && super.showOverlayCondition(options); + * } + * + * @param {{ currentValue: string, lastKey:string }} options + */ + // eslint-disable-next-line class-methods-use-this + _showOverlayCondition({ lastKey }) { + const doNotOpenOn = ['Tab', 'Esc', 'Enter']; + return lastKey && !doNotOpenOn.includes(lastKey); + } + /** * @param {Event} ev */ @@ -378,7 +410,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { if (ev.key === 'Tab') { this.opened = false; } - this.__hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart; } /** @@ -397,33 +428,26 @@ export class LionCombobox extends OverlayMixin(LionListbox) { * @param {string} v */ _setTextboxValue(v) { + // Make sure that we don't loose inputNode.selectionStart and inputNode.selectionEnd if (this._inputNode.value !== v) { this._inputNode.value = v; } } - /** - * For multiple choice, a subclasser could do something like: - * @example - * _syncCheckedWithTextboxOnInteraction() { - * super._syncCheckedWithTextboxOnInteraction(); - * if (this.multipleChoice) { - * this._inputNode.value = this.checkedElements.map(o => o.value).join(', '); - * } - * } - * @overridable - */ - _syncCheckedWithTextboxOnInteraction() { - if (!this.multipleChoice && this._inputNode.value === '') { - this._uncheckChildren(); - } - - if (!this.multipleChoice && this.checkedIndex !== -1) { - this._inputNode.value = this.formElements[/** @type {number} */ (this.checkedIndex)].value; + __onOverlayClose() { + if (!this.multipleChoice) { + if (this.checkedIndex !== -1) { + this._inputNode.value = this.formElements[ + /** @type {number} */ (this.checkedIndex) + ].choiceValue; + } + } else { + this._syncToTextboxMultiple(this.modelValue, this.__oldModelValue); } } /** + * @enhance FormControlMixin * We need to extend the repropagation prevention conditions here. * Usually form groups with single choice will not repropagate model-value-changed of an option upwards * if this option itself is not the checked one. We want to prevent duplicates. However, for combobox @@ -475,24 +499,30 @@ export class LionCombobox extends OverlayMixin(LionListbox) { /** * Computes whether a user intends to autofill (inline autocomplete textbox) - * @overridable * @param {{ prevValue:string, curValue:string }} config */ // eslint-disable-next-line class-methods-use-this - _computeUserIntendsAutoFill({ prevValue, curValue }) { + __computeUserIntendsAutoFill({ prevValue, curValue }) { const userIsAddingChars = prevValue.length < curValue.length; const userStartsNewWord = prevValue.length && curValue.length && prevValue[0].toLowerCase() !== curValue[0].toLowerCase(); - return userIsAddingChars || userStartsNewWord; } /* eslint-enable no-param-reassign, class-methods-use-this */ /** - * Matches visibility of listbox options against current ._inputNode contents + * Handles autocompletion. This entails: + * - list: shows a list on keydown character press + * - filter: filters list of potential matches according to matchmode or provided matchCondition + * - focus: automatically focuses closest match (makes it the activedescendant) + * - check: automatically checks/selects closest match when selection-follows-focus is enabled + * (this is the default configuration) + * - complete: completes the textbox value inline (the 'missing characters' will be added as + * selected text) + * */ _handleAutocompletion() { // TODO: this is captured by 'noFilter' @@ -502,8 +532,13 @@ export class LionCombobox extends OverlayMixin(LionListbox) { return; } + const hasSelection = this._inputNode.value.length !== this._inputNode.selectionStart; + const curValue = this._inputNode.value; - const prevValue = this.__hasSelection ? this.__prevCboxValueNonSelected : this.__prevCboxValue; + const prevValue = + hasSelection || this.__hadSelectionLastAutofill + ? this.__prevCboxValueNonSelected + : this.__prevCboxValue; const isEmpty = !curValue; /** @@ -511,38 +546,55 @@ export class LionCombobox extends OverlayMixin(LionListbox) { * @type {LionOption[]} */ const visibleOptions = []; + /** Whether autofill (activeIndex/checkedIndex and ) has taken place in this 'cycle' */ let hasAutoFilled = false; - const userIntendsAutoFill = this._computeUserIntendsAutoFill({ prevValue, curValue }); - const isCandidate = this.autocomplete === 'both' || this.autocomplete === 'inline'; + const userIntendsInlineAutoFill = this.__computeUserIntendsAutoFill({ prevValue, curValue }); + const isInlineAutoFillCandidate = + this.autocomplete === 'both' || this.autocomplete === 'inline'; + const autoselect = this._autoSelectCondition(); // @ts-ignore this.autocomplete === 'none' needs to be there if statement above is removed const noFilter = this.autocomplete === 'inline' || this.autocomplete === 'none'; /** @typedef {LionOption & { onFilterUnmatch?:function, onFilterMatch?:function }} OptionWithFilterFn */ this.formElements.forEach((/** @type {OptionWithFilterFn} */ option, i) => { - // [1]. Decide whether otion should be shown + // [1]. Decide whether option should be shown + const matches = this.matchCondition(option, curValue); let show = false; if (isEmpty) { show = this.showAllOnEmpty; } else { - show = noFilter ? true : this.matchCondition(option, curValue); + show = noFilter || matches; } // [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; - - if (beginsWith) { - const prevLen = this._inputNode.value.length; - this._inputNode.value = option.choiceValue; - this._inputNode.selectionStart = prevLen; - this._inputNode.selectionEnd = this._inputNode.value.length; + if (autoselect && !hasAutoFilled && matches && !option.disabled) { + const doAutoSelect = () => { this.activeIndex = i; if (this.selectionFollowsFocus && !this.multipleChoice) { this.setCheckedIndex(this.activeIndex); } hasAutoFilled = true; + }; + + if (userIntendsInlineAutoFill) { + // We should never directly select when removing chars or starting a new word + // This leads to bad UX and unwanted syncing of modelValue (based on checkedIndex) + // and _inputNode.value + + if (isInlineAutoFillCandidate) { + const stringValues = + typeof option.choiceValue === 'string' && typeof curValue === 'string'; + const beginsWith = + stringValues && + option.choiceValue.toLowerCase().indexOf(curValue.toLowerCase()) === 0; + // We only can do proper inline autofilling when the beginning of the word matches + if (beginsWith) { + this.__textboxInlineComplete(option); + doAutoSelect(); + } + } else { + doAutoSelect(); + } } } @@ -578,7 +630,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { }); // [7]. If no autofill took place, we are left with the previously matched option; correct this - if (!hasAutoFilled && isCandidate && !this.multipleChoice) { + if (!hasAutoFilled && autoselect && !this.multipleChoice) { // This means there is no match for checkedIndex this.checkedIndex = -1; } @@ -587,7 +639,8 @@ export class LionCombobox extends OverlayMixin(LionListbox) { this.__prevCboxValueNonSelected = curValue; // See test 'computation of "user intends autofill" works correctly afer autofill' this.__prevCboxValue = this._inputNode.value; - this.__hasSelection = hasAutoFilled; + this.__hadSelectionLastAutofill = + this._inputNode.value.length !== this._inputNode.selectionStart; // [9]. Reposition overlay if (this._overlayCtrl && this._overlayCtrl._popper) { @@ -595,6 +648,23 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } } + __textboxInlineComplete(option = this.formElements[this.activeIndex]) { + const prevLen = this._inputNode.value.length; + this._inputNode.value = option.choiceValue; + this._inputNode.selectionStart = prevLen; + this._inputNode.selectionEnd = this._inputNode.value.length; + } + + /** + * When this condition is false, an end user will have to manually select a suggested + * option from the list (by default when autocomplete is 'none' or 'list'). + * For autocomplete 'both' or 'inline', it will automatically select on a match. + * @overridable + */ + _autoSelectCondition() { + return this.autocomplete === 'both' || this.autocomplete === 'inline'; + } + /** * @enhance ListboxMixin */ @@ -629,7 +699,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { */ _setupOpenCloseListeners() { super._setupOpenCloseListeners(); - this._overlayInvokerNode.addEventListener('keydown', this.__showOverlay); + this._inputNode.addEventListener('keydown', this.__requestShowOverlay); } /** @@ -637,7 +707,7 @@ export class LionCombobox extends OverlayMixin(LionListbox) { */ _teardownOpenCloseListeners() { super._teardownOpenCloseListeners(); - this._overlayInvokerNode.removeEventListener('keydown', this.__showOverlay); + this._inputNode.removeEventListener('keydown', this.__requestShowOverlay); } /** @@ -650,7 +720,6 @@ export class LionCombobox extends OverlayMixin(LionListbox) { switch (key) { case 'Escape': this.opened = false; - this.__shouldAutocompleteNextUpdate = true; this._setTextboxValue(''); break; case 'Enter': @@ -665,6 +734,35 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } } + /** + * @param {string|string[]} modelValue + * @param {string|string[]} oldModelValue + */ + // eslint-disable-next-line no-unused-vars + _syncToTextboxCondition(modelValue, oldModelValue) { + return this.autocomplete === 'inline' || this.autocomplete === 'both'; + } + + /** + * @overridable + * Allows to control what happens when checkedIndexes change + * @param {string[]} modelValue + * @param {string[]} oldModelValue + */ + _syncToTextboxMultiple(modelValue, oldModelValue = []) { + const diff = modelValue.filter(x => !oldModelValue.includes(x)); + this._setTextboxValue(diff); // or last selected value? + } + + /** + * @override FormControlMixin - add form-control to [slot=input] instead of _inputNode + */ + _enhanceLightDomClasses() { + if (this.querySelector('[slot=input]')) { + this.querySelector('[slot=input]').classList.add('form-control'); + } + } + __initFilterListbox() { this._handleAutocompletion(); } @@ -705,12 +803,16 @@ export class LionCombobox extends OverlayMixin(LionListbox) { } /** - * @param {KeyboardEvent} ev + * @param {KeyboardEvent} [ev] */ - __showOverlay(ev) { - if (ev.key === 'Tab' || ev.key === 'Esc' || ev.key === 'Enter') { - return; + __requestShowOverlay(ev) { + if ( + this._showOverlayCondition({ + lastKey: ev && ev.key, + currentValue: this._inputNode.value, + }) + ) { + this.opened = true; } - this.opened = true; } } diff --git a/packages/combobox/test/lion-combobox.test.js b/packages/combobox/test/lion-combobox.test.js index 2df02778f..3592dd574 100644 --- a/packages/combobox/test/lion-combobox.test.js +++ b/packages/combobox/test/lion-combobox.test.js @@ -5,6 +5,7 @@ import '../lion-combobox.js'; import { LionOptions } from '@lion/listbox/src/LionOptions.js'; import { browserDetection, LitElement } from '@lion/core'; import { Required } from '@lion/form-core'; +import { LionCombobox } from '../src/LionCombobox.js'; /** * @typedef {import('../src/LionCombobox.js').LionCombobox} LionCombobox @@ -20,7 +21,7 @@ function mimicUserTyping(el, value) { // eslint-disable-next-line no-param-reassign el._inputNode.value = value; el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); - el._overlayInvokerNode.dispatchEvent(new Event('keydown')); + el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: value })); } /** @@ -31,31 +32,32 @@ async function mimicUserTypingAdvanced(el, values) { const inputNode = /** @type {HTMLInputElement & {selectionStart:number, selectionEnd:number}} */ (el._inputNode); inputNode.dispatchEvent(new Event('focusin', { bubbles: true })); - let hasSelection = inputNode.selectionStart !== inputNode.selectionEnd; - for (const key of values) { // eslint-disable-next-line no-await-in-loop, no-loop-func await new Promise(resolve => { - setTimeout(() => { - if (key === 'Backspace') { - if (hasSelection) { - inputNode.value = - inputNode.value.slice(0, inputNode.selectionStart) + - inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); - } else { - inputNode.value = inputNode.value.slice(0, -1); - } - } else if (hasSelection) { + const hasSelection = inputNode.selectionStart !== inputNode.selectionEnd; + + if (key === 'Backspace') { + if (hasSelection) { inputNode.value = inputNode.value.slice(0, inputNode.selectionStart) + - key + inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); } else { - inputNode.value += key; + inputNode.value = inputNode.value.slice(0, -1); } - hasSelection = false; - inputNode.dispatchEvent(new KeyboardEvent('keydown', { key })); - el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + } else if (hasSelection) { + inputNode.value = + inputNode.value.slice(0, inputNode.selectionStart) + + key + + inputNode.value.slice(inputNode.selectionEnd, inputNode.value.length); + } else { + inputNode.value += key; + } + + inputNode.dispatchEvent(new KeyboardEvent('keydown', { key })); + el._inputNode.dispatchEvent(new Event('input', { bubbles: true, composed: true })); + + el.updateComplete.then(() => { resolve(); }); }); @@ -246,8 +248,8 @@ describe('lion-combobox', () => { }); }); - describe('Listbox visibility', () => { - it('does not show listbox on focusin', async () => { + describe('Overlay visibility', () => { + it('does not show overlay on focusin', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -263,14 +265,14 @@ describe('lion-combobox', () => { expect(el.opened).to.equal(false); }); - it('shows listbox again after select and char keydown', async () => { + it('shows overlay again after select and char keydown', async () => { /** * Scenario: - * [1] user focuses textbox: listbox hidden - * [2] user types char: listbox shows - * [3] user selects "Artichoke": listbox closes, textbox gets value "Artichoke" and textbox + * [1] user focuses textbox: overlay hidden + * [2] user types char: overlay shows + * [3] user selects "Artichoke": overlay closes, textbox gets value "Artichoke" and textbox * still has focus - * [4] user changes textbox value to "Artichoke": the listbox should show again + * [4] user changes textbox value to "Artichoke": the overlay should show again */ const el = /** @type {LionCombobox} */ (await fixture(html` @@ -306,7 +308,7 @@ describe('lion-combobox', () => { expect(el.opened).to.equal(true); }); - it('hides (and clears) listbox on [Escape]', async () => { + it('hides (and clears) overlay on [Escape]', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -329,7 +331,7 @@ describe('lion-combobox', () => { expect(el._inputNode.value).to.equal(''); }); - it('hides listbox on [Tab]', async () => { + it('hides overlay on [Tab]', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -371,13 +373,40 @@ describe('lion-combobox', () => { expect(el._inputNode.value).to.equal('Artichoke'); expect(el.checkedIndex).to.equal(0); - el._inputNode.value = ''; mimicUserTyping(el, ''); + await el.updateComplete; el.opened = false; await el.updateComplete; expect(el.checkedIndex).to.equal(-1); }); + // NB: If this becomes a suite, move to separate file + describe('Subclassers', () => { + it('allows to control overlay visibility via "_showOverlayCondition"', async () => { + class ShowOverlayConditionCombobox extends LionCombobox { + _showOverlayCondition(options) { + return this.focused || super.showOverlayCondition(options); + } + } + const tagName = defineCE(ShowOverlayConditionCombobox); + const tag = unsafeStatic(tagName); + + const el = /** @type {LionCombobox} */ (await fixture(html` + <${tag} name="foo" multiple-choice> + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + expect(el.opened).to.equal(false); + el._comboboxNode.dispatchEvent(new Event('focusin', { bubbles: true, composed: true })); + await el.updateComplete; + expect(el.opened).to.equal(true); + }); + }); + describe('Accessibility', () => { it('sets "aria-posinset" and "aria-setsize" on visible entries', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` @@ -783,7 +812,42 @@ describe('lion-combobox', () => { expect(el._inputNode.selectionEnd).to.equal('ch'.length); }); - it('does autocompletion when adding chars', async () => { + it('synchronizes textbox on overlay close', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + expect(el._inputNode.value).to.equal(''); + + async function performChecks(autocomplete, index, valueOnClose) { + await el.updateComplete; + el.opened = true; + el.setCheckedIndex(-1); + await el.updateComplete; + el.autocomplete = autocomplete; + el.setCheckedIndex(index); + el.opened = false; + await el.updateComplete; + expect(el._inputNode.value).to.equal(valueOnClose); + } + + await performChecks('none', 0, 'Artichoke'); + await performChecks('list', 0, 'Artichoke'); + await performChecks('inline', 0, 'Artichoke'); + await performChecks('both', 0, 'Artichoke'); + + el.multipleChoice = true; + // await performChecks('none', [0, 1], 'Chard'); + // await performChecks('list', [0, 1], 'Chard'); + // await performChecks('inline', [0, 1], 'Chard'); + // await performChecks('both', [0, 1], 'Chard'); + }); + + it('does inline autocompletion when adding chars', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -809,7 +873,7 @@ describe('lion-combobox', () => { expect(el.checkedIndex).to.equal(1); }); - it('does autocompletion when changing the word', async () => { + it('does inline autocompletion when changing the word', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` Artichoke @@ -824,7 +888,7 @@ describe('lion-combobox', () => { expect(el.activeIndex).to.equal(1); expect(el.checkedIndex).to.equal(1); - await mimicUserTypingAdvanced(el, 'ic'.split('')); + await mimicUserTypingAdvanced(el, ['i']); await el.updateComplete; expect(el.activeIndex).to.equal(2); expect(el.checkedIndex).to.equal(2); @@ -854,11 +918,37 @@ describe('lion-combobox', () => { // Autocompletion happened. When we go backwards ('Char'), we should not // autocomplete to 'Chard' anymore. - mimicUserTyping(el, 'Char'); + await mimicUserTypingAdvanced(el, ['Backspace']); await el.updateComplete; - expect(el._inputNode.value).to.equal('Char'); // so not 'Chard' - expect(el._inputNode.selectionStart).to.equal('Char'.length); - expect(el._inputNode.selectionEnd).to.equal('Char'.length); + expect(el._inputNode.value).to.equal('Ch'); // so not 'Chard' + expect(el._inputNode.selectionStart).to.equal('Ch'.length); + expect(el._inputNode.selectionEnd).to.equal('Ch'.length); + }); + + describe('Subclassers', () => { + it('allows to configure autoselect', async () => { + class X extends LionCombobox { + _autoSelectCondition() { + return true; + } + } + const tagName = defineCE(X); + const tag = unsafeStatic(tagName); + + const el = /** @type {LionCombobox} */ (await fixture(html` + <${tag} name="foo" autocomplete="list" opened> + Artichoke + Chard + Chicory + Victoria Plum + + `)); + // This ensures autocomplete would be off originally + el.autocomplete = 'list'; + await mimicUserTypingAdvanced(el, 'vi'); // so we have options ['Victoria Plum'] + await el.updateComplete; + expect(el.checkedIndex).to.equal(3); + }); }); it('highlights matching options', async () => { @@ -889,6 +979,125 @@ describe('lion-combobox', () => { expect(options[3]).lightDom.to.equal(`Victoria Plum`); }); + it('synchronizes textbox when autocomplete is "inline" or "both"', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'none'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'list'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'inline'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal('Artichoke'); + + el.setCheckedIndex(-1); + el.autocomplete = 'both'; + el.setCheckedIndex(0); + expect(el._inputNode.value).to.equal('Artichoke'); + }); + + it('synchronizes last index to textbox when autocomplete is "inline" or "both" when multipleChoice', async () => { + const el = /** @type {LionCombobox} */ (await fixture(html` + + Artichoke + Chard + Chicory + Victoria Plum + + `)); + expect(el._inputNode.value).to.eql(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'none'; + el.setCheckedIndex([0]); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'list'; + el.setCheckedIndex([0]); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal(''); + + el.setCheckedIndex(-1); + el.autocomplete = 'inline'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke'); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal('Chard'); + + el.setCheckedIndex(-1); + el.autocomplete = 'both'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke'); + el.setCheckedIndex([1]); + expect(el._inputNode.value).to.equal('Chard'); + }); + + describe('Subclassers', () => { + it('allows to override "_syncCheckedWithTextboxMultiple"', async () => { + class X extends LionCombobox { + // eslint-disable-next-line no-unused-vars + _syncToTextboxCondition() { + return true; + } + + // eslint-disable-next-line no-unused-vars + _syncToTextboxMultiple(modelValue, oldModelValue) { + // In a real scenario (depending on how selection display works), + // you could override the default (last selected option) with '' for instance + this._setTextboxValue(`${modelValue}-${oldModelValue}-multi`); + } + } + const tagName = defineCE(X); + const tag = unsafeStatic(tagName); + + const el = /** @type {LionCombobox} */ (await fixture(html` + <${tag} name="foo" autocomplete="none" multiple-choice> + Artichoke + Chard + Chicory + Victoria Plum + + `)); + + el.setCheckedIndex(-1); + el.autocomplete = 'none'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + + el.setCheckedIndex(-1); + el.autocomplete = 'list'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + + el.setCheckedIndex(-1); + el.autocomplete = 'inline'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + + el.setCheckedIndex(-1); + el.autocomplete = 'both'; + el.setCheckedIndex([0]); + expect(el._inputNode.value).to.equal('Artichoke--multi'); + }); + }); + describe('Active index behavior', () => { it('sets the active index to the closest match on open by default', async () => { const el = /** @type {LionCombobox} */ (await fixture(html` @@ -919,9 +1128,19 @@ describe('lion-combobox', () => { Victoria Plum `)); + + /** @param {LionCombobox} elm */ + function reset(elm) { + // eslint-disable-next-line no-param-reassign + elm.activeIndex = -1; + // eslint-disable-next-line no-param-reassign + elm.checkedIndex = -1; + } + // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 1. List Autocomplete with Manual Selection: // does not set active at all until user selects + reset(el); el.autocomplete = 'none'; mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; @@ -932,6 +1151,7 @@ describe('lion-combobox', () => { // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 2. List Autocomplete with Automatic Selection: // does not set active at all until user selects + reset(el); el.autocomplete = 'list'; mimicUserTyping(/** @type {LionCombobox} */ (el), 'cha'); await el.updateComplete; @@ -941,6 +1161,7 @@ describe('lion-combobox', () => { // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 3. List with Inline Autocomplete (mostly, but with aria-autocomplete="inline") + reset(el); el.autocomplete = 'inline'; mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; @@ -948,18 +1169,18 @@ describe('lion-combobox', () => { await el.updateComplete; await el.updateComplete; - // TODO: enable this, so it does not open listbox and is different from [autocomplete=both]? - // expect(el.opened).to.be.false; expect(el.activeIndex).to.equal(1); el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); await el.updateComplete; + await el.updateComplete; expect(el.activeIndex).to.equal(-1); expect(el.opened).to.be.false; // https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html // Example 3. List with Inline Autocomplete + reset(el); el.autocomplete = 'both'; mimicUserTyping(/** @type {LionCombobox} */ (el), ''); await el.updateComplete; @@ -1022,6 +1243,7 @@ describe('lion-combobox', () => { el._inputNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); await el.updateComplete; expect(el._inputNode.textContent).to.equal(''); + el.formElements.forEach(option => expect(option.active).to.be.false); // change selection, active index should update to closest match From dd1ac632849a1cca60f94f4a2c312bc5ff1cfb12 Mon Sep 17 00:00:00 2001 From: Thijs Louisse Date: Wed, 28 Oct 2020 12:02:07 +0100 Subject: [PATCH 3/4] chore(combobox): demo google search and whatsapp --- packages/combobox/docs/Subclassers.md | 115 +++-- .../docs/google-combobox/assets/appleLogo.png | Bin 0 -> 2225 bytes .../assets/google-clear-icon.js | 9 + .../assets/google-search-icon.js | 9 + .../assets/google-voice-search-icon.js | 19 + .../assets/googlelogo_color_272x92dp.png | Bin 0 -> 5969 bytes .../docs/google-combobox/google-combobox.js | 460 ++++++++++++++++++ packages/combobox/docs/lm-option/lm-option.js | 6 - .../combobox/docs/wa-combobox/wa-combobox.js | 77 ++- packages/combobox/test/lion-combobox.test.js | 12 +- 10 files changed, 636 insertions(+), 71 deletions(-) create mode 100644 packages/combobox/docs/google-combobox/assets/appleLogo.png create mode 100644 packages/combobox/docs/google-combobox/assets/google-clear-icon.js create mode 100644 packages/combobox/docs/google-combobox/assets/google-search-icon.js create mode 100644 packages/combobox/docs/google-combobox/assets/google-voice-search-icon.js create mode 100644 packages/combobox/docs/google-combobox/assets/googlelogo_color_272x92dp.png create mode 100644 packages/combobox/docs/google-combobox/google-combobox.js delete mode 100644 packages/combobox/docs/lm-option/lm-option.js diff --git a/packages/combobox/docs/Subclassers.md b/packages/combobox/docs/Subclassers.md index 7ae142276..823ef2206 100644 --- a/packages/combobox/docs/Subclassers.md +++ b/packages/combobox/docs/Subclassers.md @@ -5,13 +5,22 @@ import { html } from 'lit-html'; import './md-combobox/md-combobox.js'; import './gh-combobox/gh-combobox.js'; import './wa-combobox/wa-combobox.js'; -import './lm-option/lm-option.js'; +import './google-combobox/google-combobox.js'; export default { title: 'Forms/Combobox/Extensions', }; ``` +Below several extensions can be found. They illustrate that complex UI components can be created +easily from an extended Lion component, just by: + +- **configuring**: setting properties or providing conditions via methods +- **enhancing**: adding extra html/styles/logic without changing behavior of the extended element +- **overriding**: replace html/styles/logic of the extended element with your own + +## Material Design + ```js preview-story export const MaterialDesign = () => html` @@ -24,6 +33,8 @@ export const MaterialDesign = () => html` `; ``` +## Github + ```js preview-story export const Github = () => html` @@ -36,6 +47,8 @@ export const Github = () => html` `; ``` +## Whatsapp + ```js preview-story export const Whatsapp = () => html` @@ -81,44 +94,66 @@ export const Whatsapp = () => html` `; ``` +**Whatsapp example shows:** + +- advanced styling +- how to match/highlight text on multiple rows of the option (not just choiceValue) +- how to animate options + +## Google Search + ```js preview-story -export const LinkMixinBox = () => html` - - Apple - Artichoke - Asparagus - Banana - Beets - -`; +export const GoogleSearch = () => { + const appleLogoUrl = new URL('./google-combobox/assets/appleLogo.png', import.meta.url).href; + return html` + + Apple + Artichoke + Asparagus + Banana + Beets + +
+ `; +}; ``` + +**Google Search example shows:** + +- advanced styling +- how to use options that are links +- create exact user experience of Google Search, by: + - using autocomplete 'list' as a fundament (we don't want inline completion in textbox) + - enhancing `_showOverlayCondition`: open on focus + - enhancing `_syncToTextboxCondition`: always sync to textbox when navigating by keyboard (this needs to be enabled, since it's not provided in the "autocomplete=list" preset) diff --git a/packages/combobox/docs/google-combobox/assets/appleLogo.png b/packages/combobox/docs/google-combobox/assets/appleLogo.png new file mode 100644 index 0000000000000000000000000000000000000000..ec15637c3beb43cb1b4763a531d1340431178f2d GIT binary patch literal 2225 zcmV;i2u}BjP)YJOJe}8|fx@^7x00;?5 zL_t(|ob8=yo3knmhD*R*Yu&1~ZvX#R9-Yp-Gqp+=gQT9`-^;~QB7_jaZujBChYugN zhvOZqIP0=TcSqFV^x|A!aDc?q-4_)cvV?J9Uu;leQ@H-Ox=ku{ibSx#Y)qk11psWr z3d8gD`1L?)>+TgIg$rYWn^NdRxB51vuuk}dKn9WSj$v^Orbdu49_#KI5|ufruUDAs z?ivnze?}k^weG&5u%8=2#(YC}=P)?T>SHX>-8l@x`4Qa4MB^pXv*^W054yXzoFsOA=$t> z_|2q35xu>){mX zQCu8eoB!=PkK42hm@fu=9t2(1mV3?afMFlv#5Eak_=bb&2JPN?W-tvqEYhKsQ4Z$N z9vfP-HQBXM=Mnd(_Ou{GR~{c5OFPUX)914Ijexe&;TY`OM9lXKIP3a9|6U`b`{7^Ie$CmoAKmiVTDCEY2lK# zM6_1A+bNTMHe)y>H|A>R(Idur(^OEdv4A+yGy1;hO)T3t-_xsM*RA=eao)7#s{b7K z(^r@+w65~na0nsLaw%MDU$$?u|7oWPRgqu_!(~> zmFpHW3EBYm?zS|`M-RPAgBY_p)3Z@Py*6}wFy^!({EX?b+4P1`w5Xf>oSrk&V(Q!3 z@(Vl~;1O<^=1yAn`tTI_Y*liVRSOUx2ii{5zCsuI{;VNOTGjzzU)(zqb7vsJK7A^P z87ap;@}{;_;8)8a=CA?wLUH#t>+0ZN8Pcuj8U;6MLRw!TV%+J6l9oaj{n*gDbCGUk z6xhny!EhUEt0m z<&>(ZC2=G*(fA6IFKF!zRn)8@HkB&4oI%QGgHC}$QXv{>6{H7~^4XM>8Kk99LHRFQ z3%-g1XAY?`{3zUjLIpK5h$&PshMsOwSA4AW{S~U%Jke69!fPX@Fx|RB=Hl-yh$-|H z+-@hPP(c)fmO>Sq3u*bk)qyjK# z*~|d;=|w{;Rbjx#z4}Wm>uUfq++apbP9b)!+C4pm%thVndju6ig1S$n=C=)SwA;-C z)b5j9C=q8HVq3%?5EI%PnhFu0qPh1nlP*j&iNsP&e=*Sx+hk9LYQ>L{W>l}!EoBaf zkT#3-T+<%_>3Oz_=!eLL!XG8ZFgi5Xiwiu(xPn2YLfa(Z#> zt$T=LFzQ1(lm`)|QU=kYi5ED^750$uAOeO!dR1zE!8#W{kXp>%QZ!#2= z7aU>eEo%y?`UlG`tvUSY1+S-QbY8?btsqcm4#|BuL-Oo_1_$jL0G0pO9Py@K| z2gK7)bBk8P2!2U?TpTbrIG0se_Z*5g2;66$ATL&1>Bl_w%rgixjjQmgmv)%9sVN@% zIo<`57dQs@_WU4tm)Dbr3{G7|;NNY)U3VS4>18W>5gx!zb4O-kRu~7a%wD0xKZ1FZ zeC|nkE`q3dU^{0Wjj`@pM-dY-PpT#HdQBT + + +`; diff --git a/packages/combobox/docs/google-combobox/assets/google-search-icon.js b/packages/combobox/docs/google-combobox/assets/google-search-icon.js new file mode 100644 index 000000000..8ceaa571e --- /dev/null +++ b/packages/combobox/docs/google-combobox/assets/google-search-icon.js @@ -0,0 +1,9 @@ +import { html } from '@lion/core'; + +export default html` + + + +`; diff --git a/packages/combobox/docs/google-combobox/assets/google-voice-search-icon.js b/packages/combobox/docs/google-combobox/assets/google-voice-search-icon.js new file mode 100644 index 000000000..c439b89c7 --- /dev/null +++ b/packages/combobox/docs/google-combobox/assets/google-voice-search-icon.js @@ -0,0 +1,19 @@ +import { html } from '@lion/core'; + +export default html` + + + + + + +`; diff --git a/packages/combobox/docs/google-combobox/assets/googlelogo_color_272x92dp.png b/packages/combobox/docs/google-combobox/assets/googlelogo_color_272x92dp.png new file mode 100644 index 0000000000000000000000000000000000000000..333bda93710d1668ca6452a507ab8a12c343996e GIT binary patch literal 5969 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�U0N z3m6JPKp0sW8@)7ef#2ByzNjH_8IB}mH>f&eAXG;q!hPy}p-9g4)~36>eW@M_@Y*0J zu()+zMC5S+-TB@6)yQVYsmCP7Eu0(m(S@+;dYRA#Wi`_Spy`oEjm1TS`wRsw9sEbOIWFBIc zOd}Gyb8#`kUPSRe7fBA8nmI}&FiV0!hA^gqg#-vodh*@Mwh^D8=m5uFB0s#G;JZlb zhac7*EK7i?GLFx##DnX_Mm%Ytb{3JxEyL+Z&?6&RD5xo?Io|6H%v%{m%Qfs1Q6*s^ zFuSWd4o4O4_&=!KOj66KoEM6j37#A^>vkl=q;2MG>xXjF)UDpp$O<<#Wa5%z9hN{r z8e-D_U6K>(R#HYs2vWFQ@>;L7^P4^_H*a_IN9Yl^dIEu=RmnEiNtAD2M{^yM1e)g% z#ZpKrbTpFg9+<#gZ%qWln+Bo1fcn#Rg0pjZZyJnzU>}Dg38EEy{>G2OVgT*j2w!bC z=%tVb;aGJU3yJa!;|N(BxSg|1FCXp6fNop3ONr=ZgCu!uq5805bb`n;Y?Yiu_m&`% zO9e5Ecoc;pGDK^Fq&7&t|5ki1MgVdlJRiI$m(=$*amQPP2jFN4cuXh2*C%hVly#ybtNS?fMsTH@Ft<4uUv_k~-QFBv8|nMP556&n<;Q8KqwAJD3CEs8Y-t zFNA#0=P)sVg$1PR-0^?XD#U@!VY@zUqY{oXuysqqaBa*v#pjXDPmolh>KDjkDLzjK zVb>RNUEfK=wbA&7Cb+ow9Fn#DogIfn(k5>^!90T1TdXVN1{*U@a1x6UkOvcdLX@ro zH-CntS6FWj_=LnfnSVLv&nWPCX<-5(~Bk~r}GnDnnkOjxmCE1_UNRbW&ERdtdG1bBJogx&BkK}AF@d6+j45~k4; z3Cg9m>%G>jhy&_Flzv&0Q8Ng65z4Aj+s>7;=9Q`&r9?hrFA*Iq!g$L_i39J4r~{M! z6#_G1Y}fx*R1dGeR2uzbm;i-9kf>KeAcQuH0=FKHnm7>Q6m!Nj7AK$tgz(FD{9Cvi zlp`jMZSW!CPH;|c!Fq+8Q@#nQ#I0^bB#lNK#jNSn%;HcE=sm=Z-`!|Lva^A%2l|j^ zcacNSQPVp^UKsy@u_X{KzO5gulXBEiMj(XBU^#1Kkdqr_2t3JC7piN#XX4C_5o#h= zVmrY-Fo1!iT%v}*#&*01ITB}1a3Vp?8Y_^e5a-4mawLVU_0(tsvhn|z?2nnFg`AaD z?eb2_kMy?XkRuUCoTo7QaRB>!ouGHXSrd;~fa(3-qC6$pWPC zf=ni&SE|U3)8JT#2%|qUX)>Z^#gT@Fh6WVO1Oi#bYN4T_p@Ad!Xas^hA8S2mXlNk6 z20^2$_l76y7@HTZp`kIOYv8@y_2**pAj+sJ%J}FACu&AcU>X`4$p<=eIjSqJw|Uo_ z*na>C(PmF4fc7N8iGB?Y4U}c!4Kv!LL3~YQKhvq0h-?V?kDoFP4UOaiz8yrp&}PU2 zSc8CNMJ6A-K%C3_&rCx@Be}@U-*^~Rc!;LXMyB;cqzDH*qR4T>tgh-FOhZE>*~qy5 zjZ&=+Av&@#-A=}F&vty5wFWdabkzkVRmnkTFJd?pqKc7vw&2T)F(l~dmgt)qz-MBu z0SyfeNYTUkdWakTQQPqs+pg~*azI4G9dCKK#yc#34r?uFXlQ@{;x?^9jszkz){4;3 z(8^P9XEwaA&)pjjXRQwn4IPOfm(fH5d5K`s-Ad5V z(7@OZIEkq}wgh)7bH3Bi(1_nKh&#b`Q9HCljS90XHuj;Rp#g_6?gUq(G~37|L!J-% z;95qQ@ITg|p`ih9Q`lk(BDsb;{^KJJX3ylkEl{7@u74XuXE0`F8X6iJZ=Ym`(reoF zkLFIL1OBMo^^W4M|9-9KXf$1`, + ); + // For Safari, we need to add a label to the element + this.setAttribute('aria-label', this.textContent); + this.innerHTML = newInnerHTML; + // Alternatively, an extension can add an animation here + this.style.display = ''; + } + + /** + * @configure LionCombobox + */ + onFilterUnmatch() { + this.removeAttribute('aria-label'); + if (this.__originalInnerHTML) { + this.innerHTML = this.__originalInnerHTML; + } + this.style.display = 'none'; + } + + render() { + return html` + ${!this.imageUrl + ? html`
${googleSearchIcon}
` + : html` `} + ${super.render()} + `; + } +} +customElements.define('google-option', GoogleOption); + +export class GoogleCombobox extends LionCombobox { + static get styles() { + return [ + super.styles, + css` + /** @configure FormControlMixin */ + + /* ======================= + block | .form-field + ======================= */ + + :host { + font-family: arial, sans-serif; + } + + .form-field__label { + margin-top: 36px; + margin-bottom: 24px; + display: flex; + justify-content: center; + } + + /* ============================== + element | .input-group + ============================== */ + + .input-group { + margin-bottom: 16px; + max-width: 582px; + margin: auto; + } + + .input-group__container { + position: relative; + background: #fff; + display: flex; + border: 1px solid #dfe1e5; + box-shadow: none; + border-radius: 24px; + height: 44px; + } + + .input-group__container:hover, + :host([opened]) .input-group__container { + border-color: rgba(223, 225, 229, 0); + box-shadow: 0 1px 6px rgba(32, 33, 36, 0.28); + } + + :host([opened]) .input-group__container { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + :host([opened]) .input-group__container::before { + content: ''; + position: absolute; + background: white; + left: 0; + right: 0; + height: 10px; + bottom: -10px; + } + + :host([opened]) .input-group__container::after { + content: ''; + position: absolute; + background: #eee; + left: 16px; + right: 16px; + height: 1px; + bottom: 0; + z-index: 3; + } + + .input-group__prefix, + .input-group__suffix { + display: block; + fill: var(--icon-color); + display: flex; + place-items: center; + } + + .input-group__input { + flex: 1; + } + + .input-group__input ::slotted([slot='input']) { + border: transparent; + width: 100%; + } + + /** @configure LionCombobox */ + + /* ======================= + block | .form-field + ======================= */ + + #overlay-content-node-wrapper { + box-shadow: 0 4px 6px rgba(32, 33, 36, 0.28); + border-radius: 0 0 24px 24px; + margin-top: -2px; + padding-top: 6px; + background: white; + } + + * > ::slotted([slot='listbox']) { + margin-bottom: 8px; + background: none; + } + + :host { + --icon-color: #9aa0a6; + } + + /** @enhance LionCombobox */ + + /* =================================== + block | .google-search-clear-btn + =================================== */ + + .google-search-clear-btn { + position: relative; + height: 100%; + align-items: center; + display: none; + } + + .google-search-clear-btn::after { + border-left: 1px solid #dfe1e5; + height: 65%; + right: 0; + content: ''; + margin-right: 10px; + margin-left: 8px; + } + + :host([filled]) .google-search-clear-btn { + display: flex; + } + + * > ::slotted([slot='suffix']), + * > ::slotted([slot='clear-btn']) { + font: inherit; + margin: 0; + border: 0; + outline: 0; + padding: 0; + color: inherit; + background-color: transparent; + text-align: left; + white-space: normal; + overflow: visible; + + user-select: none; + -moz-user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + -webkit-tap-highlight-color: transparent; + + width: 25px; + height: 25px; + cursor: pointer; + } + + * > ::slotted([slot='suffix']) { + margin-right: 20px; + } + + * > ::slotted([slot='prefix']) { + height: 20px; + width: 20px; + margin-left: 12px; + margin-right: 16px; + } + + /* ============================= + block | .google-search-btns + ============================ */ + + .google-search-btns { + display: flex; + justify-content: center; + align-items: center; + } + + .google-search-btns__input-button { + background-image: -webkit-linear-gradient(top, #f8f9fa, #f8f9fa); + background-color: #f8f9fa; + border: 1px solid #f8f9fa; + border-radius: 4px; + color: #3c4043; + font-family: arial, sans-serif; + font-size: 14px; + margin: 11px 4px; + padding: 0 16px; + line-height: 27px; + height: 36px; + min-width: 54px; + text-align: center; + cursor: pointer; + user-select: none; + } + + .google-search-btns__input-button:hover { + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1); + background-image: -webkit-linear-gradient(top, #f8f8f8, #f1f1f1); + background-color: #f8f8f8; + border: 1px solid #c6c6c6; + color: #222; + } + + .google-search-btns__input-button:focus { + border: 1px solid #4d90fe; + outline: none; + } + + /* =============================== + block | .google-search-report + ============================== */ + + .google-search-report { + display: flex; + align-content: right; + color: #70757a; + font-style: italic; + font-size: 8pt; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); + margin-bottom: 8px; + justify-content: flex-end; + margin-right: 20px; + } + + .google-search-report a { + color: inherit; + } + `, + ]; + } + + /** + * @enhance LionCombobox - add google search buttons + */ + _overlayListboxTemplate() { + return html` + + + `; + } + + /** + * @enhance FormControlMixin add clear-btn + */ + _inputGroupSuffixTemplate() { + return html` +
+
+ +
+ +
+ `; + } + + _googleSearchBtnsTemplate() { + return html`
+ + +
`; + } + + /** + * @enhance FormControlMixin - add google search buttons + */ + _groupTwoTemplate() { + return html`${super._groupTwoTemplate()} ${!this.opened ? this._googleSearchBtnsTemplate() : ''} `; + } + + get slots() { + return { + ...super.slots, + label: () => renderLitAsNode(html` Google Search`), + prefix: () => renderLitAsNode(html` ${googleSearchIcon} `), + suffix: () => + renderLitAsNode( + html` `, + ), + 'clear-btn': () => + renderLitAsNode( + html` + + `, + ), + }; + } + + /** + * @configure OverlayMixin + */ + get _overlayReferenceNode() { + return /** @type {ShadowRoot} */ (this.shadowRoot).querySelector('.input-group'); + } + + constructor() { + super(); + /** @configure LionCombobox */ + this.autocomplete = 'list'; + /** @configure LionCombobox */ + this.showAllOnEmpty = true; + + this.__resetFocus = this.__resetFocus.bind(this); + this.__clearText = this.__clearText.bind(this); + } + + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + + this._overlayContentNode.addEventListener('mouseenter', this.__resetFocus); + } + + /** + * @override LionCombobox - always sync textbox when selected value changes + */ + // eslint-disable-next-line no-unused-vars + _syncToTextboxCondition() { + return true; + } + + _showOverlayCondition(options) { + return this.focused || super.showOverlayCondition(options); + } + + __resetFocus() { + this.activeIndex = -1; + this.checkedIndex = -1; + } + + __clearText() { + this._inputNode.value = ''; + } +} +customElements.define('google-combobox', GoogleCombobox); diff --git a/packages/combobox/docs/lm-option/lm-option.js b/packages/combobox/docs/lm-option/lm-option.js deleted file mode 100644 index 242aa2701..000000000 --- a/packages/combobox/docs/lm-option/lm-option.js +++ /dev/null @@ -1,6 +0,0 @@ -import { LionOption } from '@lion/listbox'; -import { LinkMixin } from '../LinkMixin.js'; - -export class LmOption extends LinkMixin(LionOption) {} - -customElements.define('lm-option', LmOption); diff --git a/packages/combobox/docs/wa-combobox/wa-combobox.js b/packages/combobox/docs/wa-combobox/wa-combobox.js index c876df32f..9d4162b0b 100644 --- a/packages/combobox/docs/wa-combobox/wa-combobox.js +++ b/packages/combobox/docs/wa-combobox/wa-combobox.js @@ -167,9 +167,9 @@ class WaOption extends LionOption { :host([is-user-text-read]) .wa-option__content-row2-text-inner-icon { color: lightblue; } - /* - .wa-option__content-row2-menu { - } */ + .wa-selected { + color: #009688; + } `, ]; } @@ -178,13 +178,7 @@ class WaOption extends LionOption { return html`