From 9b969f633b560e15ae3c85db7a23e47c4b710dfe Mon Sep 17 00:00:00 2001 From: Simone Todaro Date: Sat, 1 Jul 2023 19:23:36 +0100 Subject: [PATCH] Feature/UI/combobox (#3409) * Remove invalid markup from index.html * Allow all tests to run * Accessibility improvements * Tidy up combobox hiding logic * Update accessibility tests * Revert to use short syntax * Reduce visual noise in PR * First review * Remove superflous $data calls to improve performance * Revert $refs syntax * Match tailwind logic for active optiton while typing * add unit tests * Update index file * Finishing touches on combobox * Fix combobox accessibility * Preserve selected state when opening box * wip * make sure we select the first match in the DOM for multiselect with more then one option selected * Preserve active key for combobox only if activated by the user using the navigation keys * Add test --------- Co-authored-by: Caleb Porzio --- index.html | 420 ++++---------- packages/ui/src/combobox.js | 165 +++--- packages/ui/src/list-context.js | 174 ++---- .../integration/plugins/ui/combobox.spec.js | 522 +++++++++++++++++- 4 files changed, 729 insertions(+), 552 deletions(-) diff --git a/index.html b/index.html index dded76d4e..8ec27abdf 100644 --- a/index.html +++ b/index.html @@ -9,291 +9,6 @@ -
-
-
- - -
-
- - -
- - -
-
local selected: Ruby on Rails
-
internal selected: Django
-
-
-
- - - - - - - - -
- -
-
-
-
--> - - diff --git a/packages/ui/src/combobox.js b/packages/ui/src/combobox.js index baeb7ca31..595d851af 100644 --- a/packages/ui/src/combobox.js +++ b/packages/ui/src/combobox.js @@ -85,24 +85,6 @@ function handleRoot(el, Alpine) { __inputName: null, __isTyping: false, __hold: false, - __pointer: { - lastPosition: [-1, -1], - - wasMoved(e) { - let newPosition = [e.screenX, e.screenY] - - if (this.lastPosition[0] === newPosition[0] && this.lastPosition[1] === newPosition[1]) { - return false - } - - this.lastPosition = newPosition - return true - }, - - update(e) { - this.lastPosition = [e.screenX, e.screenY] - }, - }, /** * Combobox initialization... @@ -114,7 +96,7 @@ function handleRoot(el, Alpine) { this.__nullable = Alpine.extractProp(el, 'nullable', false) this.__compareBy = Alpine.extractProp(el, 'by') - this.__context = generateContext(this.__isMultiple, 'vertical', () => this.$data.__activateSelectedOrFirst()) + this.__context = generateContext(this.__isMultiple, 'vertical', () => this.__activateSelectedOrFirst()) let defaultValue = Alpine.extractProp(el, 'default-value', this.__isMultiple ? [] : null) @@ -134,24 +116,22 @@ function handleRoot(el, Alpine) { __startTyping() { this.__isTyping = true }, - __stopTyping(resetInput = true) { + __stopTyping() { this.__isTyping = false - - if (resetInput) this.$data.__resetInput() }, __resetInput() { let input = this.$refs.__input + if (! input) return - let value = this.$data.__getCurrentValue() + let value = this.__getCurrentValue() input.value = value - input.dispatchEvent(new Event('change')) }, __getCurrentValue() { if (! this.$refs.__input) return '' if (! this.__value) return '' - if (this.$data.__displayValue && this.__value !== undefined) return this.$data.__displayValue(this.__value) + if (this.__displayValue) return this.__displayValue(this.__value) if (typeof this.__value === 'string') return this.__value return '' }, @@ -159,13 +139,36 @@ function handleRoot(el, Alpine) { if (this.__isOpen) return this.__isOpen = true - this.__activateSelectedOrFirst() + let input = this.$refs.__input + + // Make sure we always notify the parent component + // that the starting value is the empty string + // when we open the combobox (ignoring any existing value) + // to avoid inconsistent displaying. + // Setting the input to empty and back to the real value + // also helps VoiceOver to annunce the content properly + // See https://github.com/tailwindlabs/headlessui/pull/2153 + if (input) { + let value = input.value + let { selectionStart, selectionEnd, selectionDirection } = input + input.value = '' + input.dispatchEvent(new Event('change')) + input.value = value + if (selectionDirection !== null) { + input.setSelectionRange(selectionStart, selectionEnd, selectionDirection) + } else { + input.setSelectionRange(selectionStart, selectionEnd) + } + } // Safari needs more of a "tick" for focusing after x-show for some reason. // Probably because Alpine adds an extra tick when x-showing for @click.outside let nextTick = callback => requestAnimationFrame(() => requestAnimationFrame(callback)) - nextTick(() => this.$refs.__input.focus({ preventScroll: true })) + nextTick(() => { + this.$refs.__input.focus({ preventScroll: true }) + this.__activateSelectedOrFirst() + }) }, __close() { this.__isOpen = false @@ -175,31 +178,32 @@ function handleRoot(el, Alpine) { __activateSelectedOrFirst(activateSelected = true) { if (! this.__isOpen) return - if (this.__context.activeKey) { - this.__context.activateAndScrollToKey(this.__context.activeKey) - return - } + if (this.__context.hasActive() && this.__context.wasActivatedByKeyPress()) return let firstSelectedValue if (this.__isMultiple) { - firstSelectedValue = this.__value.find(i => { - return !! this.__context.getItemByValue(i) - }) + let selectedItem = this.__context.getItemsByValues(this.__value) + + firstSelectedValue = selectedItem.length ? selectedItem[0].value : null } else { firstSelectedValue = this.__value } + let firstSelected = null if (activateSelected && firstSelectedValue) { - let firstSelected = this.__context.getItemByValue(firstSelectedValue) + firstSelected = this.__context.getItemByValue(firstSelectedValue) + } - firstSelected && this.__context.activateAndScrollToKey(firstSelected.key) - } else { - this.__context.activateAndScrollToKey(this.__context.firstKey()) + if (firstSelected) { + this.__context.activateAndScrollToKey(firstSelected.key) + return } + + this.__context.activateAndScrollToKey(this.__context.firstKey()) }, __selectActive() { - let active = this.$data.__context.getActiveItem() + let active = this.__context.getActiveItem() if (active) this.__toggleSelected(active.value) }, __selectOption(el) { @@ -249,7 +253,6 @@ function handleRoot(el, Alpine) { }, } }, - // Register event listeners.. '@mousedown.window'(e) { if ( @@ -257,8 +260,8 @@ function handleRoot(el, Alpine) { && ! this.$refs.__button.contains(e.target) && ! this.$refs.__options.contains(e.target) ) { - this.$data.__resetInput() - this.$data.__close() + this.__close() + this.__resetInput() } } }) @@ -273,6 +276,8 @@ function handleInput(el, Alpine) { // Accessibility attributes... 'role': 'combobox', 'tabindex': '0', + 'aria-autocomplete': 'list', + // We need to defer this evaluation a bit because $refs that get declared later // in the DOM aren't available yet when x-ref is the result of an Alpine.bind object. async ':aria-controls'() { return await microtask(() => this.$refs.__options && this.$refs.__options.id) }, @@ -295,16 +300,19 @@ function handleInput(el, Alpine) { // Register listeners... '@input.stop'(e) { - this.$data.__open(); this.$dispatch('change') + if(this.$data.__isTyping) { + this.$data.__open(); + this.$dispatch('change') + } }, '@blur'() { this.$data.__stopTyping(false) }, '@keydown'(e) { queueMicrotask(() => this.$data.__context.activateByKeyEvent(e, false, () => this.$data.__isOpen, () => this.$data.__open(), (state) => this.$data.__isTyping = state)) - }, + }, '@keydown.enter.prevent.stop'() { this.$data.__selectActive() - this.$data.isTyping = false + this.$data.__stopTyping() if (! this.$data.__isMultiple) { this.$data.__close() @@ -314,15 +322,15 @@ function handleInput(el, Alpine) { '@keydown.escape.prevent'(e) { if (! this.$data.__static) e.stopPropagation() + this.$data.__stopTyping() this.$data.__close() + this.$data.__resetInput() - this.$data.__stopTyping() }, '@keydown.tab'() { this.$data.__stopTyping() - this.$data.__resetInput() - if (this.$data.__isOpen) { this.$data.__close() } + this.$data.__resetInput() }, '@keydown.backspace'(e) { if (this.$data.__isMultiple) return @@ -369,8 +377,8 @@ function handleButton(el, Alpine) { '@click'(e) { if (this.$data.__isDisabled) return if (this.$data.__isOpen) { - this.$data.__resetInput() this.$data.__close() + this.$data.__resetInput() } else { e.preventDefault() this.$data.__open() @@ -396,15 +404,8 @@ function handleOptions(el, Alpine) { ':id'() { return this.$id('alpine-combobox-options') }, // Accessibility attributes... - 'role': 'combobox', + 'role': 'listbox', ':aria-labelledby'() { return this.$refs.__label ? this.$refs.__label.id : (this.$refs.__button ? this.$refs.__button.id : null) }, - ':aria-activedescendant'() { - if (! this.$data.__context.hasActive()) return - - let active = this.$data.__context.getActiveItem() - - return active ? active.el.id : null - }, // Initialize... 'x-init'() { @@ -428,28 +429,32 @@ function handleOption(el, Alpine) { // Accessibility attributes... 'role': 'option', ':tabindex'() { return this.$comboboxOption.isDisabled ? undefined : '-1' }, - ':aria-selected'() { return this.$comboboxOption.isSelected }, + + // Only the active element should have aria-selected="true"... + 'x-effect'() { + this.$comboboxOption.isActive + ? el.setAttribute('aria-selected', true) + : el.removeAttribute('aria-selected') + }, + ':aria-disabled'() { return this.$comboboxOption.isDisabled }, // Initialize... 'x-data'() { return { init() { - let key = el.__optionKey = (Math.random() + 1).toString(36).substring(7) - - let value = Alpine.extractProp(el, 'value') - let disabled = Alpine.extractProp(el, 'disabled', false, false) + let key = this.$el.__optionKey = (Math.random() + 1).toString(36).substring(7) - this.$data.__context.registerItem(key, el, value, disabled) + let value = Alpine.extractProp(this.$el, 'value') + let disabled = Alpine.extractProp(this.$el, 'disabled', false, false) - // @todo: make sure the "destroy" hook is good enough and we don't need this... - // el._x_forCleanup = () => { - // this.$data.__context.unregisterItem(key) - // } + // memoize the context as it's not going to change + // and calling this.$data on mouse action is expensive + this.__context.registerItem(key, this.$el, value, disabled) }, destroy() { - this.$data.__context.unregisterItem(this.$el.__optionKey) - }, + this.__context.unregisterItem(this.$el.__optionKey) + } } }, @@ -457,34 +462,32 @@ function handleOption(el, Alpine) { '@click'() { if (this.$comboboxOption.isDisabled) return; - this.$data.__selectOption(el) + this.__selectOption(this.$el) - if (! this.$data.__isMultiple) { - this.$data.__resetInput() - this.$data.__close() + if (! this.__isMultiple) { + this.__close() + this.__resetInput() } this.$nextTick(() => this.$refs.__input.focus({ preventScroll: true })) }, - - // @todo: this is a memory leak for _x_cleanups... '@mouseenter'(e) { - this.$data.__pointer.update(e) + this.__context.activateEl(this.$el) }, '@mousemove'(e) { - if (!this.$data.__pointer.wasMoved(e)) return + if (this.__context.isActiveEl(this.$el)) return - this.$data.__context.activateEl(el) + this.__context.activateEl(this.$el) }, '@mouseleave'(e) { - if (!this.$data.__pointer.wasMoved(e)) return - if (this.$data.__hold) return + if (this.__hold) return - this.$data.__context.deactivate() + this.__context.deactivate() }, }) } + // Little utility to defer a callback into the microtask queue... function microtask(callback) { return new Promise(resolve => queueMicrotask(() => resolve(callback()))) diff --git a/packages/ui/src/list-context.js b/packages/ui/src/list-context.js index 71555696d..dbc3f4cc6 100644 --- a/packages/ui/src/list-context.js +++ b/packages/ui/src/list-context.js @@ -5,13 +5,9 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) * Main state... */ items: [], - - disabledKeys: [], activeKey: null, - selectedKeys: [], orderedKeys: [], - elsByKey: {}, - values: {}, + activatedByKeyPress: false, /** * Initialization... @@ -54,12 +50,24 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) return this.items.find(i => i.el === el) }, + getItemsByValues(values) { + let rawValues = values.map(i => Alpine.raw(i)); + let filteredValue = this.items.filter(i => rawValues.includes(Alpine.raw(i.value))) + filteredValue = filteredValue.slice().sort((a, b) => { + let position = a.el.compareDocumentPosition(b.el) + if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1 + if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1 + return 0 + }) + return filteredValue + }, + getActiveItem() { if (! this.hasActive()) return null let item = this.items.find(i => i.key === this.activeKey) - if (! item) return this.activeKey = null + if (! item) this.deactivateKey(this.activeKey) return item }, @@ -67,7 +75,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) activateItem(item) { if (! item) return - this.activeKey = item.key + this.activateKey(item.key) }, /** @@ -91,7 +99,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) // If there no longer is the active key in the items list, then // deactivate it... - if (! this.orderedKeys.includes(this.activeKey)) this.activeKey = null + if (! this.orderedKeys.includes(this.activeKey)) this.deactivateKey(this.activeKey) }), activeEl() { @@ -120,7 +128,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) scrollingCount: 0, - activateAndScrollToKey(key) { + activateAndScrollToKey(key, activatedByKeyPress) { if (! this.getItemByKey(key)) return // This addresses the following problem: @@ -129,7 +137,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) // This "isScrollingTo" is exposed to prevent that. this.scrollingCount++ - this.activateKey(key) + this.activateKey(key, activatedByKeyPress) let targetEl = this.items.find(i => i.key === key).el @@ -143,57 +151,6 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) }, 25) }, - /** - * Handle values... - */ - // selectedValueOrValues() { - // if (multiple) { - // return this.selectedValues() - // } else { - // return this.selectedValue() - // } - // }, - - // selectedValues() { - // return this.selectedKeys.map(i => this.values[i]) - // }, - - // selectedValue() { - // return this.selectedKeys[0] ? this.values[this.selectedKeys[0]] : null - // }, - - // selectValue(value, by) { - // if (!value) value = (multiple ? [] : null) - // if (! by) by = (a, b) => a === b - - // if (typeof by === 'string') { - // let property = by - // by = (a, b) => a[property] === b[property] - // } - - // if (multiple) { - // let keys = [] - - // value.forEach(i => { - // for (let key in this.values) { - // if (by(this.values[key], i)) { - // if (! keys.includes(key)) { - // keys.push(key) - // } - // } - // } - // }) - - // this.selectExclusive(keys) - // } else { - // for (let key in this.values) { - // if (value && by(this.values[key], value)) { - // this.selectKey(key) - // } - // } - // } - // }, - /** * Handle disabled keys... */ @@ -210,95 +167,32 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) }, /** - * Handle selected keys... + * Handle activated keys... */ - // selectKey(key) { - // if (this.isDisabled(key)) return - - // if (multiple) { - // this.toggleSelected(key) - // } else { - // this.selectOnly(key) - // } - // }, - - // toggleSelected(key) { - // console.log(key) - // if (this.selectedKeys.includes(key)) { - // this.selectedKeys.splice(this.selectedKeys.indexOf(key), 1) - // } else { - // this.selectedKeys.push(key) - // } - // }, - - // selectOnly(key) { - // this.selectedKeys = [] - // this.selectedKeys.push(key) - // }, - - // selectExclusive(keys) { - // // We can't just do this.selectedKeys = keys, - // // because we need to preserve reactivity... - - // let toAdd = [...keys] - - // for (let i = 0; i < this.selectedKeys.length; i++) { - // if (keys.includes(this.selectedKeys[i])) { - // delete toAdd[toAdd.indexOf(this.selectedKeys[i])] - // continue; - // } - - // if (! keys.includes(this.selectedKeys[i])) { - // this.selectedKeys.splice(i, 1) - // } - // } - - // toAdd.forEach(i => { - // this.selectedKeys.push(i) - // }) - // }, - - // selectActive(key) { - // if (! this.activeKey) return - - // this.selectKey(this.activeKey) - // }, - - // isSelected(key) { return this.selectedKeys.includes(key) }, - - - // firstSelectedKey() { return this.selectedKeys[0] }, + hasActive() { return !! this.activeKey }, /** - * Handle activated keys... + * Return true if the latest active element was activated + * by the user (i.e. using the arrow keys) and false if was + * activated automatically by alpine (i.e. first element automatically + * activeted after filtering the list) */ - hasActive() { return !! this.activeKey }, + wasActivatedByKeyPress() {return this.activatedByKeyPress}, isActiveKey(key) { return this.activeKey === key }, - - // activateSelectedOrFirst() { - // let firstSelected = this.firstSelectedKey() - - // if (firstSelected) { - // return this.activateKey(firstSelected) - // } - - // let firstKey = this.firstKey() - - // if (firstKey) { - // this.activateKey(firstKey) - // } - // }, - - activateKey(key) { + activateKey(key, activatedByKeyPress = false) { if (this.isDisabled(key)) return this.activeKey = key + this.activatedByKeyPress = activatedByKeyPress }, deactivateKey(key) { - if (this.activeKey === key) this.activeKey = null + if (this.activeKey === key) { + this.activeKey = null + this.activatedByKeyPress = false + } }, deactivate() { @@ -306,6 +200,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) if (this.isScrollingTo) return this.activeKey = null + this.activatedByKeyPress = false }, /** @@ -361,6 +256,8 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) setIsTyping(true) + let activatedByKeyPress = true + switch (e.key) { // case 'Backspace': // case 'Delete': @@ -410,6 +307,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) break; default: + activatedByKeyPress = this.activatedByKeyPress if (searchable && e.key.length === 1) { targetKey = this.searchKey(e.key) } @@ -417,7 +315,7 @@ export function generateContext(multiple, orientation, activateSelectedOrFirst) } if (targetKey) { - this.activateAndScrollToKey(targetKey) + this.activateAndScrollToKey(targetKey, activatedByKeyPress) } } } diff --git a/tests/cypress/integration/plugins/ui/combobox.spec.js b/tests/cypress/integration/plugins/ui/combobox.spec.js index 794d96f3a..6092f1326 100644 --- a/tests/cypress/integration/plugins/ui/combobox.spec.js +++ b/tests/cypress/integration/plugins/ui/combobox.spec.js @@ -1,14 +1,9 @@ -import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, contain, notContain, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test, haveValue} from '../../../utils' +import { beVisible, beHidden, haveAttribute, haveClasses, notHaveClasses, haveText, contain, notContain, html, notBeVisible, notHaveAttribute, notExist, haveFocus, test, haveValue, haveLength} from '../../../utils' -test.only('it works with x-model', +test('it works with x-model', [html`
+
+ + +
+
+ + + +
+ +
+
    + +
+ +

No people match your query.

+
+
+
+ +
lorem ipsum
+
+ `], + ({ get }) => { + get('input').should(haveText('')) + get('button').click() + get('[option="3"]').click() + cy.wait(100) + get('input').type('{selectAll}{backspace}') + cy.wait(100) + get('input').type('{downArrow}') + cy.wait(100) + get('[option="3"]').should(contain('*')) + get('input').type('{upArrow}{upArrow}') + cy.wait(100) + get('[option="1"]').should(contain('*')) + cy.wait(100) + get('input').type('d') + get('input').trigger('change') + cy.wait(100) + get('[option="1"]').should(contain('*')) + }, +); + +test('Ignore active selection while options change if not selected by a keyboard event', + [html` +
+
+ + +
+
+ + + +
+ +
+
    + +
+ +

No people match your query.

+
+
+
+ +
lorem ipsum
+
+ `], + ({ get }) => { + get('input').should(haveText('')) + get('button').click() + get('[option="1"]').should(contain('*')) + get('input').type('t') + get('input').trigger('change') + get('[option="4"]').should(contain('*')) + get('input').type('{backspace}') + get('input').trigger('change') + get('[option="1"]').should(contain('*')) + }, +); + test('"name" prop with object value', [html`
`], ({ get }) => { + get('input') + .should(haveAttribute('aria-expanded', 'false')) + get('button') .should(haveAttribute('aria-haspopup', 'true')) .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1 alpine-combobox-button-1')) .should(haveAttribute('aria-expanded', 'false')) .should(notHaveAttribute('aria-controls')) .should(haveAttribute('id', 'alpine-combobox-button-1')) + .should(haveAttribute('tabindex', '-1')) .click() .should(haveAttribute('aria-expanded', 'true')) .should(haveAttribute('aria-controls', 'alpine-combobox-options-1')) get('[options]') - .should(haveAttribute('role', 'combobox')) + .should(haveAttribute('role', 'listbox')) .should(haveAttribute('id', 'alpine-combobox-options-1')) .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1')) - .should(notHaveAttribute('aria-activedescendant')) - .should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-1')) get('[option="1"]') .should(haveAttribute('role', 'option')) .should(haveAttribute('id', 'alpine-combobox-option-1')) .should(haveAttribute('tabindex', '-1')) - .should(haveAttribute('aria-selected', 'false')) + .should(haveAttribute('aria-selected', 'true')) get('[option="2"]') .should(haveAttribute('role', 'option')) .should(haveAttribute('id', 'alpine-combobox-option-2')) .should(haveAttribute('tabindex', '-1')) - .should(haveAttribute('aria-selected', 'false')) + .should(notHaveAttribute('aria-selected')) get('input') + .should(haveAttribute('role', 'combobox')) + .should(haveAttribute('aria-autocomplete', 'list')) + .should(haveAttribute('tabindex', '0')) + .should(haveAttribute('aria-expanded', 'true')) + .should(haveAttribute('aria-labelledby', 'alpine-combobox-label-1')) + .should(haveAttribute('aria-controls', 'alpine-combobox-options-1')) + .should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-1')) .type('{downarrow}') .should(haveAttribute('aria-activedescendant', 'alpine-combobox-option-2')) get('[option="2"]') - .click() .should(haveAttribute('aria-selected', 'true')) }, ) @@ -807,3 +989,317 @@ test('"static" prop', get('[normal-toggle]').should(haveText('Arlene Mccoy')) }, ) + +test('input reset', + [html` +
+
+ + +
+
+ + + +
+ +
+
    + +
+ +

No people match your query.

+
+
+
+ +
lorem ipsum
+
+ `], + ({ get }) => { + // Test after closing with button + get('button').click() + get('input').type('w') + get('button').click() + get('input').should(haveValue('')) + + // Test correct state after closing with ESC + get('button').click() + get('input').type('w') + get('input').type('{esc}') + get('input').should(haveValue('')) + + // Test correct state after closing with TAB + get('button').click() + get('input').type('w') + get('input').tab() + get('input').should(haveValue('')) + + // Test correct state after closing with external click + get('button').click() + get('input').type('w') + get('article').click() + get('input').should(haveValue('')) + + // Select something + get('button').click() + get('ul').should(beVisible()) + get('[option="2"]').click() + get('input').should(haveValue('Arlene Mccoy')) + + // Test after closing with button + get('button').click() + get('input').type('w') + get('button').click() + get('input').should(haveValue('Arlene Mccoy')) + + // Test correct state after closing with ESC and reopening + get('button').click() + get('input').type('w') + get('input').type('{esc}') + get('input').should(haveValue('Arlene Mccoy')) + + // Test correct state after closing with TAB and reopening + get('button').click() + get('input').type('w') + get('input').tab() + get('input').should(haveValue('Arlene Mccoy')) + + // Test correct state after closing with external click and reopening + get('button').click() + get('input').type('w') + get('article').click() + get('input').should(haveValue('Arlene Mccoy')) + }, +) + +test('combobox shows all options when opening', + [html` +
+
+ + +
+
+ + + +
+ +
+
    + +
+ +

No people match your query.

+
+
+
+ +
lorem ipsum
+
+ `], + ({ get }) => { + get('button').click() + get('li').should(haveLength('10')) + + // Test after closing with button and reopening + get('input').type('w').trigger('input') + get('li').should(haveLength('2')) + get('button').click() + get('button').click() + get('li').should(haveLength('10')) + + // Test correct state after closing with ESC and reopening + get('input').type('w').trigger('input') + get('li').should(haveLength('2')) + get('input').type('{esc}') + get('button').click() + get('li').should(haveLength('10')) + + // Test correct state after closing with TAB and reopening + get('input').type('w').trigger('input') + get('li').should(haveLength('2')) + get('input').tab() + get('button').click() + get('li').should(haveLength('10')) + + // Test correct state after closing with external click and reopening + get('input').type('w').trigger('input') + get('li').should(haveLength('2')) + get('article').click() + get('button').click() + get('li').should(haveLength('10')) + }, +) + +test('active element logic when opening a combobox', + [html` +
+
+ + +
+
+ + + +
+ +
+
    + +
+ +

No people match your query.

+
+
+
+
+ `], + ({ get }) => { + get('button').click() + // First option is selected on opening if no preselection + get('ul').should(beVisible()) + get('[option="1"]').should(haveAttribute('aria-selected', 'true')) + // First match is selected while typing + get('[option="4"]').should(notHaveAttribute('aria-selected')) + get('input').type('T') + get('input').trigger('change') + get('[option="4"]').should(haveAttribute('aria-selected', 'true')) + // Reset state and select option 3 + get('button').click() + get('button').click() + get('[option="3"]').click() + // Previous selection is selected + get('button').click() + get('[option="4"]').should(notHaveAttribute('aria-selected')) + get('[option="3"]').should(haveAttribute('aria-selected', 'true')) + } +)