feat(grid): arrow key navigation in detail dialogs

This commit is contained in:
jearhe authored and GitHub committed 2026-09-01 02:12:55 +08:00
1 parent 8cc04f3af4
commit d69cf9da79
4 files changed
+229 -4

No files matched your search

+26 -2
View File
@@ -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"
@@ -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<boolean>("rowOpen", { default: false });
const columnOpen = defineModel<boolean>("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);
}
</script>
<template>
<Dialog v-model:open="rowOpen">
<DialogContent v-if="rowDetail" class="sm:max-w-[960px] max-h-[85vh] flex flex-col overflow-hidden">
<DialogContent v-if="rowDetail" tabindex="-1" class="sm:max-w-[960px] max-h-[85vh] flex flex-col overflow-hidden" @open-auto-focus="focusDetailDialogContentOnOpen" @keydown="onRowDetailKeydown">
<DialogHeader class="shrink-0 pr-8">
<DialogTitle class="flex min-w-0 items-center gap-2 text-sm"
><ListTree class="h-4 w-4 shrink-0 text-muted-foreground" /><span class="min-w-0 truncate">{{ t("grid.rowDetailsFor", { row: rowDetail.rowNumber }) }}</span></DialogTitle
@@ -90,7 +124,7 @@ const filteredColumnFields = computed(() => (props.columnDetail ? filterDataGrid
</Dialog>
<Dialog v-model:open="columnOpen">
<DialogContent v-if="columnDetail" class="sm:max-w-[900px] max-h-[85vh] flex flex-col overflow-hidden">
<DialogContent v-if="columnDetail" tabindex="-1" class="sm:max-w-[900px] max-h-[85vh] flex flex-col overflow-hidden" @open-auto-focus="focusDetailDialogContentOnOpen" @keydown="onColumnDetailKeydown">
<DialogHeader class="shrink-0 pr-8"
><DialogTitle class="flex min-w-0 items-center gap-2 text-sm"
><TableProperties class="h-4 w-4 shrink-0 text-muted-foreground" /><span class="min-w-0 truncate">{{ t("grid.columnDetailsFor", { column: columnDetail.column }) }}</span></DialogTitle
@@ -0,0 +1,132 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { adjacentDataGridDetailIndex, detailNavigationDelta, shouldIgnoreDataGridDetailNavigation } from "../../../lib/dataGrid/dataGridDetailNavigation";
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
const detailDialogsSource = readFileSync(new URL("../DataGridDetailDialogs.vue", import.meta.url), "utf8");
function keyboardEvent(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
key: "ArrowDown",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
defaultPrevented: false,
target: null,
currentTarget: {},
preventDefault: () => undefined,
stopPropagation: () => undefined,
...overrides,
} as unknown as KeyboardEvent;
}
function targetMatching(selectorPart: string): EventTarget {
return { closest: (selector: string) => (selector.includes(selectorPart) ? {} : null) } as unknown as EventTarget;
}
function functionBody(source: string, name: string, nextName: string): string {
const start = source.indexOf(`function ${name}`);
const end = source.indexOf(`function ${nextName}`, start + 1);
return start >= 0 && end > start ? source.slice(start, end) : "";
}
describe("DataGrid detail navigation index", () => {
it("follows the supplied displayed order instead of deriving neighboring ids", () => {
const displayedRows = [42, 7, 99];
const currentRowIndex = displayedRows.indexOf(7);
const previousRowIndex = adjacentDataGridDetailIndex(currentRowIndex, -1, displayedRows.length);
const nextRowIndex = adjacentDataGridDetailIndex(currentRowIndex, 1, displayedRows.length);
expect(displayedRows[previousRowIndex!]).toBe(42);
expect(displayedRows[nextRowIndex!]).toBe(99);
expect(displayedRows[previousRowIndex!]).not.toBe(6);
expect(displayedRows[nextRowIndex!]).not.toBe(8);
const visibleColumns = [5, 1, 3];
const currentColumnPosition = visibleColumns.indexOf(1);
expect(visibleColumns[adjacentDataGridDetailIndex(currentColumnPosition, -1, visibleColumns.length)!]).toBe(5);
expect(visibleColumns[adjacentDataGridDetailIndex(currentColumnPosition, 1, visibleColumns.length)!]).toBe(3);
});
it("returns no-op indexes at every row and column boundary", () => {
expect(adjacentDataGridDetailIndex(0, -1, 3)).toBeNull();
expect(adjacentDataGridDetailIndex(2, 1, 3)).toBeNull();
expect(adjacentDataGridDetailIndex(0, -1, 1)).toBeNull();
expect(adjacentDataGridDetailIndex(0, 1, 1)).toBeNull();
expect(adjacentDataGridDetailIndex(-1, 1, 3)).toBeNull();
const visibleColumns = [5, 1, 3];
expect(adjacentDataGridDetailIndex(visibleColumns.indexOf(5), -1, visibleColumns.length)).toBeNull();
expect(adjacentDataGridDetailIndex(visibleColumns.indexOf(3), 1, visibleColumns.length)).toBeNull();
});
it("maps only the matching arrows to each detail axis", () => {
expect(detailNavigationDelta("ArrowUp", "row")).toBe(-1);
expect(detailNavigationDelta("ArrowDown", "row")).toBe(1);
expect(detailNavigationDelta("ArrowLeft", "column")).toBe(-1);
expect(detailNavigationDelta("ArrowRight", "column")).toBe(1);
expect(detailNavigationDelta("ArrowLeft", "row")).toBeNull();
expect(detailNavigationDelta("ArrowDown", "column")).toBeNull();
});
});
describe("DataGrid detail keyboard safety", () => {
it("ignores all arrows from inputs, textareas, contenteditable, and textbox targets", () => {
for (const selectorPart of ["input", "textarea", "contenteditable", "textbox"]) {
const target = targetMatching(selectorPart);
for (const key of ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]) {
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ key, target }))).toBe(true);
}
}
});
it("leaves nested overlays, modified arrows, and already-handled events alone", () => {
const currentTarget = {} as EventTarget;
const overlayTarget = targetMatching("popover-content");
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ target: overlayTarget, currentTarget }))).toBe(true);
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ shiftKey: true }))).toBe(true);
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ ctrlKey: true }))).toBe(true);
expect(shouldIgnoreDataGridDetailNavigation(keyboardEvent({ defaultPrevented: true }))).toBe(true);
});
it("wires row and column intents to the displayed DataGrid order", () => {
expect(detailDialogsSource).toContain('@keydown="onRowDetailKeydown"');
expect(detailDialogsSource).toContain('@keydown="onColumnDetailKeydown"');
expect(detailDialogsSource).toContain("shouldIgnoreDataGridDetailNavigation(event)");
expect(detailDialogsSource).toContain('detailNavigationDelta(event.key, "row")');
expect(detailDialogsSource).toContain('detailNavigationDelta(event.key, "column")');
expect(detailDialogsSource).toContain('emit("navigateRow", delta)');
expect(detailDialogsSource).toContain('emit("navigateColumn", delta)');
expect(dataGridSource).toContain('@navigate-row="navigateRowDetail"');
expect(dataGridSource).toContain('@navigate-column="navigateColumnDetail"');
const rowNavigation = functionBody(dataGridSource, "navigateRowDetail", "navigateColumnDetail");
const columnNavigation = functionBody(dataGridSource, "navigateColumnDetail", "openContextRowDetailDialog");
expect(rowNavigation).toContain("displayRowIndexById(rowDetailDialogRowId.value)");
expect(rowNavigation).toContain("displayItemAt(nextRowIndex)");
expect(rowNavigation).not.toMatch(/rowDetailDialogRowId\.value\s*[+-]/);
expect(columnNavigation).toContain("visibleColumnIndexes.value.indexOf(columnDetailDialogColumnIndex.value)");
expect(columnNavigation).toContain("visibleColumnIndexes.value[nextColumnPosition]");
expect(columnNavigation).not.toMatch(/columnDetailDialogColumnIndex\.value\s*[+-]/);
});
it("keeps the dialog open while replacing the detail target and guards closed dialogs", () => {
const rowNavigation = functionBody(dataGridSource, "navigateRowDetail", "navigateColumnDetail");
const columnNavigation = functionBody(dataGridSource, "navigateColumnDetail", "openContextRowDetailDialog");
expect(rowNavigation).toContain("if (!rowDetailDialogOpen.value || rowDetailDialogRowId.value === null) return;");
expect(columnNavigation).toContain("if (!columnDetailDialogOpen.value || columnDetailDialogColumnIndex.value === null) return;");
expect(rowNavigation).not.toContain("rowDetailDialogOpen.value = false");
expect(columnNavigation).not.toContain("columnDetailDialogOpen.value = false");
expect(detailDialogsSource).toContain('const rowSearch = ref("");');
expect(detailDialogsSource).toContain('const columnSearch = ref("");');
});
it("focuses the dialog content on open so arrows work immediately", () => {
expect(detailDialogsSource).toContain('@open-auto-focus="focusDetailDialogContentOnOpen"');
expect(detailDialogsSource).toContain('tabindex="-1"');
expect(detailDialogsSource).toContain("function focusDetailDialogContentOnOpen(event: Event) {");
});
});
@@ -0,0 +1,35 @@
import { eventTargetUsesNativeClipboard } from "@/lib/common/clipboard";
export type DataGridDetailNavigationDelta = -1 | 1;
export type DataGridDetailNavigationAxis = "row" | "column";
const nestedOverlaySelector = "[data-slot='dropdown-menu-content'], [data-slot='popover-content'], [data-slot='dialog-content'], [role='menu'], [role='listbox']";
/** Return the adjacent index without wrapping past either end of the displayed list. */
export function adjacentDataGridDetailIndex(currentIndex: number, delta: DataGridDetailNavigationDelta, itemCount: number): number | null {
const nextIndex = currentIndex + delta;
if (currentIndex < 0 || currentIndex >= itemCount || nextIndex < 0 || nextIndex >= itemCount) return null;
return nextIndex;
}
export function detailNavigationDelta(key: string, axis: DataGridDetailNavigationAxis): DataGridDetailNavigationDelta | null {
if (axis === "row") {
if (key === "ArrowUp") return -1;
if (key === "ArrowDown") return 1;
} else {
if (key === "ArrowLeft") return -1;
if (key === "ArrowRight") return 1;
}
return null;
}
function closestElement(target: EventTarget | null, selector: string): unknown {
return (target as { closest?: (selector: string) => unknown } | null)?.closest?.(selector) ?? null;
}
export function shouldIgnoreDataGridDetailNavigation(event: KeyboardEvent): boolean {
if (event.defaultPrevented || eventTargetUsesNativeClipboard(event)) return true;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return true;
const overlay = closestElement(event.target, nestedOverlaySelector);
return overlay !== null && overlay !== event.currentTarget;
}