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.
This commit is contained in:
Jeppe Bundgaard
2026-03-13 13:06:33 +01:00
parent 2b3cf2010a
commit 489a64d5ee
2 changed files with 206 additions and 15 deletions
@@ -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<SearchMethod>('POST');
const query = ref('');
const queryInputRef = ref<unknown>(null);
const searchShellRef = ref<HTMLElement | null>(null);
const includeTypes = ref<EntityType[]>([]);
const excludeTypes = ref<EntityType[]>([]);
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<Record<string, SearchResult[]>>(() => payload.value?.grouped_results ?? {});
const results = computed(() => sortSystemSearchResults(payload.value?.results ?? []));
const groupedMap = computed<Record<string, SearchResult[]>>(() => 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<string, SearchResult[]> = {};
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<VisibleResultCard[]>(() => visibleResults.va
};
}));
const keyboardState = ref<SystemSearchKeyboardState>({
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<SystemSearchKeyboardState> = {}) => {
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<VisibleResultCard | null>(() => {
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
>
<div class="search-modal-shell">
<div
ref="searchShellRef"
class="search-modal-shell"
tabindex="-1"
@keydown.capture="onSearchShellKeydown"
>
<div class="search-modal-sidebar-pane">
<b-sidebar class="search-modal-sidebar" :model-value="true" position="static" :overlay="false" fullheight>
<div class="search-sidebar-content">
@@ -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' })"
>
<span class="sidebar-group-label">{{ group.label }}</span>
<span class="sidebar-group-count">{{ group.count }}</span>
@@ -1871,7 +2042,7 @@ const asJson = (v: unknown) => {
<template v-if="!activeSetting">
<b-field grouped group-multiline class="search-query-row">
<b-input ref="queryInputRef" v-model="query" :placeholder="t('global_search.search.placeholder')" icon="search" icon-pack="fas" expanded @keyup.enter="runSearch(0)" />
<b-input ref="queryInputRef" v-model="query" :placeholder="t('global_search.search.placeholder')" icon="search" icon-pack="fas" expanded @focus="setKeyboardPane('query')" @keyup.enter="runSearch(0)" />
<p class="control">
<b-button class="search-submit-btn" type="is-link" icon-left="search" icon-pack="fas" :loading="loading" @click="runSearch(0)">
{{ t('global.search') }}
@@ -1949,7 +2120,11 @@ const asJson = (v: unknown) => {
v-for="card in visibleResultCards"
:key="`${card.row.entity_type}-${card.row.entity_id}-${card.row.score}`"
class="result-item"
:class="{ 'result-item--cancelled': card.isCancelledBooking }"
:class="{
'result-item--cancelled': card.isCancelledBooking,
'result-item--selected': keyboardState.selectedResultKey === getCardKey(card)
}"
@click="selectResultCard(card)"
>
<div class="result-item-header mb-2">
<p class="result-title is-size-7 has-text-weight-semibold mb-0">{{ card.viewModel.title }}</p>
@@ -2285,6 +2460,10 @@ const asJson = (v: unknown) => {
.search-sidebar-body { flex: 1; overflow-y: auto; overflow-x: hidden; }
.search-sidebar-footer { border-top: 1px solid var(--search-border); background: transparent; }
.sidebar-group-button { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.sidebar-group-button--selected {
box-shadow: 0 0 0 0.125em rgba(37, 99, 235, 0.16);
border-color: rgba(37, 99, 235, 0.35);
}
.sidebar-group-label { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-group-count {
min-width: 30px;
@@ -2460,6 +2639,11 @@ const asJson = (v: unknown) => {
box-shadow: 0 3px 10px rgba(15, 23, 42, 0.05);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.result-item--selected {
border-color: rgba(37, 99, 235, 0.48);
box-shadow: 0 0 0 0.16em rgba(37, 99, 235, 0.12), 0 12px 22px rgba(15, 23, 42, 0.1);
background: linear-gradient(180deg, #ffffff 0%, #f4f8ff 100%);
}
.result-item:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(15, 23, 42, 0.1);
@@ -17,6 +17,9 @@ describe('advanced system search modal UI contract', () => {
expect(modalSource).toContain('buildSystemSearchViewModel');
expect(modalSource).toContain('resolveSystemSearchNavigationTarget');
expect(modalSource).toContain('const visibleResultCards = computed');
expect(modalSource).toContain('const keyboardState = ref<SystemSearchKeyboardState>');
expect(modalSource).toContain('const onSearchShellKeydown = (event: KeyboardEvent) =>');
expect(modalSource).toContain("@keydown.capture=\"onSearchShellKeydown\"");
expect(modalSource).toContain('v-for="card in visibleResultCards"');
expect(modalSource).toContain('card.viewModel.keyFields');
expect(modalSource).toContain('const relatedTargets = resolveCardRelatedTargets(');
@@ -30,6 +33,8 @@ describe('advanced system search modal UI contract', () => {
expect(modalSource).toContain('card.viewModel.parsedPayload ?? card.viewModel.rawPayload');
expect(modalSource).toContain('result-badges');
expect(modalSource).toContain('result-key-fields');
expect(modalSource).toContain('result-item--selected');
expect(modalSource).toContain('sidebar-group-button--selected');
expect(modalSource).toContain('related-actions-panel');
expect(modalSource).toContain('v-if="hasRelatedActions(card)"');
expect(modalSource).toContain("@click=\"openRelatedTarget(card, 'order')\"");
@@ -45,6 +50,8 @@ describe('advanced system search modal UI contract', () => {
expect(modalSource).toContain('const DOUBLE_SHIFT_MAX_INTERVAL_MS = 420');
expect(modalSource).toContain('const onGlobalKeyDown = (event: KeyboardEvent) =>');
expect(modalSource).toContain('const onGlobalKeyUp = (event: KeyboardEvent) =>');
expect(modalSource).toContain("selectFirstVisibleResult();");
expect(modalSource).toContain("focusKeyboardPane('sidebar');");
expect(modalSource).toContain("if (event.key === 'Shift')");
expect(modalSource).toContain('openSearchFromShortcut();');
expect(modalSource).toContain("window.addEventListener('keydown', onGlobalKeyDown)");