feat(editor): add keyboard navigation to type selector and column resize to structure editor

- SearchableSelect: support ArrowUp/Down to navigate filtered options, Enter to select, Escape to dismiss
- TableStructureEditor: add drag-to-resize column widths and horizontal scrolling for the columns table
This commit is contained in:
t8y2 2026-05-29 00:22:08 +08:00
parent a1e00fcf1a
commit ab12ab5c11
2 changed files with 113 additions and 30 deletions

View File

@ -92,9 +92,28 @@ const warnings = ref<string[]>([]);
const foreignKeys = ref<ForeignKeyInfo[]>([]);
const triggers = ref<TriggerInfo[]>([]);
const colWidths = ref([28, 128, 144, 96, 64, 56, 112, 128, 128]);
const colResizing = ref<{ col: number; startX: number; startW: number } | null>(null);
const indexColWidths = ref([132, 200, 64, 96, 132, 160, 132, 76]);
const resizing = ref<{ col: number; startX: number; startW: number } | null>(null);
function onColResize(e: MouseEvent, col: number) {
e.preventDefault();
colResizing.value = { col, startX: e.clientX, startW: colWidths.value[col] };
const onMove = (ev: MouseEvent) => {
if (!colResizing.value) return;
const delta = ev.clientX - colResizing.value.startX;
colWidths.value[col] = Math.max(28, colResizing.value.startW + delta);
};
const onUp = () => {
colResizing.value = null;
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
function onIndexColResize(e: MouseEvent, col: number) {
e.preventDefault();
resizing.value = { col, startX: e.clientX, startW: indexColWidths.value[col] };
@ -128,6 +147,17 @@ const indexTypeOptions = computed(() =>
structureCapabilities.value.indexType ? (indexTypesByDb[databaseType.value ?? ""] ?? []) : [],
);
const colLabels = computed(() => [
"#",
t("structureEditor.columnName"),
t("structureEditor.dataType"),
t("structureEditor.length"),
t("structureEditor.nullable"),
t("structureEditor.primaryKey"),
t("structureEditor.defaultValue"),
t("structureEditor.comment"),
t("structureEditor.actions"),
]);
const indexColLabels = computed(() => [
t("structureEditor.indexName"),
t("structureEditor.indexColumns"),
@ -508,33 +538,26 @@ watch(
</div>
<TabsContent value="columns" class="m-0 min-h-0 flex-1 overflow-auto p-0">
<table class="min-w-full border-separate border-spacing-0 text-[11px]">
<table
class="border-separate border-spacing-0 text-[11px]"
:style="{ minWidth: colWidths.reduce((a, w) => a + w, 0) + 'px' }"
>
<thead class="sticky top-0 z-10 bg-background">
<tr>
<th class="w-7 border-b border-r px-1.5 py-1.5 text-left">#</th>
<th class="min-w-32 border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.columnName") }}
</th>
<th class="w-36 border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.dataType") }}
</th>
<th class="w-24 border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.length") }}
</th>
<th class="w-16 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.nullable") }}
</th>
<th class="w-14 whitespace-nowrap border-b border-r px-1.5 py-1.5 text-center">
{{ t("structureEditor.primaryKey") }}
</th>
<th class="min-w-28 border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.defaultValue") }}
</th>
<th class="min-w-32 border-b border-r px-1.5 py-1.5 text-left">
{{ t("structureEditor.comment") }}
</th>
<th class="w-32 border-b px-1.5 py-1.5 text-left">
{{ t("structureEditor.actions") }}
<th
v-for="(label, i) in colLabels"
:key="i"
class="relative border-b border-r px-1.5 py-1.5 text-left"
:class="{ 'text-center': i === 5 }"
:style="{ width: colWidths[i] + 'px', minWidth: colWidths[i] + 'px' }"
>
{{ label }}
<div
v-if="i < colLabels.length - 1"
class="absolute right-0 top-0 z-20 h-full w-1 cursor-col-resize hover:bg-primary/30"
:class="colResizing?.col === i ? 'bg-primary/30' : ''"
@mousedown="onColResize($event, i)"
/>
</th>
</tr>
</thead>

View File

@ -39,6 +39,8 @@ const emit = defineEmits<{
const open = ref(false);
const searchText = ref("");
const searchInput = ref<InstanceType<typeof Input>>();
const listContainer = ref<HTMLDivElement>();
const highlightIndex = ref(-1);
const selectedLabel = computed(() => {
if (!props.modelValue) return props.placeholder;
@ -55,6 +57,7 @@ watch(open, async (value) => {
emit("update:open", value);
if (!value) {
searchText.value = "";
highlightIndex.value = -1;
return;
}
await nextTick();
@ -62,6 +65,19 @@ watch(open, async (value) => {
input?.focus();
});
watch(searchText, () => {
highlightIndex.value = 0;
});
watch(highlightIndex, async () => {
await nextTick();
const container = listContainer.value;
if (!container || highlightIndex.value < 0) return;
const buttons = container.querySelectorAll("button");
const target = buttons[highlightIndex.value];
target?.scrollIntoView({ block: "nearest" });
});
function selectOption(option: string) {
emit("update:modelValue", option);
open.value = false;
@ -71,6 +87,34 @@ function selectCustomOption() {
if (!canSelectCustom.value) return;
selectOption(customOptionValue.value);
}
function optionCount() {
return filteredOptions.value.length + (canSelectCustom.value ? 1 : 0);
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === "ArrowDown") {
event.preventDefault();
const total = optionCount();
if (total === 0) return;
highlightIndex.value = highlightIndex.value < total - 1 ? highlightIndex.value + 1 : 0;
} else if (event.key === "ArrowUp") {
event.preventDefault();
const total = optionCount();
if (total === 0) return;
highlightIndex.value = highlightIndex.value > 0 ? highlightIndex.value - 1 : total - 1;
} else if (event.key === "Enter") {
if (highlightIndex.value < 0 || highlightIndex.value >= optionCount()) return;
event.preventDefault();
if (highlightIndex.value < filteredOptions.value.length) {
selectOption(filteredOptions.value[highlightIndex.value]);
} else {
selectCustomOption();
}
} else if (event.key === "Escape") {
open.value = false;
}
}
</script>
<template>
@ -101,18 +145,24 @@ function selectCustomOption() {
:placeholder="searchPlaceholder"
class="h-6 border-0 px-0 text-sm shadow-none focus-visible:ring-0"
@update:model-value="(value) => (searchText = String(value))"
@keydown="handleKeydown"
/>
</div>
<div class="max-h-64 overflow-y-auto py-1">
<div ref="listContainer" class="max-h-64 overflow-y-auto py-1">
<div v-if="loading" class="px-2 py-2 text-sm text-muted-foreground">
{{ loadingText }}
</div>
<template v-else-if="filteredOptions.length">
<button
v-for="option in filteredOptions"
v-for="(option, index) in filteredOptions"
:key="option"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
:class="
cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
index === highlightIndex && 'bg-accent text-accent-foreground',
)
"
@click="selectOption(option)"
>
<Check :class="cn('h-3.5 w-3.5 shrink-0', option === modelValue ? 'opacity-100' : 'opacity-0')" />
@ -123,7 +173,12 @@ function selectCustomOption() {
<button
v-if="canSelectCustom"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
:class="
cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
filteredOptions.length === highlightIndex && 'bg-accent text-accent-foreground',
)
"
@click="selectCustomOption"
>
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />
@ -135,7 +190,12 @@ function selectCustomOption() {
<button
v-else-if="canSelectCustom"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
:class="
cn(
'flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none',
0 === highlightIndex && 'bg-accent text-accent-foreground',
)
"
@click="selectCustomOption"
>
<Check class="h-3.5 w-3.5 shrink-0 opacity-0" />