diff --git a/src/index.ts b/src/index.ts index 35efe6e..f4f22b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,6 +31,7 @@ export default class Combobox { tabInsertsSuggestions: boolean firstOptionSelectionMode: FirstOptionSelectionMode scrollIntoViewOptions?: boolean | ScrollIntoViewOptions + didAutoAssignLastSelectedId: boolean constructor( input: HTMLTextAreaElement | HTMLInputElement, @@ -44,6 +45,7 @@ export default class Combobox { this.scrollIntoViewOptions = scrollIntoViewOptions ?? {block: 'nearest', inline: 'nearest'} this.isComposing = false + this.didAutoAssignLastSelectedId = false if (!list.id) { list.id = `combobox-${Math.random().toString().slice(2, 6)}` @@ -126,11 +128,21 @@ export default class Combobox { el.removeAttribute('data-combobox-option-default') if (target === el) { - this.input.setAttribute('aria-activedescendant', target.id) + if (!target.id && !document.getElementById(`${this.list.id}-selected`)) { + target.id = `${this.list.id}-selected` + this.didAutoAssignLastSelectedId = true + } + if (target.id) { + this.input.setAttribute('aria-activedescendant', target.id) + } target.setAttribute('aria-selected', 'true') fireSelectEvent(target) target.scrollIntoView(this.scrollIntoViewOptions) } else { + if (el.id === `${this.list.id}-selected` && this.didAutoAssignLastSelectedId) { + el.removeAttribute('id') + this.didAutoAssignLastSelectedId = false + } el.removeAttribute('aria-selected') } } @@ -141,6 +153,10 @@ export default class Combobox { for (const el of this.list.querySelectorAll('[aria-selected="true"], [data-combobox-option-default="true"]')) { el.removeAttribute('aria-selected') el.removeAttribute('data-combobox-option-default') + if (el.id === `${this.list.id}-selected` && this.didAutoAssignLastSelectedId) { + el.removeAttribute('id') + this.didAutoAssignLastSelectedId = false + } } } diff --git a/test/test.js b/test/test.js index a39d783..429c018 100644 --- a/test/test.js +++ b/test/test.js @@ -410,4 +410,71 @@ describe('combobox-nav', function () { }) }) }) + + describe('with missing IDs on options', function () { + let input + let list + beforeEach(function () { + document.body.innerHTML = ` + + + ` + input = document.querySelector('input') + list = document.querySelector('ul') + }) + + afterEach(function () { + document.body.innerHTML = '' + }) + + it('automatically adds and removes option IDs when needed for aria-activedescendant', function () { + const combobox = new Combobox(input, list) + combobox.start() + assert.equal(input.getAttribute('aria-expanded'), 'true') + + press(input, 'ArrowDown') + assert.equal(input.getAttribute('aria-activedescendant'), 'list-id-selected') + assert.equal(list.children[0].getAttribute('id'), 'list-id-selected') + assert.equal(list.children[1].getAttribute('id'), 'hubot') + assert.equal(list.children[2].getAttribute('id'), null) + + press(input, 'ArrowDown') + assert.equal(input.getAttribute('aria-activedescendant'), 'hubot') + assert.equal(list.children[0].getAttribute('id'), null) + assert.equal(list.children[1].getAttribute('id'), 'hubot') + assert.equal(list.children[2].getAttribute('id'), null) + + press(input, 'ArrowDown') + assert.equal(input.getAttribute('aria-activedescendant'), 'list-id-selected') + assert.equal(list.children[0].getAttribute('id'), null) + assert.equal(list.children[1].getAttribute('id'), 'hubot') + assert.equal(list.children[2].getAttribute('id'), 'list-id-selected') + + press(input, 'Escape') + assert.equal(input.getAttribute('aria-activedescendant'), null) + assert.equal(list.children[0].getAttribute('id'), null) + assert.equal(list.children[1].getAttribute('id'), 'hubot') + assert.equal(list.children[2].getAttribute('id'), null) + }) + + it('avoids collisions with existing IDs when automatically adding option IDs', function () { + const div = document.createElement('div') + div.setAttribute('id', 'list-id-selected') + document.body.appendChild(div) + + const combobox = new Combobox(input, list) + combobox.start() + assert.equal(input.getAttribute('aria-expanded'), 'true') + + press(input, 'ArrowDown') + assert.equal(input.getAttribute('aria-activedescendant'), null) + assert.equal(list.children[0].getAttribute('id'), null) + assert.equal(list.children[1].getAttribute('id'), 'hubot') + assert.equal(list.children[2].getAttribute('id'), null) + }) + }) })