feat(grid): arrow key navigation in detail dialogs
This commit is contained in:
1 parent
8cc04f3af4
commit
d69cf9da79
4 files changed
+229
-4
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user