Improve data grid cell detail, navigation, and transpose UX (#342)

* fix(mysql): avoid metadata collation conflicts

Split MySQL column and primary key metadata lookups so MariaDB and OceanBase do not join information_schema tables with incompatible collations.

* feat(grid): add invert column visibility action

* feat(grid): improve cell detail editing

* feat(grid): add keyboard cell navigation

* feat(grid): add column name copy in header tooltip

* feat(grid): improve transpose navigation
This commit is contained in:
yavon007 2026-05-20 09:43:40 +08:00 committed by GitHub
parent b9bcb90ac7
commit 49a7541450
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 399 additions and 129 deletions

View File

@ -119,6 +119,7 @@ import {
} from "@/lib/paginationPageSize";
import {
filterColumnVisibilityOptions,
invertedHiddenColumnIndexes,
nextHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "@/lib/dataGridColumnVisibility";
@ -1076,6 +1077,9 @@ function toggleColumnVisibility(columnIndex: number) {
function showAllColumns() {
hiddenColumnIndexes.value = new Set();
}
function invertColumnVisibility() {
hiddenColumnIndexes.value = invertedHiddenColumnIndexes(displayableColumnIndexes.value, hiddenColumnIndexes.value);
}
const firstVisibleColumnIndex = computed(() => visibleColumnIndexes.value[0] ?? 0);
function actualColumnIndex(visibleColumnIndex: number): number {
return visibleColumnIndexes.value[visibleColumnIndex] ?? visibleColumnIndex;
@ -1716,6 +1720,21 @@ const activeCellDetail = computed(() => {
const detailEditValue = ref("");
const isEditingDetail = ref(false);
const detailTemporalEditorKind = computed(() => {
const detail = activeCellDetail.value;
return detail ? temporalEditorKindForColumn(detail.colIndex) : undefined;
});
function resetDetailEdit() {
isEditingDetail.value = false;
detailEditValue.value = "";
}
function closeCellDetails() {
resetDetailEdit();
showCellDetail.value = false;
detailCell.value = null;
}
function startDetailEdit() {
const detail = activeCellDetail.value;
@ -1759,7 +1778,7 @@ function commitDetailEdit() {
}
function cancelDetailEdit() {
isEditingDetail.value = false;
resetDetailEdit();
}
function setDetailNull() {
@ -1772,7 +1791,7 @@ function setDetailNull() {
if (item.isNew && item.newIndex !== undefined) {
newRows.value[item.newIndex][detail.colIndex] = null;
newRows.value = [...newRows.value];
isEditingDetail.value = false;
resetDetailEdit();
detailCell.value = { ...detailCell.value! };
return;
}
@ -1785,7 +1804,7 @@ function setDetailNull() {
if (useTransaction.value && !transactionActive.value) {
enterTransaction();
}
isEditingDetail.value = false;
resetDetailEdit();
detailCell.value = { ...detailCell.value! };
}
@ -2002,6 +2021,7 @@ const {
// --- Cell selection and detail ---
function showCellDetails(rowIndex: number, colIndex: number) {
resetDetailEdit();
detailCell.value = { rowIndex, col: colIndex };
showCellDetail.value = true;
}
@ -2059,6 +2079,48 @@ function cutSelection() {
}
}
function currentSelectedCellPosition() {
const range = selectedRange.value;
if (!range) return null;
return { rowIndex: range.startRow, colIndex: range.startCol };
}
function scrollCellIntoView(rowIndex: number, colIndex: number) {
nextTick(() => {
const rowEl = gridRef.value?.querySelector<HTMLElement>(`[data-row-index="${rowIndex}"]`);
const cellEl = rowEl?.querySelector<HTMLElement>(`[data-visible-col-index="${colIndex}"]`);
(cellEl ?? rowEl)?.scrollIntoView({ block: "nearest", inline: "nearest" });
});
}
function moveSelectedCell(rowDelta: number, colDelta: number): boolean {
const position = currentSelectedCellPosition();
if (!position || editingCell.value || displayItems.value.length === 0 || visibleColumnIndexes.value.length === 0)
return false;
const rowIndex = Math.max(0, Math.min(displayItems.value.length - 1, position.rowIndex + rowDelta));
const colIndex = Math.max(0, Math.min(visibleColumnIndexes.value.length - 1, position.colIndex + colDelta));
selectSingleCell(rowIndex, colIndex);
clearRowSelection();
if (showTranspose.value) transposeRowIndex.value = rowIndex;
scrollCellIntoView(rowIndex, colIndex);
return true;
}
function editSelectedCell(): boolean {
const position = currentSelectedCellPosition();
if (!position || editingCell.value) return false;
const item = displayItems.value[position.rowIndex];
const actualColIndex = actualColumnIndex(position.colIndex);
if (!item || !canEditCellItem(item, actualColIndex)) return false;
startEdit(item.id, actualColIndex);
return true;
}
function commitGridEdit() {
commitEdit();
nextTick(() => gridRef.value?.focus({ preventScroll: true }));
}
async function onGridKeydown(event: KeyboardEvent) {
if (isFocusSearchShortcut(event)) {
event.preventDefault();
@ -2066,6 +2128,34 @@ async function onGridKeydown(event: KeyboardEvent) {
return;
}
if (eventTargetAllowsNativeClipboard(event)) return;
if (event.key === "ArrowLeft" && moveTransposeRecordSelection(-1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowRight" && moveTransposeRecordSelection(1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowUp" && moveSelectedCell(-1, 0)) {
event.preventDefault();
return;
}
if (event.key === "ArrowDown" && moveSelectedCell(1, 0)) {
event.preventDefault();
return;
}
if (event.key === "ArrowLeft" && moveSelectedCell(0, -1)) {
event.preventDefault();
return;
}
if (event.key === "ArrowRight" && moveSelectedCell(0, 1)) {
event.preventDefault();
return;
}
if (event.key === "Enter" && editSelectedCell()) {
event.preventDefault();
return;
}
if (clipboardShortcut(event, "c")) {
if (!hasCellSelection.value) return;
event.preventDefault();
@ -2092,6 +2182,12 @@ function copyDetailValue() {
copyText(text);
}
function copyDetailFormattedJson() {
const detail = activeCellDetail.value;
if (!detail?.formattedJson) return;
copyText(detail.formattedJson);
}
function copyDetailColumnName() {
if (!activeCellDetail.value) return;
copyText(activeCellDetail.value.column);
@ -2105,8 +2201,14 @@ function copyDetailSqlCondition() {
copyText(condition);
}
const TRANSPOSE_RECORD_WIDTH = 168;
const transposePinnedWidth = computed(() => transposeFieldWidth(visibleColumns.value));
const TRANSPOSE_RECORD_DEFAULT_WIDTH = 168;
const TRANSPOSE_RECORD_MIN_WIDTH = 96;
const TRANSPOSE_PINNED_MIN_WIDTH = 104;
const transposeRecordWidth = ref(TRANSPOSE_RECORD_DEFAULT_WIDTH);
const transposePinnedWidthOverride = ref<number | null>(null);
const transposePinnedWidth = computed(
() => transposePinnedWidthOverride.value ?? transposeFieldWidth(visibleColumns.value),
);
const transposeRows = computed(() => {
return buildTransposeRows({
@ -2123,7 +2225,7 @@ const transposeRecordWindow = computed(() =>
scrollLeft: transposeScrollLeft.value,
viewportWidth: transposeViewportWidth.value,
pinnedWidth: transposePinnedWidth.value,
recordWidth: TRANSPOSE_RECORD_WIDTH,
recordWidth: transposeRecordWidth.value,
overscan: 2,
}),
);
@ -2132,7 +2234,7 @@ const visibleTransposeRecordIndexes = computed(() => {
return Array.from({ length: window.end - window.start }, (_, offset) => window.start + offset);
});
const transposeTotalWidth = computed(
() => transposePinnedWidth.value + displayItems.value.length * TRANSPOSE_RECORD_WIDTH,
() => transposePinnedWidth.value + displayItems.value.length * transposeRecordWidth.value,
);
function transposeScrollElement(): HTMLElement | undefined {
@ -2161,13 +2263,54 @@ function scrollTransposeRecordIntoView(rowIndex: number) {
totalRecords: displayItems.value.length,
viewportWidth: el.clientWidth,
pinnedWidth: transposePinnedWidth.value,
recordWidth: TRANSPOSE_RECORD_WIDTH,
recordWidth: transposeRecordWidth.value,
});
updateTransposeViewport();
});
}
function onTransposePinnedResizeStart(event: MouseEvent) {
event.preventDefault();
const startX = event.clientX;
const startWidth = transposePinnedWidth.value;
const onMove = (e: MouseEvent) => {
transposePinnedWidthOverride.value = Math.max(TRANSPOSE_PINNED_MIN_WIDTH, startWidth + e.clientX - startX);
updateTransposeViewport();
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function onTransposeRecordResizeStart(event: MouseEvent) {
event.preventDefault();
const startX = event.clientX;
const startWidth = transposeRecordWidth.value;
const onMove = (e: MouseEvent) => {
transposeRecordWidth.value = Math.max(TRANSPOSE_RECORD_MIN_WIDTH, startWidth + e.clientX - startX);
updateTransposeViewport();
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function closeTranspose() {
showTranspose.value = false;
transposeRowIndex.value = null;
}
function openContextTranspose() {
if (showTranspose.value) {
closeTranspose();
return;
}
if (!contextCell.value) return;
const next = nextContextTransposeState({
showTranspose: showTranspose.value,
@ -2180,7 +2323,7 @@ function openContextTranspose() {
transposeRowIndex.value = next.transposeRowIndex;
showTranspose.value = next.showTranspose;
if (next.showTranspose) {
showCellDetail.value = false;
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
}
@ -2191,19 +2334,29 @@ function toggleTranspose(rowIndex: number) {
transposeRowIndex.value = next.transposeRowIndex;
showTranspose.value = next.showTranspose;
if (next.showTranspose) {
showCellDetail.value = false;
closeCellDetails();
nextTick(updateTransposeViewport);
if (next.transposeRowIndex !== null) scrollTransposeRecordIntoView(next.transposeRowIndex);
}
}
function selectTransposeRecord(rowIndex: number) {
if (rowIndex < 0 || rowIndex >= displayItems.value.length) return;
transposeRowIndex.value = rowIndex;
gridRef.value?.focus({ preventScroll: true });
}
function moveTransposeRecordSelection(delta: number): boolean {
if (!isTransposeMode.value || displayItems.value.length === 0) return false;
const current = transposeRowIndex.value ?? 0;
const next = Math.max(0, Math.min(displayItems.value.length - 1, current + delta));
transposeRowIndex.value = next;
scrollTransposeRecordIntoView(next);
return true;
}
function transposeNav(delta: number) {
if (transposeRowIndex.value === null) return;
const next = transposeRowIndex.value + delta;
if (next >= 0 && next < displayItems.value.length) {
transposeRowIndex.value = next;
scrollTransposeRecordIntoView(next);
}
moveTransposeRecordSelection(delta);
}
watch(isTransposeMode, (active) => {
@ -2219,10 +2372,8 @@ watch(
}
clearCellSelection();
clearRowSelection();
showCellDetail.value = false;
detailCell.value = null;
showTranspose.value = false;
transposeRowIndex.value = null;
closeCellDetails();
closeTranspose();
exitTransaction();
},
);
@ -2546,6 +2697,7 @@ defineExpose({
isColumnVisible,
toggleColumnVisibility,
showAllColumns,
invertColumnVisibility,
});
</script>
@ -2885,7 +3037,7 @@ defineExpose({
>
<ChevronRight class="w-3 h-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showTranspose = false">
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeTranspose">
<X class="w-3 h-3" />
</Button>
</div>
@ -2895,7 +3047,7 @@ defineExpose({
:style="{
'--transpose-total-w': `${transposeTotalWidth}px`,
'--transpose-field-w': `${transposePinnedWidth}px`,
'--transpose-record-w': `${TRANSPOSE_RECORD_WIDTH}px`,
'--transpose-record-w': `${transposeRecordWidth}px`,
}"
:items="transposeRows"
:item-size="30"
@ -2909,24 +3061,33 @@ defineExpose({
:style="{ width: `${transposeTotalWidth}px` }"
>
<div
class="sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 bg-[rgb(239_239_239)] truncate dark:bg-muted"
class="sticky left-0 z-30 shrink-0 border-r border-border px-3 py-1.5 bg-[rgb(239_239_239)] truncate dark:bg-muted relative"
:style="{ width: `${transposePinnedWidth}px` }"
>
{{ t("grid.columnName") }}
<div
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
@mousedown.stop="onTransposePinnedResizeStart"
/>
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.beforeWidth}px` }" />
<div
v-for="recordIndex in visibleTransposeRecordIndexes"
:key="`transpose-head-${recordIndex}`"
class="shrink-0 border-r border-border px-2 py-1.5 text-center tabular-nums"
class="shrink-0 border-r border-border px-2 py-1.5 text-center tabular-nums relative"
:class="
recordIndex === transposeRowIndex
? 'bg-primary/15 text-primary font-semibold'
: 'bg-[rgb(239_239_239)] dark:bg-muted'
"
:style="{ width: `${TRANSPOSE_RECORD_WIDTH}px` }"
:style="{ width: `${transposeRecordWidth}px` }"
@click="selectTransposeRecord(recordIndex)"
>
{{ recordIndex + 1 }}
<div
class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-primary/30"
@mousedown.stop="onTransposeRecordResizeStart"
/>
</div>
<div class="shrink-0" :style="{ width: `${transposeRecordWindow.afterWidth}px` }" />
</div>
@ -2952,8 +3113,9 @@ defineExpose({
'text-muted-foreground italic': item.values[recordIndex]?.isNull,
'bg-primary/10': recordIndex === transposeRowIndex,
}"
:style="{ width: `${TRANSPOSE_RECORD_WIDTH}px` }"
:style="{ width: `${transposeRecordWidth}px` }"
:title="item.values[recordIndex]?.display"
@click="selectTransposeRecord(recordIndex)"
>
{{ item.values[recordIndex]?.display }}
</div>
@ -3397,16 +3559,27 @@ defineExpose({
</div>
</TooltipTrigger>
<TooltipContent
v-if="columnTypeMap.get(col) || columnCommentMap.get(col)"
side="bottom"
class="text-xs grid grid-cols-[auto_1fr] gap-x-2"
class="grid min-w-56 grid-cols-[auto_minmax(0,1fr)] gap-x-2 gap-y-1 text-xs"
>
<span class="text-background/70">{{ t("grid.columnName") }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate font-mono">{{ col }}</span>
<button
type="button"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-background/10"
:title="t('grid.copyColumnName')"
@click.stop="copyText(col)"
>
<Copy class="h-3 w-3" />
</button>
</span>
<template v-if="columnTypeMap.get(col)">
<span class="text-muted-foreground">{{ t("grid.columnType") }}</span>
<span class="text-background/70">{{ t("grid.columnType") }}</span>
<span :class="typeColorClass(columnTypeMap.get(col)!)">{{ columnTypeMap.get(col) }}</span>
</template>
<template v-if="columnCommentMap.get(col)">
<span class="text-muted-foreground">{{ t("grid.columnComment") }}</span>
<span class="text-background/70">{{ t("grid.columnComment") }}</span>
<span>{{ columnCommentMap.get(col) }}</span>
</template>
</TooltipContent>
@ -3509,6 +3682,7 @@ defineExpose({
@mousedown="handleDataCellMousedown(index, visibleColIdx, item.id, $event)"
@mouseenter="extendCellSelection(index, visibleColIdx)"
@dblclick="canEditCellItem(item, actualColIdx) && startEdit(item.id, actualColIdx)"
:data-visible-col-index="visibleColIdx"
@contextmenu="onCellContext(item.id, index, actualColIdx, visibleColIdx)"
>
<template v-if="editingCell?.rowId === item.id && editingCell?.col === actualColIdx">
@ -3517,7 +3691,7 @@ defineExpose({
v-model="editValue"
:kind="temporalEditorKindForColumn(actualColIdx)!"
@cancel="cancelEdit"
@commit="commitEdit"
@commit="commitGridEdit"
/>
<input
v-else
@ -3721,7 +3895,7 @@ defineExpose({
<div class="h-9 flex items-center gap-2 px-3 border-b shrink-0 bg-muted/20">
<Info class="w-3.5 h-3.5 text-muted-foreground" />
<span class="text-xs font-medium flex-1 min-w-0 truncate">{{ t("grid.cellDetails") }}</span>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="showCellDetail = false">
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeCellDetails">
<X class="w-3 h-3" />
</Button>
</div>
@ -3780,9 +3954,20 @@ defineExpose({
</a>
</div>
<template v-if="isEditingDetail">
<textarea
<TemporalCellEditor
v-if="detailTemporalEditorKind"
v-model="detailEditValue"
class="w-full h-40 rounded border bg-background p-2 font-mono text-xs outline-none resize-y focus:border-primary"
:kind="detailTemporalEditorKind"
variant="inline"
:commit-on-close="false"
@cancel="cancelDetailEdit"
@commit="commitDetailEdit"
/>
<textarea
v-else
v-model="detailEditValue"
wrap="off"
class="w-full h-40 overflow-auto rounded border bg-background p-2 font-mono text-xs outline-none resize-y focus:border-primary"
@keydown.escape.stop="cancelDetailEdit"
/>
<div class="flex gap-1 mt-1">
@ -3796,7 +3981,7 @@ defineExpose({
</template>
<pre
v-else
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words cursor-pointer hover:border-primary/50"
class="max-h-56 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre cursor-pointer hover:border-primary/50"
:class="{ 'cursor-text': activeCellDetail.isEditable }"
@dblclick="startDetailEdit"
>{{ activeCellDetail.displayValue }}</pre
@ -3809,12 +3994,22 @@ defineExpose({
>{{ activeCellDetail.rawValue }}</pre
>
</div>
<div v-if="activeCellDetail.formattedJson" class="space-y-1">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<div v-if="activeCellDetail.formattedJson" class="mt-2 space-y-1">
<div class="flex items-center justify-between gap-2">
<div class="text-muted-foreground">{{ t("grid.formattedJson") }}</div>
<Button
variant="ghost"
size="sm"
class="h-6 px-2 text-xs"
:title="t('grid.copyValue')"
@click="copyDetailFormattedJson"
>
<Copy class="h-3 w-3" />
</Button>
</div>
<pre
class="max-h-72 overflow-auto rounded border bg-muted/20 p-2 font-mono text-xs whitespace-pre-wrap break-words"
>
{{ activeCellDetail.formattedJson }}</pre
>{{ activeCellDetail.formattedJson }}</pre
>
</div>
</div>

View File

@ -1,17 +1,23 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import type { FocusOutsideEvent, PointerDownOutsideEvent } from "reka-ui";
import { CalendarClock, ChevronDown, ChevronUp, CircleSlash } from "lucide-vue-next";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { formatTemporalInputValue, type TemporalCellEditorKind } from "@/lib/dataGridTemporalEditor";
const props = defineProps<{
kind: TemporalCellEditorKind;
modelValue: string;
}>();
const props = withDefaults(
defineProps<{
kind: TemporalCellEditorKind;
modelValue: string;
variant?: "cell" | "inline";
commitOnClose?: boolean;
}>(),
{
variant: "cell",
commitOnClose: true,
},
);
const emit = defineEmits<{
"update:modelValue": [value: string];
@ -19,7 +25,6 @@ const emit = defineEmits<{
cancel: [];
}>();
const { locale } = useI18n();
const open = ref(true);
const triggerRef = ref<HTMLButtonElement | null>(null);
let closeHandled = false;
@ -27,14 +32,11 @@ let closeHandled = false;
const hasDate = computed(() => props.kind !== "time");
const hasTime = computed(() => props.kind !== "date");
const displayValue = computed(() => props.modelValue || "NULL");
const monthOptions = computed(() => {
const formatter = new Intl.DateTimeFormat(locale.value, { month: "short" });
return Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: formatter.format(new Date(2026, index, 1)),
}));
});
const triggerClass = computed(() =>
props.variant === "inline"
? "cell-edit-input flex h-9 w-full items-center gap-2 rounded border bg-background px-2 text-left text-xs outline-none hover:border-primary/60 focus:border-primary"
: "cell-edit-input absolute inset-0 z-10 flex items-center gap-1 border-2 border-primary bg-background px-2 py-0.5 text-left text-xs outline-none",
);
const dateParts = computed(() => {
const text = formatTemporalInputValue(props.modelValue, "date");
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
@ -61,7 +63,7 @@ onMounted(() => {
function setOpen(value: boolean) {
open.value = value;
if (!value && !closeHandled) finishCommit();
if (!value && props.commitOnClose && !closeHandled) finishCommit();
}
function setModelValue(value: string) {
@ -69,18 +71,22 @@ function setModelValue(value: string) {
}
function updateDate(part: "day" | "month" | "year", rawValue: string | number) {
const next = { ...dateParts.value, [part]: Number(rawValue) || dateParts.value[part] };
const numberValue = Number(rawValue);
const next = { ...dateParts.value };
if (Number.isNaN(numberValue)) return;
if (part === "year") next.year = Math.max(1, Math.min(9999, numberValue));
else if (part === "month") next.month = Math.max(1, Math.min(12, numberValue));
else next.day = Math.max(1, Math.min(31, numberValue));
const maxDay = daysInMonth(next.year, next.month);
next.day = Math.max(1, Math.min(maxDay, next.day));
setDateTimeValue(next.year, next.month, next.day, timeValue.value);
}
function updateDateFromSelect(part: "day" | "month", rawValue: unknown) {
if (rawValue === null || rawValue === undefined) return;
updateDate(part, String(rawValue));
function updateDateFromInput(part: "day" | "month" | "year", event: Event) {
updateDate(part, (event.target as HTMLInputElement).value);
}
function stepDate(part: "year", delta: number) {
function stepDate(part: "day" | "month" | "year", delta: number) {
updateDate(part, dateParts.value[part] + delta);
}
@ -94,6 +100,10 @@ function updateTime(part: "hour" | "minute" | "second", rawValue: string | numbe
setDateTimeValue(dateParts.value.year, dateParts.value.month, dateParts.value.day, nextTime);
}
function updateTimeFromInput(part: "hour" | "minute" | "second", event: Event) {
updateTime(part, (event.target as HTMLInputElement).value);
}
function stepTime(part: "hour" | "minute" | "second", delta: number) {
const max = part === "hour" ? 23 : 59;
const current = Number(timeParts.value[part]) || 0;
@ -154,7 +164,8 @@ function isSelectInteractionTarget(target: EventTarget | null): boolean {
}
function normalizeTimePart(value: string | number, max: number): string {
const numberValue = Math.max(0, Math.min(max, Number(value) || 0));
const parsed = Number(value);
const numberValue = Math.max(0, Math.min(max, Number.isNaN(parsed) ? 0 : parsed));
return String(numberValue).padStart(2, "0");
}
@ -178,13 +189,7 @@ function twoDigit(value: string | number): string {
<template>
<Popover :open="open" @update:open="setOpen">
<PopoverTrigger as-child>
<button
ref="triggerRef"
type="button"
class="cell-edit-input absolute inset-0 z-10 flex items-center gap-1 border-2 border-primary bg-background px-2 py-0.5 text-left text-xs outline-none"
@keydown.stop="onKeydown"
@click.stop
>
<button ref="triggerRef" type="button" :class="triggerClass" @keydown.stop="onKeydown" @click.stop>
<CalendarClock class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span class="min-w-0 flex-1 truncate">{{ displayValue }}</span>
</button>
@ -197,47 +202,16 @@ function twoDigit(value: string | number): string {
@keydown.stop="onKeydown"
@interact-outside="onPopoverInteractOutside"
>
<div v-if="hasDate" class="grid grid-cols-[3.5rem_4.75rem_5rem] gap-1.5">
<Select
:model-value="String(dateParts.day)"
@update:model-value="(value) => updateDateFromSelect('day', value)"
<div v-if="hasDate" class="grid grid-cols-[4.5rem_4.5rem_4.5rem] gap-1.5">
<div
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
>
<SelectTrigger class="h-7 w-full rounded-md px-2 text-[13px] tabular-nums">
<SelectValue />
</SelectTrigger>
<SelectContent class="min-w-16">
<SelectItem
v-for="day in daysInMonth(dateParts.year, dateParts.month)"
:key="day"
:value="String(day)"
class="py-0.5 text-xs"
>
{{ day }}
</SelectItem>
</SelectContent>
</Select>
<Select
:model-value="String(dateParts.month)"
@update:model-value="(value) => updateDateFromSelect('month', value)"
>
<SelectTrigger class="h-7 w-full rounded-md px-2 text-[13px]">
<SelectValue />
</SelectTrigger>
<SelectContent class="min-w-20">
<SelectItem
v-for="month in monthOptions"
:key="month.value"
:value="String(month.value)"
class="py-0.5 text-xs"
>
{{ month.label }}
</SelectItem>
</SelectContent>
</Select>
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">{{ dateParts.year }}</div>
<input
:value="dateParts.year"
inputmode="numeric"
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
@change="updateDateFromInput('year', $event)"
/>
<div class="grid border-l">
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('year', 1)">
<ChevronUp class="h-3 w-3" />
@ -251,13 +225,64 @@ function twoDigit(value: string | number): string {
</button>
</div>
</div>
<div
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
>
<input
:value="twoDigit(dateParts.month)"
inputmode="numeric"
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
@change="updateDateFromInput('month', $event)"
/>
<div class="grid border-l">
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('month', 1)">
<ChevronUp class="h-3 w-3" />
</button>
<button
type="button"
class="flex items-center justify-center border-t hover:bg-muted"
@click="stepDate('month', -1)"
>
<ChevronDown class="h-3 w-3" />
</button>
</div>
</div>
<div
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
>
<input
:value="twoDigit(dateParts.day)"
inputmode="numeric"
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
@change="updateDateFromInput('day', $event)"
/>
<div class="grid border-l">
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepDate('day', 1)">
<ChevronUp class="h-3 w-3" />
</button>
<button
type="button"
class="flex items-center justify-center border-t hover:bg-muted"
@click="stepDate('day', -1)"
>
<ChevronDown class="h-3 w-3" />
</button>
</div>
</div>
</div>
<div v-if="hasTime" class="grid grid-cols-[3.5rem_0.5rem_3.5rem_0.5rem_3.5rem] items-center gap-1.5">
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">
{{ twoDigit(timeParts.hour) }}
</div>
<div
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
>
<input
:value="twoDigit(timeParts.hour)"
inputmode="numeric"
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
@change="updateTimeFromInput('hour', $event)"
/>
<div class="grid border-l">
<button type="button" class="flex items-center justify-center hover:bg-muted" @click="stepTime('hour', 1)">
<ChevronUp class="h-3 w-3" />
@ -272,10 +297,15 @@ function twoDigit(value: string | number): string {
</div>
</div>
<span class="text-center text-xs text-muted-foreground">:</span>
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">
{{ twoDigit(timeParts.minute) }}
</div>
<div
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
>
<input
:value="twoDigit(timeParts.minute)"
inputmode="numeric"
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
@change="updateTimeFromInput('minute', $event)"
/>
<div class="grid border-l">
<button
type="button"
@ -294,10 +324,15 @@ function twoDigit(value: string | number): string {
</div>
</div>
<span class="text-center text-xs text-muted-foreground">:</span>
<div class="grid h-7 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background">
<div class="flex items-center justify-center px-1 text-[13px] tabular-nums">
{{ twoDigit(timeParts.second) }}
</div>
<div
class="grid h-7 min-w-0 grid-cols-[1fr_1.35rem] overflow-hidden rounded-md border border-input bg-background"
>
<input
:value="twoDigit(timeParts.second)"
inputmode="numeric"
class="min-w-0 bg-transparent px-1 text-center text-[13px] tabular-nums outline-none"
@change="updateTimeFromInput('second', $event)"
/>
<div class="grid border-l">
<button
type="button"

View File

@ -43,6 +43,7 @@ type DataGridHandle = {
isColumnVisible: (columnIndex: number) => boolean;
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
showDdl: boolean;
toggleDdl: () => void;
};
@ -496,15 +497,26 @@ defineExpose({ focusSearch, refreshData });
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
@click="dataGridRef?.showAllColumns()"
>
{{ t("grid.showAllColumns") }}
</Button>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1"
@click="dataGridRef?.invertColumnVisibility()"
>
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
@click="dataGridRef?.showAllColumns()"
>
{{ t("grid.showAllColumns") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>

View File

@ -366,6 +366,7 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
if (e.key === "Enter") {
e.preventDefault();
commitEdit();
nextTick(focusScrollerWithoutScrolling);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();

View File

@ -306,6 +306,7 @@ export default {
columnVisibility: "Columns",
columnVisibilityHint: "At least one column stays visible.",
searchColumns: "Search columns...",
invertColumnVisibility: "Invert",
showAllColumns: "Show all",
moreValues: "{count} more values, keep typing to narrow results",
filterByValue: "Filter by This Value",

View File

@ -276,6 +276,7 @@ export default {
columnVisibility: "Columnas",
columnVisibilityHint: "Al menos una columna permanece visible.",
searchColumns: "Buscar columnas...",
invertColumnVisibility: "Invertir",
showAllColumns: "Mostrar todo",
moreValues: "{count} valores más, sigue escribiendo para acotar los resultados",
filterByValue: "Filtrar por este valor",

View File

@ -303,6 +303,7 @@ export default {
columnVisibility: "字段筛选",
columnVisibilityHint: "至少保留一列可见。",
searchColumns: "搜索字段...",
invertColumnVisibility: "反选",
showAllColumns: "显示全部",
moreValues: "还有 {count} 个值,输入关键词继续缩小范围",
filterByValue: "筛选此值",

View File

@ -33,3 +33,14 @@ export function nextHiddenColumnIndexes(options: {
next.add(options.columnIndex);
return next;
}
export function invertedHiddenColumnIndexes(
availableIndexes: number[],
hiddenIndexes: ReadonlySet<number>,
): Set<number> {
const next = new Set(availableIndexes.filter((index) => !hiddenIndexes.has(index)));
if (next.size === availableIndexes.length && availableIndexes.length > 0) {
next.delete(availableIndexes[0]);
}
return next;
}

View File

@ -2,6 +2,7 @@ import { strict as assert } from "node:assert";
import test from "node:test";
import {
filterColumnVisibilityOptions,
invertedHiddenColumnIndexes,
nextHiddenColumnIndexes,
visibleColumnIndexesForFilter,
} from "../../apps/desktop/src/lib/dataGridColumnVisibility.ts";
@ -37,3 +38,15 @@ test("shows a hidden column again when toggled", () => {
assert.deepEqual([...hidden].sort(), [2]);
});
test("inverts hidden column indexes", () => {
const hidden = invertedHiddenColumnIndexes([0, 1, 2, 3], new Set([1, 3]));
assert.deepEqual([...hidden].sort(), [0, 2]);
});
test("keeps one column visible when inverting all visible columns", () => {
const hidden = invertedHiddenColumnIndexes([0, 1, 2], new Set());
assert.deepEqual([...hidden].sort(), [1, 2]);
});