diff --git a/README.md b/README.md index 4f5a5a1..8133830 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ combobox.destroy() ## Events -A bubbling `combobox-commit` event is fired on the list element when an option is selected via keyboard or click. +A bubbling `combobox-commit` event is fired on the list element when an option is selected via keyboard or mouse. Mouse selection is observed on `mousedown` so the event still fires if the input blurs before `click` (for example when a `blur` handler hides the list). For example, autocomplete when an option is selected: diff --git a/src/index.ts b/src/index.ts index f4f22b3..ee604a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,7 @@ export default class Combobox { this.input.addEventListener('compositionend', this.compositionEventHandler) this.input.addEventListener('input', this.inputHandler) ;(this.input as HTMLElement).addEventListener('keydown', this.keyboardEventHandler) + this.list.addEventListener('mousedown', commitWithElement) this.list.addEventListener('click', commitWithElement) this.resetSelection() } @@ -91,6 +92,7 @@ export default class Combobox { this.input.removeEventListener('compositionend', this.compositionEventHandler) this.input.removeEventListener('input', this.inputHandler) ;(this.input as HTMLElement).removeEventListener('keydown', this.keyboardEventHandler) + this.list.removeEventListener('mousedown', commitWithElement) this.list.removeEventListener('click', commitWithElement) } @@ -211,11 +213,31 @@ function keyboardBindings(event: KeyboardEvent, combobox: Combobox) { } } +// Set when a mousedown has already committed an option, so the click that follows +// does not commit it a second time. That click may never arrive, for example when +// the pointer leaves the option before the button is released, so the next click +// anywhere in the document clears it. +let committedOnMousedown: Element | null = null + +function clearMousedownCommit(): void { + committedOnMousedown = null +} + function commitWithElement(event: MouseEvent) { if (!(event.target instanceof Element)) return const target = event.target.closest('[role="option"]') if (!target) return if (target.getAttribute('aria-disabled') === 'true') return + + if (event.type === 'mousedown') { + if (event.button !== 0) return + committedOnMousedown = target + target.ownerDocument.addEventListener('click', clearMousedownCommit, {once: true}) + } else if (committedOnMousedown === target) { + committedOnMousedown = null + return + } + fireCommitEvent(target, {event}) } diff --git a/test/test.js b/test/test.js index 429c018..f900956 100644 --- a/test/test.js +++ b/test/test.js @@ -7,6 +7,7 @@ function press(input, key, ctrlKey) { } function click(element) { + element.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true})) element.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})) } @@ -164,6 +165,62 @@ describe('combobox-nav', function () { assert.equal(expectedTargets[1], 'baymax') }) + it('fires a commit event on a direct option.click()', function () { + const expectedTargets = [] + + document.addEventListener('combobox-commit', function ({target}) { + expectedTargets.push(target.id) + }) + + document.getElementById('hubot').click() + + assert.equal(expectedTargets.length, 1) + assert.equal(expectedTargets[0], 'hubot') + }) + + it('fires a commit event on a click after a mousedown that never completed', function () { + const expectedTargets = [] + + document.addEventListener('combobox-commit', function ({target}) { + expectedTargets.push(target.id) + }) + + // Press on the option, then release the pointer elsewhere: the click lands on + // an ancestor instead of the option. + const option = document.getElementById('hubot') + option.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true})) + document.body.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})) + + option.click() + + assert.equal(expectedTargets.length, 2) + assert.equal(expectedTargets[0], 'hubot') + assert.equal(expectedTargets[1], 'hubot') + }) + + it('fires commit before a blur handler can stop the combobox', function () { + const expectedTargets = [] + + document.addEventListener('combobox-commit', function ({target}) { + expectedTargets.push(target.id) + }) + + input.addEventListener('blur', function () { + list.hidden = true + combobox.clearSelection() + combobox.stop() + }) + + const option = document.getElementById('hubot') + option.dispatchEvent(new MouseEvent('mousedown', {bubbles: true, cancelable: true})) + input.dispatchEvent(new FocusEvent('blur')) + option.dispatchEvent(new MouseEvent('mouseup', {bubbles: true, cancelable: true})) + option.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})) + + assert.equal(expectedTargets.length, 1) + assert.equal(expectedTargets[0], 'hubot') + }) + it('fires select events on navigating', function () { const expectedTargets = []