mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 08:18:52 +00:00
fix(editor): keep scroll after closing sql parameter dialog
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type ComponentPublicInstance } from "vue";
|
||||
import { computed, ref, type ComponentPublicInstance } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, Check, Copy, Loader2, TextWrap } from "@lucide/vue";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useDialogEditorFocusRestore } from "@/composables/useDialogEditorFocusRestore";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { createBoundedTextPreview } from "@/lib/common/boundedTextPreview";
|
||||
@@ -21,10 +22,7 @@ const suppressFuturePrompts = defineModel<boolean>("suppressFuturePrompts", { de
|
||||
const wrap = ref(true);
|
||||
const copied = ref(false);
|
||||
|
||||
// The CodeMirror editor (if any) that was focused when this dialog opened, so
|
||||
// its internal selection can be restored without letting the browser move the caret.
|
||||
let editorRootToRestoreFocus: HTMLElement | null = null;
|
||||
let focusRestoreGeneration = 0;
|
||||
const { onCloseAutoFocus: onDangerDialogCloseAutoFocus } = useDialogEditorFocusRestore(open);
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -88,19 +86,6 @@ const dialogOpen = computed({
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => open.value,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
focusRestoreGeneration += 1;
|
||||
// Keep the original editor while a prior close animation is still pending.
|
||||
if (editorRootToRestoreFocus) return;
|
||||
const active = document.activeElement;
|
||||
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const confirmButtonRef = ref<ComponentPublicInstance | HTMLButtonElement | null>(null);
|
||||
const cancelButtonRef = ref<ComponentPublicInstance | HTMLButtonElement | null>(null);
|
||||
|
||||
@@ -122,32 +107,6 @@ function onDangerDialogOpenAutoFocus(event: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores focus through CodeMirror's own `EditorView.focus()` instead of the browser default.
|
||||
*
|
||||
* Reka UI's default close-auto-focus calls plain DOM `.focus()` on the element that was active
|
||||
* before the dialog opened. In WebKit, refocusing a CodeMirror contenteditable this way can reset
|
||||
* its caret to the start of the document, which makes the editor scroll to the top (#7692).
|
||||
* `EditorView.focus()` restores CodeMirror's internal selection without creating that false change.
|
||||
*/
|
||||
function onDangerDialogCloseAutoFocus(event: Event) {
|
||||
const target = editorRootToRestoreFocus;
|
||||
if (!target || !target.isConnected) {
|
||||
editorRootToRestoreFocus = null;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
// An interrupted close may emit after the dialog has reopened. Suppress the stale native
|
||||
// restoration, but retain the editor for the next completed close.
|
||||
if (dialogOpen.value) return;
|
||||
const generation = focusRestoreGeneration;
|
||||
void import("@codemirror/view").then(({ EditorView }) => {
|
||||
if (dialogOpen.value || generation !== focusRestoreGeneration || editorRootToRestoreFocus !== target) return;
|
||||
editorRootToRestoreFocus = null;
|
||||
EditorView.findFromDOM(target)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function onConfirm() {
|
||||
// Guard here as well as on the button: a disabled button still fires on some
|
||||
// synthetic/keyboard paths, and this one gates a destructive operation.
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
|
||||
import { loadSqlParameterHistory, rememberSqlParameterValues } from "@/lib/sql/sqlParameterHistory";
|
||||
import { substituteSqlParameters, type SqlParameterDescriptor, type SqlParameterInput, type SqlParameterSyntax, type SqlParameterValueKind } from "@/lib/sql/sqlParameters";
|
||||
import { useDialogEditorFocusRestore } from "@/composables/useDialogEditorFocusRestore";
|
||||
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
@@ -37,6 +38,8 @@ const histories = ref<Record<string, SqlParameterInput[]>>({});
|
||||
const activeHistoryName = ref("");
|
||||
let closeHistoryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const { onCloseAutoFocus: onSqlParameterDialogCloseAutoFocus } = useDialogEditorFocusRestore(open);
|
||||
|
||||
const parameterKinds: SqlParameterValueKind[] = ["string", "number", "boolean", "null", "raw"];
|
||||
|
||||
const syntaxLabels: Record<SqlParameterSyntax, string> = {
|
||||
@@ -150,7 +153,7 @@ async function copyResolvedSql() {
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="open">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]">
|
||||
<DialogContent class="max-h-[86vh] border border-border !bg-background-solid text-foreground shadow-2xl !backdrop-blur-none sm:max-w-[720px]" @close-auto-focus="onSqlParameterDialogCloseAutoFocus">
|
||||
<DialogHeader>
|
||||
<DialogTitle class="flex items-center gap-2">
|
||||
<Braces class="h-5 w-5 text-primary" />
|
||||
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { createApp, defineComponent, h, inject, nextTick, provide, reactive, toRef, type App, type Ref, watch } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "@/i18n";
|
||||
import SqlParameterDialog from "@/components/editor/SqlParameterDialog.vue";
|
||||
import type { SqlParameterDescriptor, SqlParameterInput } from "@/lib/sql/sqlParameters";
|
||||
|
||||
const historyMocks = vi.hoisted(() => ({
|
||||
load: vi.fn<(key: string) => SqlParameterInput[]>(),
|
||||
remember: vi.fn<(values: Record<string, SqlParameterInput>) => Record<string, SqlParameterInput[]>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/sql/sqlParameterHistory", () => ({
|
||||
loadSqlParameterHistory: historyMocks.load,
|
||||
rememberSqlParameterValues: historyMocks.remember,
|
||||
}));
|
||||
|
||||
historyMocks.load.mockReturnValue([]);
|
||||
|
||||
vi.mock("@/composables/useSqlHighlighter", () => ({
|
||||
useSqlHighlighter: () => ({ highlight: (sql: string) => sql }),
|
||||
}));
|
||||
|
||||
const dialogHarness = vi.hoisted(() => {
|
||||
const pendingCloseAutoFocus: Array<() => Event> = [];
|
||||
return {
|
||||
pendingCloseAutoFocus,
|
||||
flushCloseAutoFocus: () => pendingCloseAutoFocus.shift()?.(),
|
||||
reset: () => pendingCloseAutoFocus.splice(0),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => {
|
||||
const dialogContextKey = Symbol("sql-parameter-dialog-focus-test");
|
||||
type DialogContext = { open: Readonly<Ref<boolean>>; trigger: Element | null };
|
||||
|
||||
const passthrough = () =>
|
||||
defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h("div", slots.default?.()),
|
||||
});
|
||||
|
||||
const Dialog = defineComponent({
|
||||
props: { open: { type: Boolean, default: false } },
|
||||
setup(props, { slots }) {
|
||||
provide<DialogContext>(dialogContextKey, {
|
||||
open: toRef(props, "open"),
|
||||
trigger: document.activeElement,
|
||||
});
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
const DialogContent = defineComponent({
|
||||
emits: ["openAutoFocus", "closeAutoFocus"],
|
||||
setup(_, { emit, slots }) {
|
||||
const context = inject<DialogContext>(dialogContextKey);
|
||||
if (!context) throw new Error("DialogContent must be nested in Dialog");
|
||||
|
||||
watch(context.open, (isOpen, wasOpen) => {
|
||||
if (isOpen || !wasOpen) return;
|
||||
dialogHarness.pendingCloseAutoFocus.push(() => {
|
||||
const event = new Event("close-auto-focus", { cancelable: true });
|
||||
emit("closeAutoFocus", event);
|
||||
if (!event.defaultPrevented && context.trigger instanceof HTMLElement && context.trigger.isConnected) {
|
||||
context.trigger.focus();
|
||||
context.trigger.dispatchEvent(new Event("test-native-focus-restoration"));
|
||||
}
|
||||
return event;
|
||||
});
|
||||
});
|
||||
|
||||
return () => h("div", slots.default?.());
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter: passthrough(),
|
||||
DialogHeader: passthrough(),
|
||||
DialogTitle: passthrough(),
|
||||
};
|
||||
});
|
||||
|
||||
const SQL = "SELECT ${value}";
|
||||
const PARAMETERS: SqlParameterDescriptor[] = [{ key: "value", name: "value", syntax: "shell", token: "${value}" }];
|
||||
|
||||
const mountedApps: App[] = [];
|
||||
const editorViews: EditorView[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
dialogHarness.reset();
|
||||
for (const app of mountedApps.splice(0)) app.unmount();
|
||||
for (const view of editorViews.splice(0)) view.destroy();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
function mountDialog(state: { open: boolean }) {
|
||||
const dialogHost = document.createElement("div");
|
||||
document.body.append(dialogHost);
|
||||
const app = createApp(
|
||||
defineComponent({
|
||||
setup: () => () =>
|
||||
h(SqlParameterDialog, {
|
||||
open: state.open,
|
||||
sql: SQL,
|
||||
parameters: PARAMETERS,
|
||||
databaseType: "mysql",
|
||||
enabledSyntaxes: ["shell"],
|
||||
"onUpdate:open": (open: boolean) => {
|
||||
state.open = open;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mountedApps.push(app);
|
||||
app.use(i18n);
|
||||
app.mount(dialogHost);
|
||||
return dialogHost;
|
||||
}
|
||||
|
||||
function createEditor() {
|
||||
const editorHost = document.createElement("div");
|
||||
document.body.append(editorHost);
|
||||
const view = new EditorView({
|
||||
state: EditorState.create({
|
||||
doc: "SELECT :value;\nSELECT 2;\nSELECT 3;",
|
||||
selection: { anchor: 20 },
|
||||
}),
|
||||
parent: editorHost,
|
||||
});
|
||||
editorViews.push(view);
|
||||
view.focus();
|
||||
view.contentDOM.addEventListener("test-native-focus-restoration", () => {
|
||||
view.dispatch({ selection: { anchor: 0 } });
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
describe("SqlParameterDialog focus restoration", () => {
|
||||
it("prevents native restoration and keeps the CodeMirror selection after cancel", async () => {
|
||||
const view = createEditor();
|
||||
const state = reactive({ open: true });
|
||||
mountDialog(state);
|
||||
await nextTick();
|
||||
|
||||
state.open = false;
|
||||
await nextTick();
|
||||
|
||||
const closeAutoFocus = dialogHarness.flushCloseAutoFocus();
|
||||
expect(closeAutoFocus?.defaultPrevented).toBe(true);
|
||||
await vi.waitFor(() => expect(document.activeElement).toBe(view.contentDOM));
|
||||
expect(view.state.selection.main.head).toBe(20);
|
||||
});
|
||||
|
||||
it("leaves default restoration available for non-CodeMirror triggers", async () => {
|
||||
const trigger = document.createElement("button");
|
||||
document.body.append(trigger);
|
||||
trigger.focus();
|
||||
|
||||
const state = reactive({ open: true });
|
||||
mountDialog(state);
|
||||
await nextTick();
|
||||
state.open = false;
|
||||
await nextTick();
|
||||
|
||||
const closeAutoFocus = dialogHarness.flushCloseAutoFocus();
|
||||
expect(closeAutoFocus?.defaultPrevented).toBe(false);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { watch, type Ref } from "vue";
|
||||
|
||||
/**
|
||||
* Restores focus to the CodeMirror editor that was focused when a dialog opened.
|
||||
*
|
||||
* Reka UI's default close-auto-focus calls plain DOM `.focus()` on the element that was active
|
||||
* before the dialog opened. In WebKit, refocusing a CodeMirror contenteditable this way can reset
|
||||
* its caret to the start of the document, which makes the editor scroll to the top (#7692).
|
||||
* `EditorView.focus()` restores CodeMirror's internal selection without creating that false change.
|
||||
*
|
||||
* The returned handler suppresses the native restoration only when a CodeMirror editor actually
|
||||
* held focus at open time; other triggers keep the default behavior.
|
||||
*/
|
||||
export function useDialogEditorFocusRestore(open: Ref<boolean>) {
|
||||
// The CodeMirror editor (if any) that was focused when this dialog opened, so
|
||||
// its internal selection can be restored without letting the browser move the caret.
|
||||
let editorRootToRestoreFocus: HTMLElement | null = null;
|
||||
let focusRestoreGeneration = 0;
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (!isOpen) return;
|
||||
focusRestoreGeneration += 1;
|
||||
// Keep the original editor while a prior close animation is still pending.
|
||||
if (editorRootToRestoreFocus) return;
|
||||
const active = document.activeElement;
|
||||
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function onCloseAutoFocus(event: Event) {
|
||||
const target = editorRootToRestoreFocus;
|
||||
if (!target || !target.isConnected) {
|
||||
editorRootToRestoreFocus = null;
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
// An interrupted close may emit after the dialog has reopened. Suppress the stale native
|
||||
// restoration, but retain the editor for the next completed close.
|
||||
if (open.value) return;
|
||||
const generation = focusRestoreGeneration;
|
||||
void import("@codemirror/view").then(({ EditorView }) => {
|
||||
if (open.value || generation !== focusRestoreGeneration || editorRootToRestoreFocus !== target) return;
|
||||
editorRootToRestoreFocus = null;
|
||||
EditorView.findFromDOM(target)?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
return { onCloseAutoFocus };
|
||||
}
|
||||
Reference in New Issue
Block a user