From 4833affdd78dff7b4fc3a10b627b3c43e3b3317c Mon Sep 17 00:00:00 2001 From: Ian Gloude Date: Thu, 27 Mar 2025 16:30:40 -0500 Subject: [PATCH 1/2] handle keyboard nav & announcer --- .../base/new_dropdowns/listbox/listbox.vue | 211 +++++++++++++----- .../new_dropdowns/listbox/listbox_item.vue | 27 ++- .../listbox/listbox_search_input.vue | 4 +- 3 files changed, 183 insertions(+), 59 deletions(-) diff --git a/src/components/base/new_dropdowns/listbox/listbox.vue b/src/components/base/new_dropdowns/listbox/listbox.vue index babb34b33c..35b83dbe21 100644 --- a/src/components/base/new_dropdowns/listbox/listbox.vue +++ b/src/components/base/new_dropdowns/listbox/listbox.vue @@ -48,6 +48,14 @@ const GROUP_TOP_BORDER_CLASSES = [ ]; export const SEARCH_INPUT_SELECTOR = '.gl-listbox-search-input'; +const FOCUS_STYLE = ` +.gl-new-dropdown-item-focused { + outline: 2px solid var(--blue-500) !important; + outline-offset: -2px !important; + z-index: 1 !important; +} +`; + export default { name: 'GlCollapsibleListbox', HEADER_ITEMS_BORDER_CLASSES, @@ -377,6 +385,9 @@ export default { searchStr: '', topBoundaryVisible: true, bottomBoundaryVisible: true, + activeDescendantId: null, + lastSelectionAnnouncement: '', + announcementTimeout: null, }; }, computed: { @@ -496,6 +507,12 @@ export default { hasFooter() { return Boolean(this.$scopedSlots.footer); }, + selectionAnnouncement() { + if (!this.lastSelectionAnnouncement) { + return ''; + } + return this.lastSelectionAnnouncement; + }, }, watch: { selected: { @@ -571,9 +588,17 @@ export default { this.open(); } this.observeScroll(); + + if (!document.getElementById('gl-listbox-focus-styles')) { + const styleEl = document.createElement('style'); + styleEl.id = 'gl-listbox-focus-styles'; + styleEl.textContent = FOCUS_STYLE; + document.head.appendChild(styleEl); + } }, beforeDestroy() { this.scrollObserver?.disconnect(); + clearTimeout(this.announcementTimeout); }, methods: { open() { @@ -589,20 +614,20 @@ export default { if (this.searchable) { this.focusSearchInput(); - /** - * If the search string is not empty, highlight the first item - */ if (this.searchHasOptions) { - this.nextFocusedItemIndex = 0; + const elements = this.getFocusableListItemElements(); + if (elements.length > 0) { + this.activateItem(0, elements); + } } } else { - this.focusItem(this.selectedIndices[0] ?? 0, this.getFocusableListItemElements()); + const itemIndex = this.selectedIndices[0] ?? 0; + const elements = this.getFocusableListItemElements(); + this.activateItem(itemIndex, elements); + + this.$refs.list?.focus(); } - /** - * Emitted when dropdown is shown - * - * @event shown - */ + this.$emit(GL_DROPDOWN_SHOWN); }, onHide() { @@ -615,73 +640,120 @@ export default { this.nextFocusedItemIndex = null; }, onKeydown(event) { - const { code, target } = event; + const { code } = event; const elements = this.getFocusableListItemElements(); - if (elements.length < 1) return; - - let stop = true; - const isSearchInput = target.matches(SEARCH_INPUT_SELECTOR); + if (elements.length === 0) return; if (code === HOME) { - if (isSearchInput) { - return; - } - this.focusItem(0, elements); + this.activateItem(0, elements); + event.preventDefault(); } else if (code === END) { - if (isSearchInput) { - return; - } - this.focusItem(elements.length - 1, elements); + this.activateItem(elements.length - 1, elements); + event.preventDefault(); } else if (code === ARROW_UP) { - if (isSearchInput) { - return; - } - if (this.searchable && elements.indexOf(target) === 0) { + this.handleArrowUp(event, elements); + } else if (code === ARROW_DOWN) { + this.handleArrowDown(event, elements); + } else if (code === ENTER || code === SPACE) { + this.handleEnterOrSpace(event); + } + }, + handleArrowUp(event, elements) { + const isSearchInput = event.target.matches(SEARCH_INPUT_SELECTOR); + + if (isSearchInput) { + return; + } + + const currentIndex = this.nextFocusedItemIndex !== null ? this.nextFocusedItemIndex : 0; + + if (currentIndex <= 0) { + if (this.searchable) { this.focusSearchInput(); - if (!this.searchHasOptions) { - this.nextFocusedItemIndex = null; - } + this.activeDescendantId = null; + this.nextFocusedItemIndex = null; } else { - this.focusNextItem(event, elements, -1); + this.activateItem(elements.length - 1, elements); } - } else if (code === ARROW_DOWN) { - if (isSearchInput) { - this.focusItem(0, elements); - } else { - this.focusNextItem(event, elements, 1); + } else { + this.activateItem(currentIndex - 1, elements); + } + + event.preventDefault(); + }, + handleArrowDown(event, elements) { + const isSearchInput = event.target.matches(SEARCH_INPUT_SELECTOR); + + if (isSearchInput) { + if (elements.length > 0) { + this.activateItem(0, elements); + event.preventDefault(); } - } else if (code === ENTER && isSearchInput) { - if (this.searchHasOptions && elements.length > 0) { + return; + } + + const currentIndex = this.nextFocusedItemIndex !== null ? this.nextFocusedItemIndex : -1; + + if (currentIndex >= elements.length - 1) { + this.activateItem(0, elements); + } else { + this.activateItem(currentIndex + 1, elements); + } + + event.preventDefault(); + }, + handleEnterOrSpace(event) { + const isSearchInput = event.target.matches(SEARCH_INPUT_SELECTOR); + + if (isSearchInput && event.code === ENTER) { + if (this.searchHasOptions && this.flattenedOptions.length > 0) { this.onSelect(this.flattenedOptions[0], true); + event.preventDefault(); } - stop = true; - } else { - stop = false; + } else if (!isSearchInput && this.nextFocusedItemIndex !== null) { + const currentItem = this.flattenedOptions[this.nextFocusedItemIndex]; + if (currentItem) { + this.onSelect(currentItem, !this.isSelected(currentItem)); + event.preventDefault(); + } + } + }, + activateItem(index, elements) { + if (index < 0 || !elements || elements.length === 0) { + this.activeDescendantId = null; + this.nextFocusedItemIndex = null; + return; } - if (stop) { - stopEvent(event); + this.nextFocusedItemIndex = index; + + const item = elements[index]; + if (!item) return; + + this.activeDescendantId = item.id; + + this.scrollItemIntoView(item); + }, + scrollItemIntoView(item) { + const container = this.$refs.list; + if (!container) return; + + const containerRect = container.getBoundingClientRect(); + const itemRect = item.getBoundingClientRect(); + + if (itemRect.top < containerRect.top) { + container.scrollTop -= containerRect.top - itemRect.top; + } else if (itemRect.bottom > containerRect.bottom) { + container.scrollTop += itemRect.bottom - containerRect.bottom; } }, getFocusableListItemElements() { const items = this.$refs.list?.querySelectorAll(ITEM_SELECTOR); return Array.from(items || []); }, - focusNextItem(event, elements, offset) { - const { target } = event; - const currentIndex = elements.indexOf(target); - const nextIndex = clamp(currentIndex + offset, 0, elements.length - 1); - - this.focusItem(nextIndex, elements); - }, - focusItem(index, elements) { - this.nextFocusedItemIndex = index; - - elements[index]?.focus(); - }, focusSearchInput() { - this.$refs.searchBox.focusInput(); + this.$refs.searchBox?.focusInput(); }, onSelect(item, isSelected) { if (this.multiple) { @@ -689,6 +761,9 @@ export default { } else { this.onSingleSelect(item.value, isSelected); } + + // Announce selection changes to screen readers + this.announceSelectionChange(item, isSelected); }, isHighlighted(item) { return this.nextFocusedItemIndex === this.flattenedOptions.indexOf(item); @@ -797,6 +872,17 @@ export default { this.scrollObserver = observer; }, isOption, + announceSelectionChange(item, isSelected) { + clearTimeout(this.announcementTimeout); + + const action = isSelected ? 'selected' : 'deselected'; + this.lastSelectionAnnouncement = `${item.text} ${action}`; + + // Clear the announcement after a short delay + this.announcementTimeout = setTimeout(() => { + this.lastSelectionAnnouncement = ''; + }, 3000); + }, }, }; @@ -889,6 +975,8 @@ export default { :id="listboxId" ref="list" :aria-labelledby="listAriaLabelledBy || headerId || toggleIdComputed" + :aria-activedescendant="this.activeDescendantId" + :aria-multiselectable="this.multiple ? 'true' : undefined" role="listbox" class="gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay" :class="listboxClasses" @@ -911,6 +999,7 @@ export default { :is-selected="isSelected(item)" :is-focused="isFocused(item)" :is-check-centered="isCheckCentered" + :unique-id="`${listboxId}-item-${index}`" v-bind="listboxItemMoreItemsAriaAttributes(index)" @select="onSelect(item, $event)" > @@ -990,5 +1079,15 @@ export default { + + + + {{ selectionAnnouncement }} + diff --git a/src/components/base/new_dropdowns/listbox/listbox_item.vue b/src/components/base/new_dropdowns/listbox/listbox_item.vue index 837837ad09..745b9b4f37 100644 --- a/src/components/base/new_dropdowns/listbox/listbox_item.vue +++ b/src/components/base/new_dropdowns/listbox/listbox_item.vue @@ -2,6 +2,7 @@ import GlIcon from '../../icon/icon.vue'; import { ENTER, SPACE } from '../constants'; import { stopEvent } from '../../../../utils/utils'; +import uniqueId from 'lodash/uniqueId'; export default { name: 'GlListboxItem', @@ -29,6 +30,13 @@ export default { default: false, required: false, }, + uniqueId: { + type: String, + required: false, + default() { + return uniqueId('listbox-item-'); + }, + }, }, computed: { checkedClasses() { @@ -38,6 +46,16 @@ export default { return 'gl-mt-3 gl-self-start'; }, + listboxItemClasses() { + return [ + 'gl-new-dropdown-item', + { + 'gl-new-dropdown-item-highlighted': this.isHighlighted, + 'gl-new-dropdown-item-focused': this.isFocused, + 'gl-new-dropdown-item-selected': this.isSelected, + }, + ]; + }, }, methods: { toggleSelection() { @@ -52,14 +70,18 @@ export default { } }, }, + created() { + this.$attrs.id = this.uniqueId; + }, };