From d69cf9da7987a16419b47bdf6bcda3143850c41c Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Tue, 1 Sep 2026 02:12:55 +0800 Subject: [PATCH] feat(grid): arrow key navigation in detail dialogs --- apps/desktop/src/components/grid/DataGrid.vue | 28 +++- .../components/grid/DataGridDetailDialogs.vue | 38 ++++- .../DataGridDetailNavigation.spec.ts | 132 ++++++++++++++++++ .../lib/dataGrid/dataGridDetailNavigation.ts | 35 +++++ 4 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/components/grid/__tests__/DataGridDetailNavigation.spec.ts create mode 100644 apps/desktop/src/lib/dataGrid/dataGridDetailNavigation.ts diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index e9f8e2c04c..e6b11ed491 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -172,6 +172,7 @@ import { dataGridRowDetailTsv, type DataGridCellDetail, } from "@/lib/dataGrid/dataGridDetail"; +import { adjacentDataGridDetailIndex, type DataGridDetailNavigationDelta } from "@/lib/dataGrid/dataGridDetailNavigation"; import { applyColumnFormatter, buildColumnFormatterKey, @@ -8545,6 +8546,25 @@ function openRowDetailDialog(rowId: number) { rowDetailDialogOpen.value = true; } +function navigateRowDetail(delta: DataGridDetailNavigationDelta) { + if (!rowDetailDialogOpen.value || rowDetailDialogRowId.value === null) return; + const nextRowIndex = adjacentDataGridDetailIndex(displayRowIndexById(rowDetailDialogRowId.value), delta, displayRowCount.value); + if (nextRowIndex === null) return; + const nextItem = displayItemAt(nextRowIndex); + if (!nextItem) return; + rowDetailDialogRowId.value = nextItem.id; +} + +function navigateColumnDetail(delta: DataGridDetailNavigationDelta) { + if (!columnDetailDialogOpen.value || columnDetailDialogColumnIndex.value === null) return; + const currentColumnPosition = visibleColumnIndexes.value.indexOf(columnDetailDialogColumnIndex.value); + const nextColumnPosition = adjacentDataGridDetailIndex(currentColumnPosition, delta, visibleColumnIndexes.value.length); + if (nextColumnPosition === null) return; + const nextColumnIndex = visibleColumnIndexes.value[nextColumnPosition]; + if (nextColumnIndex === undefined) return; + columnDetailDialogColumnIndex.value = nextColumnIndex; +} + function openContextRowDetailDialog() { const cell = contextCell.value; if (!cell) return; @@ -9955,7 +9975,8 @@ async function copyRowDetailTsv() { async function copyRowDetailFieldValue(field: DataGridCellDetail) { if (!(await hydrateLargeValueCell(field.rowId, field.colIndex))) return; - const resolved = cellDetailFor(field.rowNumber - 1, field.colIndex); + const rowIndex = displayRowIndexById(field.rowId); + const resolved = rowIndex >= 0 ? cellDetailFor(rowIndex, field.colIndex) : null; if (resolved) copyText(detailClipboardText(resolved)); } @@ -9987,7 +10008,8 @@ function copyColumnDetailColumnName() { async function copyColumnDetailFieldValue(field: DataGridCellDetail) { if (!(await hydrateLargeValueCell(field.rowId, field.colIndex))) return; - const resolved = cellDetailFor(field.rowNumber - 1, field.colIndex); + const rowIndex = displayRowIndexById(field.rowId); + const resolved = rowIndex >= 0 ? cellDetailFor(rowIndex, field.colIndex) : null; if (resolved) copyText(detailClipboardText(resolved)); } @@ -14598,6 +14620,8 @@ function openGridSnapshot() { :row-detail="rowDetail" :column-detail="columnDetail" :type-color-class="typeColorClass" + @navigate-row="navigateRowDetail" + @navigate-column="navigateColumnDetail" :open-image-preview="openImagePreview" :copy-row-detail-field-value="copyRowDetailFieldValue" :copy-column-detail-field-value="copyColumnDetailFieldValue" diff --git a/apps/desktop/src/components/grid/DataGridDetailDialogs.vue b/apps/desktop/src/components/grid/DataGridDetailDialogs.vue index 63e0a7ec5a..3f4a404a2d 100644 --- a/apps/desktop/src/components/grid/DataGridDetailDialogs.vue +++ b/apps/desktop/src/components/grid/DataGridDetailDialogs.vue @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { filterDataGridDetailFields, type DataGridCellDetail, type DataGridColumnDetail, type DataGridRowDetail } from "@/lib/dataGrid/dataGridDetail"; +import { detailNavigationDelta, shouldIgnoreDataGridDetailNavigation, type DataGridDetailNavigationDelta } from "@/lib/dataGrid/dataGridDetailNavigation"; const { t } = useI18n(); const props = defineProps<{ @@ -22,17 +23,50 @@ const props = defineProps<{ copyColumnDetailColumnName: () => void; }>(); +const emit = defineEmits<{ + navigateRow: [delta: DataGridDetailNavigationDelta]; + navigateColumn: [delta: DataGridDetailNavigationDelta]; +}>(); + const rowOpen = defineModel("rowOpen", { default: false }); const columnOpen = defineModel("columnOpen", { default: false }); const rowSearch = ref(""); const columnSearch = ref(""); const filteredRowFields = computed(() => (props.rowDetail ? filterDataGridDetailFields(props.rowDetail.fields, rowSearch.value) : [])); const filteredColumnFields = computed(() => (props.columnDetail ? filterDataGridDetailFields(props.columnDetail.fields, columnSearch.value) : [])); + +// The dialog must be arrow-navigable the moment it opens. FocusScope's default +// focuses the first tabbable element — the search input — whose arrows move +// the caret and are ignored by shouldIgnoreDataGridDetailNavigation. Cancel the +// default and focus the content root instead (event.target is the FocusScope +// container, i.e. the rendered dialog content element). +function focusDetailDialogContentOnOpen(event: Event) { + event.preventDefault(); + (event.target as HTMLElement | null)?.focus(); +} + +function onRowDetailKeydown(event: KeyboardEvent) { + if (!rowOpen.value || shouldIgnoreDataGridDetailNavigation(event)) return; + const delta = detailNavigationDelta(event.key, "row"); + if (delta === null) return; + event.preventDefault(); + event.stopPropagation(); + emit("navigateRow", delta); +} + +function onColumnDetailKeydown(event: KeyboardEvent) { + if (!columnOpen.value || shouldIgnoreDataGridDetailNavigation(event)) return; + const delta = detailNavigationDelta(event.key, "column"); + if (delta === null) return; + event.preventDefault(); + event.stopPropagation(); + emit("navigateColumn", delta); +}