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:
parent
a1e00fcf1a
commit
ab12ab5c11
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
Loading…
Reference in New Issue