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:
@@ -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)");
|
||||
|
||||
Reference in New Issue
Block a user