From ad5582b1eb77ab1020c0f7eedd8c064393eb971c Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 25 Nov 2023 02:48:49 -0500 Subject: [PATCH 1/2] feat: add tabindex to list item to control up/down arrow direction - add tabIndex on every list item and select-all checkbox so that we can use up/down arrows to focus on next or previous item. - divider will be skipped and ignored - using Enter or Space key will select/unselect the input checkbox of the current item --- lib/src/MultipleSelectInstance.ts | 75 +++++++++++++++++++++++++------ playwright/e2e/example02.spec.ts | 19 +++++++- playwright/e2e/example03.spec.ts | 28 ++++++++++-- 3 files changed, 104 insertions(+), 18 deletions(-) diff --git a/lib/src/MultipleSelectInstance.ts b/lib/src/MultipleSelectInstance.ts index a84ac752..b736a71c 100644 --- a/lib/src/MultipleSelectInstance.ts +++ b/lib/src/MultipleSelectInstance.ts @@ -48,7 +48,7 @@ export class MultipleSelectInstance { protected selectAllName = ''; protected selectGroupName = ''; protected selectItemName = ''; - protected tabIndex?: string | null; + protected tabIndex?: number; protected updateDataStart?: number; protected updateDataEnd?: number; protected virtualScroll?: VirtualScroll | null; @@ -186,17 +186,17 @@ export class MultipleSelectInstance { // add placeholder to choice button this.options.placeholder = this.options.placeholder || this.elm.getAttribute('placeholder') || ''; - this.tabIndex = this.elm.getAttribute('tabindex'); - let tabIndex = ''; + this.tabIndex = this.elm.tabIndex; + let tabIndex: number | undefined; if (this.tabIndex !== null) { this.elm.tabIndex = -1; - tabIndex = this.tabIndex && `tabindex="${this.tabIndex}"`; + tabIndex = this.tabIndex; } this.choiceElm = createDomElement('button', { className: 'ms-choice', type: 'button' }, this.parentElm); - if (isNaN(tabIndex as any)) { - this.choiceElm.tabIndex = +tabIndex; + if (tabIndex !== undefined) { + this.choiceElm.tabIndex = tabIndex; } this.choiceElm.appendChild(createDomElement('span', { className: 'ms-placeholder', textContent: this.options.placeholder })); @@ -394,7 +394,7 @@ export class MultipleSelectInstance { if (this.options.selectAll && !this.options.single) { const selectName = this.elm.getAttribute('name') || this.options.name || ''; - this.selectAllParentElm = createDomElement('div', { className: 'ms-select-all' }); + this.selectAllParentElm = createDomElement('div', { className: 'ms-select-all', tabIndex: 0 }); const saLabelElm = document.createElement('label'); createDomElement( 'input', @@ -403,6 +403,7 @@ export class MultipleSelectInstance { ariaChecked: String(this.allSelected), checked: this.allSelected, dataset: { name: `selectAll${selectName}` }, + tabIndex: -1, }, saLabelElm ); @@ -491,7 +492,7 @@ export class MultipleSelectInstance { const rows: HtmlStruct[] = []; this.updateData = []; this.data?.forEach((row) => rows.push(...this.initListItem(row))); - rows.push({ tagName: 'li', props: { className: 'ms-no-results', textContent: this.formatNoMatchesFound() } }); + rows.push({ tagName: 'li', props: { className: 'ms-no-results', textContent: this.formatNoMatchesFound(), tabIndex: 0 } }); return rows; } @@ -531,6 +532,7 @@ export class MultipleSelectInstance { ariaChecked: String(row.selected || false), checked: Boolean(row.selected), disabled: row.disabled, + tabIndex: -1, }, }; @@ -543,7 +545,7 @@ export class MultipleSelectInstance { const liBlock: HtmlStruct = { tagName: 'li', - props: { className: `group ${classes}`.trim() }, + props: { className: `group ${classes}`.trim(), tabIndex: classes.includes('hide-radio') || row.disabled ? -1 : 0 }, children: [ { tagName: 'label', @@ -593,6 +595,7 @@ export class MultipleSelectInstance { ariaChecked: String(row.selected || false), checked: Boolean(row.selected), disabled: Boolean(row.disabled), + tabIndex: -1, }, }; @@ -602,7 +605,7 @@ export class MultipleSelectInstance { const liBlock: HtmlStruct = { tagName: 'li', - props: { className: liClasses, title }, + props: { className: liClasses, title, tabIndex: row.disabled ? -1 : 0 }, children: [{ tagName: 'label', props: { className: labelClasses }, children: [inputBlock, spanLabelBlock] }], }; @@ -850,6 +853,52 @@ export class MultipleSelectInstance { close(); }) as EventListener); + + // add keydown event listeners to watch for up/down arrows and focus on previous/next item + // we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox + const nodes = Array.from(this.dropElm.querySelectorAll('div.ms-select-all, li')); + nodes.forEach((liElm, idx) => { + this._bindEventService.bind(liElm, 'keydown', ((e: KeyboardEvent & { target: HTMLElement }) => { + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + if (idx > 0) { + this.focusOnUpDownItem(nodes, idx, e.key); + } + break; + case 'ArrowDown': + e.preventDefault(); + if (idx < nodes.length - 1) { + this.focusOnUpDownItem(nodes, idx, e.key); + } + break; + case 'Enter': + case ' ': + e.preventDefault(); + liElm.querySelector('input')?.click(); + if (this.options.single) { + this.choiceElm.focus(); + } + break; + default: + // ignore + } + }) as EventListener); + }); + } + + /** focus on next up/down item depending on arrow key pressed, we will ignore divider and focus on next item */ + protected focusOnUpDownItem(items: HTMLElement[], itemIdx: number, direction: 'ArrowUp' | 'ArrowDown') { + let currentIdx = itemIdx; + let dirElm: HTMLElement | null; + while ((dirElm = items[direction === 'ArrowUp' ? currentIdx - 1 : currentIdx + 1])) { + if (dirElm.classList.contains('option-divider')) { + direction === 'ArrowUp' ? currentIdx-- : currentIdx++; + continue; + } + break; + } + dirElm?.focus(); } /** @@ -1326,7 +1375,7 @@ export class MultipleSelectInstance { const rowLabel = `${(row as OptGroupRowData)?.label ?? ''}`; if (row !== undefined && row !== null) { const visible = this.options.customFilter({ - label: removeDiacritics(rowLabel.toLowerCase()), + label: removeDiacritics(rowLabel.toString().toLowerCase()), search: removeDiacritics(search), originalLabel: rowLabel, originalSearch, @@ -1345,7 +1394,7 @@ export class MultipleSelectInstance { if (child !== undefined && child !== null) { const childText = `${(child as OptionRowData)?.text ?? ''}`; child.visible = this.options.customFilter({ - text: removeDiacritics(childText.toLowerCase()), + text: removeDiacritics(childText.toString().toLowerCase()), search: removeDiacritics(search), originalText: childText, originalSearch, @@ -1359,7 +1408,7 @@ export class MultipleSelectInstance { } else { const rowText = `${(row as OptionRowData)?.text ?? ''}`; row.visible = this.options.customFilter({ - text: removeDiacritics(rowText.toLowerCase()), + text: removeDiacritics(rowText.toString().toLowerCase()), search: removeDiacritics(search), originalText: rowText, originalSearch, diff --git a/playwright/e2e/example02.spec.ts b/playwright/e2e/example02.spec.ts index 66a41a69..8bedc537 100644 --- a/playwright/e2e/example02.spec.ts +++ b/playwright/e2e/example02.spec.ts @@ -8,8 +8,25 @@ test.describe('Example 02 - Multiple Select', () => { await page.locator('span').filter({ hasText: 'April' }).click(); await page.locator('span').filter({ hasText: 'May' }).click(); const parent1Span = await page.locator('div[data-test=select1] .ms-choice span'); - await page.getByRole('button', { name: 'February, April, May' }).click(); + await expect(parent1Span).toHaveText('February, April, May'); + await page.keyboard.press('ArrowDown'); + const juneLoc = await page.locator('div[data-test=select1] .ms-drop li:nth-of-type(6)'); + await expect(juneLoc).toBeFocused(); + await expect(await juneLoc.locator('label')).toHaveText('June'); + await page.keyboard.press('Enter'); + await expect(parent1Span).toHaveText('4 of 12 selected'); + + // go up until we reach "Select All" and use Space to press the option + page.keyboard.press('ArrowUp'); + page.keyboard.press('ArrowUp'); + page.keyboard.press('ArrowUp'); + page.keyboard.press('ArrowUp'); + page.keyboard.press('ArrowUp'); + page.keyboard.press('ArrowUp'); + page.keyboard.press('Space'); + await expect(parent1Span).toHaveText('All selected'); + await page.getByRole('button', { name: 'All selected' }).click(); }); test('second select with multiple selection with optgroup and expect entire group to be selected', async ({ page }) => { diff --git a/playwright/e2e/example03.spec.ts b/playwright/e2e/example03.spec.ts index bd4d61f0..ead826a8 100644 --- a/playwright/e2e/example03.spec.ts +++ b/playwright/e2e/example03.spec.ts @@ -6,12 +6,20 @@ test.describe('Example 03 - Multiple Width', () => { await page.locator('div[data-test=select1].ms-parent').click(); await page.getByRole('listitem').filter({ hasText: '30' }).locator('label').click(); await page.getByRole('checkbox', { name: '15' }).check(); - const elm16 = await page.locator('label').filter({ hasText: '16' }); + let elm16 = await page.locator('label').filter({ hasText: '16' }); await elm16.click(); expect((await elm16!.boundingBox())!.width).toBe(44); + + elm16 = await page.locator('div[data-test=select1] .ms-drop li:nth-of-type(16)'); + await elm16.focus(); + await expect(elm16).toBeFocused(); + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('Space'); // unselect 15 + let parent1Span = await page.locator('div[data-test=select1] .ms-choice span'); + await expect(parent1Span).toHaveText('16, 30'); + await page.keyboard.press('Enter'); // reselect 15 + parent1Span = await page.locator('div[data-test=select1] .ms-choice span'); await page.getByRole('button', { name: '15, 16, 30' }).click(); - const parent1Span = await page.locator('div[data-test=select1] .ms-choice span'); - await expect(parent1Span).toHaveText('15, 16, 30'); }); test('second select and expect optgroup selection to select the entire group when optgroup is selected', async ({ page }) => { @@ -34,6 +42,18 @@ test.describe('Example 03 - Multiple Width', () => { await page.getByRole('checkbox', { name: '3', exact: true }).check(); const selectAll2 = await page.locator('[data-test=select2] .ms-select-all input[data-name=selectAll]'); await expect(selectAll2).toBeChecked(); - await page.getByRole('button', { name: 'All selected' }).click(); + await page.getByRole('button', { name: 'All selected' }); + const selectAllLoc = await page.locator('div[data-test=select2] .ms-drop .ms-select-all'); + await selectAllLoc.focus(); + await page.keyboard.press('ArrowDown'); // unselect Group 1 + await page.keyboard.press('Space'); + let parent2Span = await page.locator('div[data-test=select2] .ms-choice span'); + await expect(parent2Span).toHaveText('10 of 15 selected'); + await page.keyboard.press('ArrowDown'); // unselect Group 1 -> 1st item + await page.keyboard.press('Enter'); + parent2Span = await page.locator('div[data-test=select2] .ms-choice span'); + await expect(parent2Span).toHaveText('11 of 15 selected'); + const group1item1Loc = await page.locator('[data-test=select2] .ms-drop li:nth-of-type(2) input'); + await expect(group1item1Loc).toBeChecked(); }); }); From 2d103f6059c8da0b0f8cb78f6d13e97c29f5d435 Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 25 Nov 2023 16:31:47 -0500 Subject: [PATCH 2/2] fix: tabindex should work with v-scroll & add groupName to unbindAll - when using VirtualScroll, it recreates the dropdown list and we need to unbind previous listeners and rebind new listener, also when the list gets recreated then we also need to refocus on the same item but in the new list - add group name to unbindAll to easily unbind events when dealing with VirtualScroll --- lib/src/MultipleSelectInstance.ts | 337 +++++++++++++--------- lib/src/services/binding-event.service.ts | 73 +++-- lib/src/utils/domUtils.ts | 2 +- 3 files changed, 253 insertions(+), 159 deletions(-) diff --git a/lib/src/MultipleSelectInstance.ts b/lib/src/MultipleSelectInstance.ts index b736a71c..3dd2409f 100644 --- a/lib/src/MultipleSelectInstance.ts +++ b/lib/src/MultipleSelectInstance.ts @@ -34,6 +34,7 @@ export class MultipleSelectInstance { protected dropElm!: HTMLDivElement; protected okButtonElm?: HTMLButtonElement; protected filterParentElm?: HTMLDivElement | null; + protected lastFocusedItemKey = ''; protected ulElm?: HTMLUListElement | null; protected parentElm!: HTMLDivElement; protected labelElm?: HTMLLabelElement | null; @@ -42,7 +43,6 @@ export class MultipleSelectInstance { protected searchInputElm?: HTMLInputElement | null; protected selectGroupElms?: NodeListOf; protected selectItemElms?: NodeListOf; - protected disableItemElms?: NodeListOf; protected noResultsElm?: HTMLDivElement | null; protected options: MultipleSelectOption; protected selectAllName = ''; @@ -240,19 +240,25 @@ export class MultipleSelectInstance { this.selectItemName = `selectItem${name}`; if (!this.options.keepOpen) { - this._bindEventService.unbind(document.body, 'click'); - this._bindEventService.bind(document.body, 'click', ((e: MouseEvent & { target: HTMLElement }) => { - if (e.target === this.choiceElm || findParent(e.target, '.ms-choice') === this.choiceElm) { - return; - } + this._bindEventService.unbindAll('body-click'); + this._bindEventService.bind( + document.body, + 'click', + ((e: MouseEvent & { target: HTMLElement }) => { + if (e.target === this.choiceElm || findParent(e.target, '.ms-choice') === this.choiceElm) { + return; + } - if ( - (e.target === this.dropElm || (findParent(e.target, '.ms-drop') !== this.dropElm && e.target !== this.elm)) && - this.options.isOpen - ) { - this.close(); - } - }) as EventListener); + if ( + (e.target === this.dropElm || (findParent(e.target, '.ms-drop') !== this.dropElm && e.target !== this.elm)) && + this.options.isOpen + ) { + this.close(); + } + }) as EventListener, + undefined, + 'body-click' + ); } } @@ -521,7 +527,7 @@ export class MultipleSelectInstance { // - group option row - const htmlBlocks: HtmlStruct[] = []; - const groupBlock: HtmlStruct = + const itemOrGroupBlock: HtmlStruct = this.options.hideOptgroupCheckboxes || this.options.single ? { tagName: 'span', props: { dataset: { name: this.selectGroupName, key: row._key } } } : { @@ -545,12 +551,15 @@ export class MultipleSelectInstance { const liBlock: HtmlStruct = { tagName: 'li', - props: { className: `group ${classes}`.trim(), tabIndex: classes.includes('hide-radio') || row.disabled ? -1 : 0 }, + props: { + className: `group ${classes}`.trim(), + tabIndex: classes.includes('hide-radio') || row.disabled ? -1 : 0, + }, children: [ { tagName: 'label', props: { className: `optgroup${this.options.single || row.disabled ? ' disabled' : ''}` }, - children: [groupBlock, spanLabelBlock], + children: [itemOrGroupBlock, spanLabelBlock], }, ], }; @@ -605,7 +614,7 @@ export class MultipleSelectInstance { const liBlock: HtmlStruct = { tagName: 'li', - props: { className: liClasses, title, tabIndex: row.disabled ? -1 : 0 }, + props: { className: liClasses, title, tabIndex: row.disabled ? -1 : 0, dataset: { key: row._key } }, children: [{ tagName: 'label', props: { className: labelClasses }, children: [inputBlock, spanLabelBlock] }], }; @@ -676,13 +685,13 @@ export class MultipleSelectInstance { } protected events() { - this._bindEventService.unbind(this.okButtonElm); - this._bindEventService.unbind(this.searchInputElm); - this._bindEventService.unbind(this.selectAllElm); - this._bindEventService.unbind(this.selectGroupElms); - this._bindEventService.unbind(this.selectItemElms); - this._bindEventService.unbind(this.disableItemElms); - this._bindEventService.unbind(this.noResultsElm); + this._bindEventService.unbindAll([ + 'ok-button', + 'search-input', + 'select-all-checkbox', + 'input-checkbox-list', + 'group-checkbox-list', + ]); this.closeSearchElm = this.filterParentElm?.querySelector('.icon-close'); this.searchInputElm = this.dropElm.querySelector('.ms-search input'); @@ -691,7 +700,6 @@ export class MultipleSelectInstance { `input[data-name="${this.selectGroupName}"],span[data-name="${this.selectGroupName}"]` ); this.selectItemElms = this.dropElm.querySelectorAll(`input[data-name="${this.selectItemName}"]:enabled`); - this.disableItemElms = this.dropElm.querySelectorAll(`input[data-name="${this.selectItemName}"]:disabled`); this.noResultsElm = this.dropElm.querySelector('.ms-no-results'); const toggleOpen = (e: MouseEvent & { target: HTMLElement }) => { @@ -753,138 +761,193 @@ export class MultipleSelectInstance { } if (this.searchInputElm) { - this._bindEventService.bind(this.searchInputElm, 'keydown', ((e: KeyboardEvent) => { - // Ensure shift-tab causes lost focus from filter as with clicking away - if (e.code === 'Tab' && e.shiftKey) { - this.close(); - } - }) as EventListener); + this._bindEventService.bind( + this.searchInputElm, + 'keydown', + ((e: KeyboardEvent) => { + // Ensure shift-tab causes lost focus from filter as with clicking away + if (e.code === 'Tab' && e.shiftKey) { + this.close(); + } + }) as EventListener, + undefined, + 'search-input' + ); - this._bindEventService.bind(this.searchInputElm, 'keyup', ((e: KeyboardEvent) => { - // enter or space - // Avoid selecting/deselecting if no choices made - if (this.options.filterAcceptOnEnter && ['Enter', 'Space'].includes(e.code) && this.searchInputElm?.value) { - if (this.options.single) { - const visibleLiElms: HTMLInputElement[] = []; - this.selectItemElms?.forEach((selectedElm) => { - if (selectedElm.closest('li')?.style.display !== 'none') { - visibleLiElms.push(selectedElm); + this._bindEventService.bind( + this.searchInputElm, + 'keyup', + ((e: KeyboardEvent) => { + // enter or space + // Avoid selecting/deselecting if no choices made + if (this.options.filterAcceptOnEnter && ['Enter', 'Space'].includes(e.code) && this.searchInputElm?.value) { + if (this.options.single) { + const visibleLiElms: HTMLInputElement[] = []; + this.selectItemElms?.forEach((selectedElm) => { + if (selectedElm.closest('li')?.style.display !== 'none') { + visibleLiElms.push(selectedElm); + } + }); + if (visibleLiElms.length && visibleLiElms[0].hasAttribute('data-name')) { + this.setSelects([visibleLiElms[0].value]); } - }); - if (visibleLiElms.length && visibleLiElms[0].hasAttribute('data-name')) { - this.setSelects([visibleLiElms[0].value]); + } else { + this.selectAllElm?.click(); } - } else { - this.selectAllElm?.click(); + this.close(); + this.focus(); + return; } - this.close(); - this.focus(); - return; - } - this.filter(); - }) as EventListener); + this.filter(); + }) as EventListener, + undefined, + 'search-input' + ); } if (this.selectAllElm) { - this._bindEventService.unbind(this.selectAllElm, 'click'); - this._bindEventService.bind(this.selectAllElm, 'click', ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { - this._checkAll(e.currentTarget?.checked); - }) as EventListener); + this._bindEventService.bind( + this.selectAllElm, + 'click', + ((e: MouseEvent & { currentTarget: HTMLInputElement }) => this._checkAll(e.currentTarget?.checked)) as EventListener, + undefined, + 'select-all-checkbox' + ); } if (this.okButtonElm) { - this._bindEventService.unbind(this.okButtonElm, 'click'); - this._bindEventService.bind(this.okButtonElm, 'click', ((e: MouseEvent & { target: HTMLElement }) => { - toggleOpen(e); - e.stopPropagation(); // Causes lost focus otherwise - }) as EventListener); + this._bindEventService.bind( + this.okButtonElm, + 'click', + ((e: MouseEvent & { target: HTMLElement }) => { + toggleOpen(e); + e.stopPropagation(); // Causes lost focus otherwise + }) as EventListener, + undefined, + 'ok-button' + ); } - this._bindEventService.bind(this.selectGroupElms, 'click', ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { - const selectElm = e.currentTarget; - const checked = selectElm.checked; - const group = findByParam(this.data, '_key', selectElm.dataset.key); - - this._checkGroup(group, checked); - this.options.onOptgroupClick( - removeUndefined({ - label: group.label, - selected: group.selected, - data: group._data, - children: group.children.map((child: any) => { - if (child) { - return removeUndefined({ - text: child.text, - value: child.value, - selected: child.selected, - disabled: child.disabled, - data: child._data, - }); - } - }), - }) - ); - }) as EventListener); + this._bindEventService.bind( + this.selectGroupElms, + 'click', + ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { + const selectElm = e.currentTarget; + const checked = selectElm.checked; + const group = findByParam(this.data, '_key', selectElm.dataset.key); + + this._checkGroup(group, checked); + this.options.onOptgroupClick( + removeUndefined({ + label: group.label, + selected: group.selected, + data: group._data, + children: group.children.map((child: any) => { + if (child) { + return removeUndefined({ + text: child.text, + value: child.value, + selected: child.selected, + disabled: child.disabled, + data: child._data, + }); + } + }), + }) + ); + }) as EventListener, + undefined, + 'group-checkbox-list' + ); - this._bindEventService.bind(this.selectItemElms, 'click', ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { - const selectElm = e.currentTarget; - const checked = selectElm.checked; - const option = findByParam(this.data, '_key', selectElm.dataset.key); - const close = () => { - if (this.options.single && this.options.isOpen && !this.options.keepOpen) { - this.close(); + this._bindEventService.bind( + this.selectItemElms, + 'click', + ((e: MouseEvent & { currentTarget: HTMLInputElement }) => { + const selectElm = e.currentTarget; + const checked = selectElm.checked; + const option = findByParam(this.data, '_key', selectElm.dataset.key); + const close = () => { + if (this.options.single && this.options.isOpen && !this.options.keepOpen) { + this.close(); + } + }; + + if (this.options.onBeforeClick(option) === false) { + close(); + return; } - }; - if (this.options.onBeforeClick(option) === false) { - close(); - return; - } + this._check(option, checked); + this.options.onClick( + removeUndefined({ + text: option.text, + value: option.value, + selected: option.selected, + data: option._data, + }) + ); - this._check(option, checked); - this.options.onClick( - removeUndefined({ - text: option.text, - value: option.value, - selected: option.selected, - data: option._data, - }) - ); + close(); + }) as EventListener, + undefined, + 'input-checkbox-list' + ); - close(); - }) as EventListener); + // if we previously had an item focused and the VirtualScroll recreates the list, we need to refocus on last item by its input data-key + if (this.lastFocusedItemKey) { + const input = this.dropElm.querySelector(`li[data-key=${this.lastFocusedItemKey}]`); + input?.focus(); + } // add keydown event listeners to watch for up/down arrows and focus on previous/next item // we will ignore divider and if key pressed is the Enter/Space key then we'll instead select/deselect input checkbox + // we will also remove any previous bindings that might exist which happen when we use VirtualScroll const nodes = Array.from(this.dropElm.querySelectorAll('div.ms-select-all, li')); - nodes.forEach((liElm, idx) => { - this._bindEventService.bind(liElm, 'keydown', ((e: KeyboardEvent & { target: HTMLElement }) => { - switch (e.key) { - case 'ArrowUp': - e.preventDefault(); - if (idx > 0) { - this.focusOnUpDownItem(nodes, idx, e.key); - } - break; - case 'ArrowDown': - e.preventDefault(); - if (idx < nodes.length - 1) { - this.focusOnUpDownItem(nodes, idx, e.key); - } - break; - case 'Enter': - case ' ': - e.preventDefault(); - liElm.querySelector('input')?.click(); - if (this.options.single) { - this.choiceElm.focus(); + this._bindEventService.unbindAll('tabindex-arrow'); + this._bindEventService.bind( + this.dropElm, + 'keydown', + ((e: KeyboardEvent & { target: HTMLDivElement | HTMLLIElement }) => { + const liElm = e.target.closest('.ms-select-all') || e.target.closest('li'); + if (this.dropElm.contains(liElm)) { + let idx = 0; + const nodeLn = nodes.length; + for (idx = 0; idx < nodeLn; idx++) { + if (nodes[idx].isEqualNode(liElm)) { + break; } - break; - default: - // ignore + } + switch (e.key) { + case 'ArrowUp': + e.preventDefault(); + if (idx > 0) { + this.lastFocusedItemKey = this.focusOnUpDownItem(nodes, idx, e.key)?.dataset.key || ''; + } + break; + case 'ArrowDown': + e.preventDefault(); + if (idx < nodes.length - 1) { + this.lastFocusedItemKey = this.focusOnUpDownItem(nodes, idx, e.key)?.dataset.key || ''; + } + break; + case 'Enter': + case ' ': + e.preventDefault(); + liElm!.querySelector('input')?.click(); + if (this.options.single) { + this.choiceElm.focus(); + this.lastFocusedItemKey = this.choiceElm?.dataset.key || ''; + } + break; + default: + // ignore + } } - }) as EventListener); - }); + }) as EventListener, + undefined, + 'tabindex-arrow' + ); } /** focus on next up/down item depending on arrow key pressed, we will ignore divider and focus on next item */ @@ -898,7 +961,11 @@ export class MultipleSelectInstance { } break; } - dirElm?.focus(); + if (dirElm) { + dirElm.focus(); + return dirElm; + } + return null; } /** diff --git a/lib/src/services/binding-event.service.ts b/lib/src/services/binding-event.service.ts index 7125469e..f2595726 100644 --- a/lib/src/services/binding-event.service.ts +++ b/lib/src/services/binding-event.service.ts @@ -1,7 +1,8 @@ export interface ElementEventListener { element: Element; - eventName: string; - listener: EventListenerOrEventListenerObject; + eventName: keyof HTMLElementEventMap; + listener: EventListener; + groupName?: string; } export class BindingEventService { @@ -22,41 +23,50 @@ export class BindingEventService { } /** Bind an event listener to any element */ - bind( - elementOrElements: Element | NodeListOf, - eventNameOrNames: string | string[], - listener: EventListenerOrEventListenerObject, - options?: boolean | AddEventListenerOptions + bind( + elementOrElements: H | NodeListOf | Window, + eventNameOrNames: keyof HTMLElementEventMap | Array, + listener: EventListener, + listenerOptions?: boolean | AddEventListenerOptions, + groupName = '' ) { + // convert to array for looping in next task const eventNames = Array.isArray(eventNameOrNames) ? eventNameOrNames : [eventNameOrNames]; - if ((elementOrElements as NodeListOf)?.forEach) { - (elementOrElements as NodeListOf)?.forEach((element) => { + if (typeof (elementOrElements as NodeListOf)?.forEach === 'function') { + // multiple elements to bind to + (elementOrElements as NodeListOf).forEach((element) => { for (const eventName of eventNames) { if (!this._distinctEvent || (this._distinctEvent && !this.hasBinding(element, eventName))) { - element.addEventListener(eventName, listener, options); - this._boundedEvents.push({ element, eventName, listener }); + element.addEventListener(eventName, listener as EventListener, listenerOptions); + this._boundedEvents.push({ element, eventName, listener: listener as EventListener, groupName }); } } }); } else { + // single elements to bind to for (const eventName of eventNames) { - if (!this._distinctEvent || (this._distinctEvent && !this.hasBinding(elementOrElements as Element, eventName))) { - (elementOrElements as Element).addEventListener(eventName, listener, options); - this._boundedEvents.push({ element: elementOrElements as Element, eventName, listener }); + if (!this._distinctEvent || (this._distinctEvent && !this.hasBinding(elementOrElements as H, eventName))) { + (elementOrElements as H).addEventListener(eventName, listener as EventListener, listenerOptions); + this._boundedEvents.push({ + element: elementOrElements as H, + eventName, + listener: listener as EventListener, + groupName, + }); } } } } - hasBinding(elm: Element, eventNameOrNames?: string | string[]): boolean { + hasBinding(elm: Element, eventNameOrNames?: keyof HTMLElementEventMap | Array): boolean { return this._boundedEvents.some((f) => f.element === elm && (!eventNameOrNames || f.eventName === eventNameOrNames)); } - /** Unbind all will remove every every event handlers that were bounded earlier */ + /** Unbind a specific listener that was bounded earlier */ unbind( elementOrElements?: Element | NodeListOf | null, - eventNameOrNames?: string | string[], + eventNameOrNames?: keyof HTMLElementEventMap | Array, listener?: EventListenerOrEventListenerObject | null ) { if (elementOrElements) { @@ -80,12 +90,29 @@ export class BindingEventService { } } - /** Unbind all will remove every every event handlers that were bounded earlier */ - unbindAll() { - while (this._boundedEvents.length > 0) { - const boundedEvent = this._boundedEvents.pop() as ElementEventListener; - const { element, eventName, listener } = boundedEvent; - this.unbind(element, eventName, listener); + /** + * Unbind all event listeners that were bounded, optionally provide a group name to unbind all listeners assigned to that specific group only. + */ + unbindAll(groupName?: string | string[]) { + if (groupName) { + const groupNames = Array.isArray(groupName) ? groupName : [groupName]; + // unbind only the bounded event with a specific group + // Note: we need to loop in reverse order to avoid array reindexing (causing index offset) after a splice is called + for (let i = this._boundedEvents.length - 1; i >= 0; --i) { + const boundedEvent = this._boundedEvents[i]; + if (groupNames.some((g) => g === boundedEvent.groupName)) { + const { element, eventName, listener } = boundedEvent; + this.unbind(element, eventName, listener); + this._boundedEvents.splice(i, 1); + } + } + } else { + // unbind everything + while (this._boundedEvents.length > 0) { + const boundedEvent = this._boundedEvents.pop() as ElementEventListener; + const { element, eventName, listener } = boundedEvent; + this.unbind(element, eventName, listener); + } } } } diff --git a/lib/src/utils/domUtils.ts b/lib/src/utils/domUtils.ts index 8c80bcba..c6d02d6d 100644 --- a/lib/src/utils/domUtils.ts +++ b/lib/src/utils/domUtils.ts @@ -99,7 +99,7 @@ export function createDomStructure(item: HtmlStruct, appendToElm?: HTMLElement, delete item.props.innerHTML; } - const elm = createDomElement(item.tagName, objectRemoveEmptyProps(item.props, ['class', 'title', 'style']), appendToElm); + const elm = createDomElement(item.tagName, objectRemoveEmptyProps(item.props, ['className', 'title', 'style']), appendToElm); let parent: HTMLElement | null | undefined = parentElm; if (!parent) { parent = elm;