From 489a64d5eeaeee99cb8aaf6fbfcccb98d86304e3 Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Fri, 13 Mar 2026 13:06:33 +0100 Subject: [PATCH] Enhance global search with keyboard navigation and improved selection: - Introduce keyboard navigation for sidebar and results, including pane switching and item selection. - Add visual indicators for selected sidebar groups and results. - Refine result sorting and grouping logic with utility methods. - Update modal accessibility with focus management and keyboard event handling. - Extend unit tests to cover keyboard navigation and UI state sync. - Apply scoped styles for interactive states and selected elements. --- .../menu/NavigationMenuGlobalSearch.vue | 214 ++++++++++++++++-- .../unit/system-search-modal-contract.spec.js | 7 + 2 files changed, 206 insertions(+), 15 deletions(-) diff --git a/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue b/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue index e8d9512f..1be582be 100644 --- a/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue +++ b/src/components/viewport/page/headers/menu/NavigationMenuGlobalSearch.vue @@ -20,6 +20,14 @@ import { type SystemSearchNavigationTarget, type SystemSearchViewModel } from '@/components/viewport/page/headers/menu/systemSearchSupport'; +import { + moveSystemSearchSelection, + groupSystemSearchResults, + sortSystemSearchResults, + syncSystemSearchKeyboardState, + type SystemSearchKeyboardPane, + type SystemSearchKeyboardState +} from '@/components/viewport/page/headers/menu/systemSearchUiSupport'; type SearchMethod = 'POST' | 'GET'; type CacheRebuildScope = 'all' | 'types' | 'dirty'; @@ -64,6 +72,7 @@ const isModalOpen = ref(false); const method = ref('POST'); const query = ref(''); const queryInputRef = ref(null); +const searchShellRef = ref(null); const includeTypes = ref([]); const excludeTypes = ref([]); const includeAssociations = ref(true); @@ -116,8 +125,8 @@ const toggleCacheType = (entityType: EntityType, checked: boolean) => toggleType const includeTypeOptions = computed(() => ENTITY_TYPES.filter((t) => !excludeTypes.value.includes(t))); const excludeTypeOptions = computed(() => ENTITY_TYPES.filter((t) => !includeTypes.value.includes(t))); const payload = computed(() => response.value?.data ?? null); -const results = computed(() => payload.value?.results ?? []); -const groupedMap = computed>(() => payload.value?.grouped_results ?? {}); +const results = computed(() => sortSystemSearchResults(payload.value?.results ?? [])); +const groupedMap = computed>(() => groupSystemSearchResults(results.value)); const grouped = computed<[string, SearchResult[]][]>(() => Object.entries(groupedMap.value)); const groupResults = computed(() => { if (selectedGroup.value === 'all') return results.value; @@ -196,6 +205,17 @@ const focusQueryInput = () => { }); }; +const focusSearchShell = () => { + nextTick(() => { + searchShellRef.value?.focus(); + }); +}; + +const focusQueryFromKeyboardNavigation = () => { + setKeyboardPane('query'); + focusQueryInput(); +}; + const DOUBLE_SHIFT_MAX_INTERVAL_MS = 420; const lastStandaloneShiftReleaseAt = ref(0); const shiftIsDown = ref(false); @@ -261,9 +281,21 @@ const onGlobalKeyUp = (event: KeyboardEvent) => { }; watch(isModalOpen, (open) => { - if (!open) return; + if (!open) { + keyboardState.value = { + pane: 'query', + selectedSidebarKey: 'all', + selectedResultKey: null + }; + return; + } activeSetting.value = null; settingsOpen.value = false; + keyboardState.value = { + pane: 'query', + selectedSidebarKey: selectedGroup.value, + selectedResultKey: visibleResultCardKeys.value[0] ?? null + }; focusQueryInput(); }); @@ -669,13 +701,7 @@ const buildPostBody = (pagination?: { limit?: number; offset?: number }) => { }; const groupRows = (rows: SearchResult[]) => { - const groupedRows: Record = {}; - for (const row of rows) { - const key = row.entity_type; - if (!groupedRows[key]) groupedRows[key] = []; - groupedRows[key].push(row); - } - return groupedRows; + return groupSystemSearchResults(sortSystemSearchResults(rows)); }; const resetSearchProgress = () => { @@ -777,6 +803,12 @@ const runSearch = async (nextOffset?: number) => { }; const maxOffset = Math.max(0, allRows.length - clampLimit(limit.value)); offset.value = Math.min(requestedOffset, maxOffset); + await nextTick(); + if (visibleResultCardKeys.value.length) { + selectFirstVisibleResult(); + } else { + focusKeyboardPane('sidebar'); + } } catch (e) { error.value = parseErr(e); } finally { @@ -802,6 +834,19 @@ const clearResults = () => { unresolvedCustomerNumbers.clear(); loadingCustomerNumbers.clear(); resetSearchProgress(); + keyboardState.value = { + pane: 'query', + selectedSidebarKey: 'all', + selectedResultKey: null + }; +}; + +const selectGroup = (groupKey: string, options?: { resetOffset?: boolean; pane?: SystemSearchKeyboardPane }) => { + selectedGroup.value = groupKey; + if (options?.resetOffset !== false) offset.value = 0; + closeSettingsView(); + syncKeyboardState({ selectedSidebarKey: groupKey, pane: options?.pane ?? keyboardState.value.pane }); + if (options?.pane && options.pane !== 'query') focusSearchShell(); }; const openSetting = (setting: 'pagination' | 'object-types' | 'manage') => { @@ -1352,6 +1397,115 @@ const visibleResultCards = computed(() => visibleResults.va }; })); +const keyboardState = ref({ + pane: 'query', + selectedSidebarKey: 'all', + selectedResultKey: null +}); + +const sidebarGroupKeys = computed(() => sidebarGroups.value.map((group) => group.key)); +const visibleResultCardKeys = computed(() => visibleResultCards.value.map((card) => getCardKey(card))); + +const syncKeyboardState = (patch: Partial = {}) => { + keyboardState.value = syncSystemSearchKeyboardState( + { ...keyboardState.value, ...patch }, + sidebarGroupKeys.value, + visibleResultCardKeys.value, + selectedGroup.value + ); +}; + +const setKeyboardPane = (pane: SystemSearchKeyboardPane) => { + syncKeyboardState({ pane }); +}; + +const focusKeyboardPane = (pane: SystemSearchKeyboardPane) => { + syncKeyboardState({ pane }); + if (pane !== 'query') focusSearchShell(); +}; + +const selectResultCard = (card: VisibleResultCard) => { + focusKeyboardPane('results'); + syncKeyboardState({ selectedResultKey: getCardKey(card) }); +}; + +const selectFirstVisibleResult = () => { + if (!visibleResultCardKeys.value.length) return; + focusKeyboardPane('results'); + syncKeyboardState({ selectedResultKey: visibleResultCardKeys.value[0] ?? null }); +}; + +const selectedResultCard = computed(() => { + const selectedKey = keyboardState.value.selectedResultKey ?? visibleResultCardKeys.value[0] ?? null; + if (!selectedKey) return null; + return visibleResultCards.value.find((card) => getCardKey(card) === selectedKey) ?? null; +}); + +const isFirstSelectedKey = (keys: string[], selectedKey: string | null): boolean => { + if (!keys.length) return true; + if (!selectedKey) return true; + const index = keys.indexOf(selectedKey); + return index <= 0; +}; + +const onSearchShellKeydown = (event: KeyboardEvent) => { + if (!isModalOpen.value || activeSetting.value) return; + if (event.defaultPrevented) return; + if (event.altKey || event.ctrlKey || event.metaKey) return; + if (isTextEntryTarget(event.target)) return; + + if (event.key === 'ArrowLeft') { + if (keyboardState.value.pane !== 'results' || !sidebarGroupKeys.value.length) return; + event.preventDefault(); + focusKeyboardPane('sidebar'); + syncKeyboardState({ selectedSidebarKey: selectedGroup.value }); + return; + } + + if (event.key === 'ArrowRight' || event.key === 'Enter') { + if (keyboardState.value.pane === 'sidebar') { + event.preventDefault(); + selectFirstVisibleResult(); + return; + } + if (event.key === 'Enter' && keyboardState.value.pane === 'results' && selectedResultCard.value) { + event.preventDefault(); + void openResult(selectedResultCard.value); + } + return; + } + + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + + const delta = event.key === 'ArrowDown' ? 1 : -1; + if (keyboardState.value.pane === 'sidebar') { + if (delta === -1 && isFirstSelectedKey(sidebarGroupKeys.value, keyboardState.value.selectedSidebarKey)) { + event.preventDefault(); + focusQueryFromKeyboardNavigation(); + return; + } + event.preventDefault(); + const nextGroupKey = moveSystemSearchSelection(sidebarGroupKeys.value, keyboardState.value.selectedSidebarKey, delta); + if (!nextGroupKey) return; + syncKeyboardState({ selectedSidebarKey: nextGroupKey, pane: 'sidebar' }); + selectGroup(nextGroupKey, { pane: 'sidebar' }); + return; + } + + if (keyboardState.value.pane === 'results') { + if (delta === -1 && isFirstSelectedKey(visibleResultCardKeys.value, keyboardState.value.selectedResultKey)) { + event.preventDefault(); + focusQueryFromKeyboardNavigation(); + return; + } + event.preventDefault(); + const nextResultKey = moveSystemSearchSelection(visibleResultCardKeys.value, keyboardState.value.selectedResultKey, delta); + if (!nextResultKey) return; + focusKeyboardPane('results'); + syncKeyboardState({ selectedResultKey: nextResultKey }); + } +}; + const coreBadgePrefixes = computed(() => ([ 'Score ', 'Customer #', @@ -1486,9 +1640,18 @@ const warmVisibleCardDisplayData = async (cards: VisibleResultCard[]) => { }; watch(visibleResultCards, (cards) => { + syncKeyboardState(); void warmVisibleCardDisplayData(cards); }, { immediate: true }); +watch(sidebarGroups, () => { + syncKeyboardState({ selectedSidebarKey: selectedGroup.value }); +}, { immediate: true }); + +watch(selectedGroup, (groupKey) => { + syncKeyboardState({ selectedSidebarKey: groupKey }); +}); + const canShowCustomerAction = (card: VisibleResultCard): boolean => { if (card.relatedTargets.customer) return true; return SessionUser.canAccessSuperUser() && card.customerNumber !== null; @@ -1794,7 +1957,12 @@ const asJson = (v: unknown) => { aria-role="dialog" aria-modal > -
+
@@ -1810,8 +1978,11 @@ const asJson = (v: unknown) => { :key="`sidebar-group-${group.key}`" type="button" class="button is-fullwidth mb-2 sidebar-group-button" - :class="selectedGroup === group.key ? 'is-link' : 'is-light'" - @click="selectedGroup = group.key; closeSettingsView()" + :class="[ + selectedGroup === group.key ? 'is-link' : 'is-light', + { 'sidebar-group-button--selected': keyboardState.selectedSidebarKey === group.key } + ]" + @click="selectGroup(group.key, { pane: 'sidebar' })" > {{ group.label }} {{ group.count }} @@ -1871,7 +2042,7 @@ const asJson = (v: unknown) => {