fix(data-grid): restore desktop filter rule dragging
This commit is contained in:
5 files changed
+335
-35
No files matched your search
@@ -67,7 +67,12 @@ const dropTargetIndex = ref<number>();
|
||||
let ruleIdsBeforeKeyboardAdd: Set<string> | undefined;
|
||||
let ruleDragContainer: HTMLElement | undefined;
|
||||
let ruleDragPointerY: number | undefined;
|
||||
let ruleDragPointerId: number | undefined;
|
||||
let ruleDragHandle: HTMLElement | undefined;
|
||||
let ruleAutoScrollFrame: number | undefined;
|
||||
let ruleDragBodyStyleActive = false;
|
||||
let ruleDragPreviousBodyUserSelect = "";
|
||||
let ruleDragPreviousBodyCursor = "";
|
||||
|
||||
function columnNameDisplayUnits(value: string) {
|
||||
return Array.from(value).reduce((total, character) => total + ((character.codePointAt(0) ?? 0) > 0xff ? 2 : 1), 0);
|
||||
@@ -140,8 +145,11 @@ function stopRuleAutoScroll() {
|
||||
}
|
||||
|
||||
function removeRuleDragListeners() {
|
||||
window.removeEventListener("dragover", handleRuleContainerDragOver, true);
|
||||
window.removeEventListener("drop", handleRuleContainerDrop, true);
|
||||
window.removeEventListener("pointermove", handleRulePointerMove, true);
|
||||
window.removeEventListener("pointerup", handleRulePointerUp, true);
|
||||
window.removeEventListener("pointercancel", handleRulePointerCancel, true);
|
||||
window.removeEventListener("keydown", handleRuleDragKeydown, true);
|
||||
window.removeEventListener("blur", handleRuleDragWindowBlur);
|
||||
}
|
||||
|
||||
function clearRuleDropTarget() {
|
||||
@@ -153,14 +161,22 @@ function clearRuleDropTarget() {
|
||||
function clearRuleDragState() {
|
||||
stopRuleAutoScroll();
|
||||
removeRuleDragListeners();
|
||||
if (ruleDragHandle && ruleDragPointerId !== undefined && ruleDragHandle.hasPointerCapture?.(ruleDragPointerId)) ruleDragHandle.releasePointerCapture(ruleDragPointerId);
|
||||
if (ruleDragBodyStyleActive) {
|
||||
document.body.style.userSelect = ruleDragPreviousBodyUserSelect;
|
||||
document.body.style.cursor = ruleDragPreviousBodyCursor;
|
||||
}
|
||||
draggingRuleId.value = undefined;
|
||||
ruleDragContainer = undefined;
|
||||
ruleDragPointerY = undefined;
|
||||
ruleDragPointerId = undefined;
|
||||
ruleDragHandle = undefined;
|
||||
ruleDragBodyStyleActive = false;
|
||||
clearRuleDropTarget();
|
||||
}
|
||||
|
||||
function ruleDropZoneContains(event: DragEvent, bounds: DOMRect) {
|
||||
return event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top - RULE_DROP_ZONE_EXTENSION && event.clientY <= bounds.bottom + RULE_DROP_ZONE_EXTENSION;
|
||||
function ruleDropZoneContains(clientX: number, clientY: number, bounds: DOMRect) {
|
||||
return clientX >= bounds.left && clientX <= bounds.right && clientY >= bounds.top - RULE_DROP_ZONE_EXTENSION && clientY <= bounds.bottom + RULE_DROP_ZONE_EXTENSION;
|
||||
}
|
||||
|
||||
function updateRuleDropTarget(clientY: number) {
|
||||
@@ -214,25 +230,24 @@ function scheduleRuleAutoScroll() {
|
||||
if (ruleAutoScrollFrame === undefined) ruleAutoScrollFrame = window.requestAnimationFrame(runRuleAutoScroll);
|
||||
}
|
||||
|
||||
function handleRuleContainerDragOver(event: DragEvent) {
|
||||
if (!draggingRuleId.value || !ruleDragContainer) return;
|
||||
function handleRulePointerMove(event: PointerEvent) {
|
||||
if (!draggingRuleId.value || !ruleDragContainer || event.pointerId !== ruleDragPointerId) return;
|
||||
const bounds = ruleDragContainer.getBoundingClientRect();
|
||||
if (!ruleDropZoneContains(event, bounds)) {
|
||||
if (!ruleDropZoneContains(event.clientX, event.clientY, bounds)) {
|
||||
stopRuleAutoScroll();
|
||||
clearRuleDropTarget();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
ruleDragPointerY = event.clientY;
|
||||
updateRuleDropTarget(event.clientY);
|
||||
scheduleRuleAutoScroll();
|
||||
}
|
||||
|
||||
function handleRuleContainerDrop(event: DragEvent) {
|
||||
if (!draggingRuleId.value || !ruleDragContainer) return;
|
||||
function handleRulePointerUp(event: PointerEvent) {
|
||||
if (!draggingRuleId.value || !ruleDragContainer || event.pointerId !== ruleDragPointerId) return;
|
||||
const bounds = ruleDragContainer.getBoundingClientRect();
|
||||
if (!ruleDropZoneContains(event, bounds)) {
|
||||
if (!ruleDropZoneContains(event.clientX, event.clientY, bounds)) {
|
||||
clearRuleDragState();
|
||||
return;
|
||||
}
|
||||
@@ -242,23 +257,44 @@ function handleRuleContainerDrop(event: DragEvent) {
|
||||
clearRuleDragState();
|
||||
}
|
||||
|
||||
function startRuleDrag(event: DragEvent, id: string) {
|
||||
if (props.disabled || props.rules.length < 2 || !event.dataTransfer) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
function handleRulePointerCancel(event?: PointerEvent) {
|
||||
if (event && event.pointerId !== ruleDragPointerId) return;
|
||||
clearRuleDragState();
|
||||
}
|
||||
|
||||
function handleRuleDragWindowBlur() {
|
||||
clearRuleDragState();
|
||||
}
|
||||
|
||||
function handleRuleDragKeydown(event: KeyboardEvent) {
|
||||
if (event.key !== "Escape" || !draggingRuleId.value) return;
|
||||
event.preventDefault();
|
||||
clearRuleDragState();
|
||||
}
|
||||
|
||||
function startRulePointerDrag(event: PointerEvent, id: string) {
|
||||
if (event.button !== 0 || props.disabled || props.rules.length < 2) return;
|
||||
const handle = event.currentTarget instanceof HTMLElement ? event.currentTarget : undefined;
|
||||
ruleDragContainer = handle?.closest<HTMLElement>("[data-filter-rules-scroll]") ?? filterBuilderRootRef.value;
|
||||
if (!ruleDragContainer) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (!ruleDragContainer || !handle) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clearRuleDropTarget();
|
||||
draggingRuleId.value = id;
|
||||
window.addEventListener("dragover", handleRuleContainerDragOver, true);
|
||||
window.addEventListener("drop", handleRuleContainerDrop, true);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", id);
|
||||
ruleDragPointerId = event.pointerId;
|
||||
ruleDragPointerY = event.clientY;
|
||||
ruleDragHandle = handle;
|
||||
ruleDragPreviousBodyUserSelect = document.body.style.userSelect;
|
||||
ruleDragPreviousBodyCursor = document.body.style.cursor;
|
||||
ruleDragBodyStyleActive = true;
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.cursor = "grabbing";
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
window.addEventListener("pointermove", handleRulePointerMove, true);
|
||||
window.addEventListener("pointerup", handleRulePointerUp, true);
|
||||
window.addEventListener("pointercancel", handleRulePointerCancel, true);
|
||||
window.addEventListener("keydown", handleRuleDragKeydown, true);
|
||||
window.addEventListener("blur", handleRuleDragWindowBlur);
|
||||
}
|
||||
|
||||
function moveRuleByKeyboard(event: KeyboardEvent, id: string, offset: -1 | 1) {
|
||||
@@ -466,14 +502,16 @@ function blurValueRule(id: string) {
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="emit('clear')"> <Trash2 class="mr-1 h-3.5 w-3.5" />{{ t("grid.clearFilter") }} </Button>
|
||||
</div>
|
||||
|
||||
<div v-if="props.rules.length" :class="props.layout === 'text' ? 'space-y-0' : props.layout === 'panel' ? 'space-y-1' : 'space-y-1.5'">
|
||||
<div v-if="props.rules.length" :class="props.layout === 'popover' ? 'space-y-1.5' : 'space-y-0'">
|
||||
<template v-for="(rule, index) in props.rules" :key="rule.id">
|
||||
<div v-if="index > 0 && (props.layout === 'popover' || rule.conjunction === 'OR')" class="flex" :class="props.layout === 'popover' ? 'justify-center' : props.layout === 'text' ? 'justify-start pl-11' : 'justify-start pl-6'">
|
||||
<Button variant="ghost" size="sm" :class="props.layout === 'text' ? 'h-4 px-1 text-[10px]' : 'h-5 px-2 text-[11px]'" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
|
||||
<div v-if="index > 0 && props.layout === 'popover'" class="flex justify-center">
|
||||
<Button variant="ghost" size="sm" class="h-5 px-2 text-[11px]" @click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })">{{ rule.conjunction }}</Button>
|
||||
</div>
|
||||
<div
|
||||
data-filter-rule-item
|
||||
:ref="(element) => setFilterRuleElement(rule.id, element)"
|
||||
class="filter-rule-row relative grid items-center justify-start gap-1.5"
|
||||
:data-connected="index > 0 && props.layout !== 'popover' ? '' : undefined"
|
||||
:data-dragging="draggingRuleId === rule.id ? '' : undefined"
|
||||
:data-drop-position="dropRuleId === rule.id ? dropPosition : undefined"
|
||||
:class="
|
||||
@@ -484,16 +522,27 @@ function blurValueRule(id: string) {
|
||||
: 'grid-cols-[18px_var(--filter-builder-column-width)_92px_var(--filter-builder-value-width)_auto]'
|
||||
"
|
||||
>
|
||||
<button
|
||||
v-if="index > 0 && props.layout !== 'popover'"
|
||||
type="button"
|
||||
data-filter-rule-connector
|
||||
data-filter-conjunction
|
||||
class="filter-rule-conjunction"
|
||||
:class="rule.conjunction === 'OR' ? 'border-primary/70 text-primary' : ''"
|
||||
:data-conjunction="rule.conjunction"
|
||||
:disabled="props.disabled"
|
||||
@click="emit('updateRule', rule.id, { conjunction: rule.conjunction === 'AND' ? 'OR' : 'AND' })"
|
||||
>
|
||||
{{ rule.conjunction }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-filter-drag-handle
|
||||
class="flex h-6 w-4 cursor-grab items-center justify-center justify-self-center text-muted-foreground/70 outline-none hover:text-foreground focus-visible:text-primary disabled:cursor-default disabled:opacity-30 active:cursor-grabbing"
|
||||
class="flex h-6 w-4 touch-none cursor-grab items-center justify-center justify-self-center text-muted-foreground/70 outline-none hover:text-foreground focus-visible:text-primary disabled:cursor-default disabled:opacity-30 active:cursor-grabbing"
|
||||
:disabled="props.disabled || props.rules.length < 2"
|
||||
:draggable="!props.disabled && props.rules.length > 1"
|
||||
:aria-label="t('grid.filterBuilderReorderRule')"
|
||||
:aria-grabbed="draggingRuleId === rule.id"
|
||||
@dragstart="startRuleDrag($event, rule.id)"
|
||||
@dragend="clearRuleDragState"
|
||||
@pointerdown="startRulePointerDrag($event, rule.id)"
|
||||
@keydown.up="moveRuleByKeyboard($event, rule.id, -1)"
|
||||
@keydown.down="moveRuleByKeyboard($event, rule.id, 1)"
|
||||
>
|
||||
@@ -681,6 +730,45 @@ function blurValueRule(id: string) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-rule-row[data-connected] {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.filter-rule-conjunction {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: -16px;
|
||||
left: 0;
|
||||
display: flex;
|
||||
width: 24px;
|
||||
height: 16px;
|
||||
padding: 0 1px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 2px;
|
||||
background: var(--background);
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.filter-rule-conjunction:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.filter-rule-conjunction:focus-visible {
|
||||
outline: 1px solid var(--primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.filter-rule-conjunction:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.filter-rule-row[data-dragging] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@@ -479,6 +479,13 @@ describe("DataGridFilterBuilder", () => {
|
||||
expect(updateRule).toHaveBeenCalledWith("r1", { disabled: true });
|
||||
expect(add).toHaveBeenCalledTimes(2);
|
||||
expect(findAll(mounted.root, (node) => node.props["data-stub"] === "Select")[2].props.open).toBe(false);
|
||||
const ruleItems = findAll(mounted.root, (node) => node.props["data-filter-rule-item"] === "");
|
||||
const conjunction = findOne(mounted.root, (node) => node.props["data-filter-conjunction"] === "");
|
||||
expect(ruleItems).toHaveLength(2);
|
||||
expect(ruleItems[1].props["data-connected"]).toBe("");
|
||||
expect(hostText(conjunction)).toBe("AND");
|
||||
dispatch(conjunction, "click");
|
||||
expect(updateRule).toHaveBeenCalledWith("r2", { conjunction: "OR" });
|
||||
});
|
||||
|
||||
it("opens the first empty rule column search on request", async () => {
|
||||
@@ -564,7 +571,9 @@ describe("DataGridFilterBuilder", () => {
|
||||
const handles = findAll(mounted.root, (node) => node.props["data-filter-drag-handle"] === "");
|
||||
|
||||
expect(handles).toHaveLength(2);
|
||||
expect(handles.every((handle) => handle.props.draggable === true)).toBe(true);
|
||||
expect(handles.every((handle) => handle.props.draggable === undefined)).toBe(true);
|
||||
expect(handles.every((handle) => typeof handle.props.onPointerdown === "function")).toBe(true);
|
||||
expect(handles.every((handle) => String(handle.props.class).includes("touch-none"))).toBe(true);
|
||||
expect(handles[0].props["aria-label"]).toBe("grid.filterBuilderReorderRule");
|
||||
const arrowDown = dispatch(handles[0], "keydown", { key: "ArrowDown" });
|
||||
expect(arrowDown.defaultPrevented).toBe(true);
|
||||
@@ -1050,6 +1059,7 @@ describe("DataGridFilterWorkbench", () => {
|
||||
const reset = vi.fn();
|
||||
const clear = vi.fn();
|
||||
const copySql = vi.fn();
|
||||
const updateRule = vi.fn();
|
||||
const mounted = mountComponent(DataGridFilterWorkbench, {
|
||||
sqlPreview: "WHERE (tenant_id = 7) AND (status = 'open')",
|
||||
rules: [
|
||||
@@ -1066,6 +1076,7 @@ describe("DataGridFilterWorkbench", () => {
|
||||
onReset: reset,
|
||||
onClear: clear,
|
||||
onCopySql: copySql,
|
||||
onUpdateRule: updateRule,
|
||||
});
|
||||
await nextTick();
|
||||
|
||||
@@ -1076,7 +1087,13 @@ describe("DataGridFilterWorkbench", () => {
|
||||
expect(hostText(mounted.root)).toContain("WHERE (tenant_id = 7) AND (status = 'open')");
|
||||
const ruleScroller = findOne(mounted.root, (node) => node.props["data-filter-rules-scroll"] === "");
|
||||
expect(String(ruleScroller.props.class)).toContain("overflow-auto");
|
||||
expect(findAll(mounted.root, (node) => node.type === "button" && hostText(node) === "AND")).toHaveLength(0);
|
||||
const conjunctions = findAll(mounted.root, (node) => node.props["data-filter-conjunction"] === "");
|
||||
const ruleItems = findAll(mounted.root, (node) => node.props["data-filter-rule-item"] === "");
|
||||
expect(conjunctions).toHaveLength(1);
|
||||
expect(hostText(conjunctions[0])).toBe("AND");
|
||||
expect(ruleItems[1].props["data-connected"]).toBe("");
|
||||
dispatch(conjunctions[0], "click");
|
||||
expect(updateRule).toHaveBeenCalledWith("r2", { conjunction: "OR" });
|
||||
|
||||
dispatch(
|
||||
findOne(mounted.root, (node) => node.props["aria-label"] === "grid.copyFilterSql"),
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, type App, type Component } from "vue";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DataGridStructuredFilterRule } from "@/composables/useDataGridFilterBuilder";
|
||||
|
||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) }));
|
||||
vi.mock("@lucide/vue", () => {
|
||||
const icon = defineComponent({ setup: () => () => h("span") });
|
||||
return { Check: icon, Eye: icon, EyeOff: icon, GripVertical: icon, Plus: icon, Search: icon, Trash2: icon, X: icon };
|
||||
});
|
||||
|
||||
function passthrough(tag = "div") {
|
||||
return defineComponent({
|
||||
inheritAttrs: false,
|
||||
setup(_props, { attrs, slots }) {
|
||||
return () => h(tag, attrs, [slots.default?.(), slots.footer?.()]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock("@/components/ui/button", () => ({ Button: passthrough("button") }));
|
||||
vi.mock("@/components/ui/input", () => ({ Input: passthrough("input") }));
|
||||
vi.mock("@/components/ui/select", () => ({ Select: passthrough(), SelectContent: passthrough(), SelectItem: passthrough(), SelectTrigger: passthrough("button"), SelectValue: passthrough("span") }));
|
||||
|
||||
import DataGridFilterBuilder from "../DataGridFilterBuilder.vue";
|
||||
|
||||
const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
|
||||
|
||||
function rules(count: number): DataGridStructuredFilterRule[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `r${index + 1}`,
|
||||
columnName: `column_${index + 1}`,
|
||||
mode: "equals",
|
||||
rawValue: String(index + 1),
|
||||
rawEndValue: "",
|
||||
conjunction: "AND",
|
||||
}));
|
||||
}
|
||||
|
||||
async function mountBuilder(ruleCount = 3) {
|
||||
const move = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const wrapper: Component = defineComponent({
|
||||
setup() {
|
||||
return () =>
|
||||
h("div", { "data-filter-rules-scroll": "" }, [
|
||||
h(DataGridFilterBuilder, {
|
||||
rules: rules(ruleCount),
|
||||
columns: rules(ruleCount).map((rule) => rule.columnName),
|
||||
filteredColumns: rules(ruleCount).map((rule) => rule.columnName),
|
||||
modeOptions: [{ value: "equals", labelKey: "equals" }],
|
||||
columnSearch: "",
|
||||
layout: "panel",
|
||||
showHeader: false,
|
||||
showFooter: false,
|
||||
onMove: move,
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const app = createApp(wrapper);
|
||||
app.mount(host);
|
||||
mountedApps.push({ app, host });
|
||||
await nextTick();
|
||||
return { host, move };
|
||||
}
|
||||
|
||||
function configureGeometry(host: HTMLElement, scrollerHeight = 120) {
|
||||
const scroller = host.querySelector<HTMLElement>("[data-filter-rules-scroll]")!;
|
||||
let scrollTop = 0;
|
||||
Object.defineProperties(scroller, {
|
||||
clientHeight: { configurable: true, value: scrollerHeight },
|
||||
scrollHeight: { configurable: true, value: 400 },
|
||||
scrollTop: {
|
||||
configurable: true,
|
||||
get: () => scrollTop,
|
||||
set: (value: number) => {
|
||||
scrollTop = Math.max(0, Math.min(400 - scrollerHeight, value));
|
||||
},
|
||||
},
|
||||
});
|
||||
scroller.getBoundingClientRect = () => domRect(0, scrollerHeight, 400);
|
||||
host.querySelectorAll<HTMLElement>(".filter-rule-row").forEach((row, index) => {
|
||||
row.getBoundingClientRect = () => domRect(index * 38 - scrollTop, index * 38 + 28 - scrollTop, 400);
|
||||
});
|
||||
return scroller;
|
||||
}
|
||||
|
||||
function domRect(top: number, bottom: number, width: number): DOMRect {
|
||||
return {
|
||||
left: 0,
|
||||
right: width,
|
||||
top,
|
||||
bottom,
|
||||
width,
|
||||
height: bottom - top,
|
||||
x: 0,
|
||||
y: top,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchPointer(target: EventTarget, type: string, options: { pointerId?: number; clientX?: number; clientY: number }) {
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
pointerId: options.pointerId ?? 1,
|
||||
clientX: options.clientX ?? 20,
|
||||
clientY: options.clientY,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const { app, host } of mountedApps.splice(0)) {
|
||||
app.unmount();
|
||||
host.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("DataGridFilterBuilder pointer dragging", () => {
|
||||
it("reorders rules with pointer events", async () => {
|
||||
const { host, move } = await mountBuilder();
|
||||
configureGeometry(host);
|
||||
const handle = host.querySelectorAll<HTMLButtonElement>("[data-filter-drag-handle]")[0];
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { clientY: 100 });
|
||||
await nextTick();
|
||||
|
||||
expect(document.body.style.userSelect).toBe("none");
|
||||
expect(host.querySelector('[data-drop-position="after"]')).not.toBeNull();
|
||||
dispatchPointer(window, "pointerup", { clientY: 100 });
|
||||
expect(move).toHaveBeenCalledOnce();
|
||||
expect(move).toHaveBeenCalledWith("r1", 2);
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
});
|
||||
|
||||
it("cancels an active pointer drag on Escape", async () => {
|
||||
const { host, move } = await mountBuilder();
|
||||
configureGeometry(host);
|
||||
const handle = host.querySelectorAll<HTMLButtonElement>("[data-filter-drag-handle]")[0];
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { pointerId: 3, clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { pointerId: 3, clientY: 100 });
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }));
|
||||
dispatchPointer(window, "pointerup", { pointerId: 3, clientY: 100 });
|
||||
await nextTick();
|
||||
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
expect(host.querySelector("[data-drop-position]")).toBeNull();
|
||||
expect(document.body.style.cursor).toBe("");
|
||||
});
|
||||
|
||||
it("auto-scrolls the condition list near its bottom edge", async () => {
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let nextFrameId = 1;
|
||||
vi.stubGlobal(
|
||||
"requestAnimationFrame",
|
||||
vi.fn((callback: FrameRequestCallback) => {
|
||||
const frameId = nextFrameId++;
|
||||
frames.set(frameId, callback);
|
||||
return frameId;
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal(
|
||||
"cancelAnimationFrame",
|
||||
vi.fn((frameId: number) => frames.delete(frameId)),
|
||||
);
|
||||
const { host, move } = await mountBuilder(10);
|
||||
const scroller = configureGeometry(host, 100);
|
||||
const handle = host.querySelectorAll<HTMLButtonElement>("[data-filter-drag-handle]")[0];
|
||||
|
||||
dispatchPointer(handle, "pointerdown", { pointerId: 7, clientY: 14 });
|
||||
dispatchPointer(window, "pointermove", { pointerId: 7, clientY: 96 });
|
||||
for (let frameIndex = 0; frameIndex < 8; frameIndex += 1) {
|
||||
const callbacks = [...frames.values()];
|
||||
frames.clear();
|
||||
callbacks.forEach((callback) => callback(frameIndex));
|
||||
}
|
||||
|
||||
expect(scroller.scrollTop).toBeGreaterThan(0);
|
||||
dispatchPointer(window, "pointercancel", { pointerId: 7, clientY: 96 });
|
||||
expect(move).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1913,7 +1913,7 @@ export default withEnglishFallback({
|
||||
rollback: "回滚",
|
||||
transactionSaveHint: "在事务中提交 {count} 项待保存更改。",
|
||||
nonTransactionalSaveHint: "逐条保存 {count} 项更改;如果中途失败,前面已成功的更改不会回滚。",
|
||||
keylessEditWarning: "无主键定位",
|
||||
keylessEditWarning: "无主键",
|
||||
keylessEditWarningHint: "这张表没有主键。保存更新或删除时会使用整行原始值作为 WHERE 条件;如果存在完全重复的行,可能会影响多行。",
|
||||
queryEditReadOnly: "只读结果",
|
||||
queryEditUnsupported: {
|
||||
|
||||
@@ -1852,7 +1852,7 @@ export default withEnglishFallback({
|
||||
rollback: "回溯",
|
||||
transactionSaveHint: "在事務中提交 {count} 項待儲存更改。",
|
||||
nonTransactionalSaveHint: "逐條儲存 {count} 項更改;如果中途失敗,前面已成功的更改不會回溯。",
|
||||
keylessEditWarning: "無主鍵定位",
|
||||
keylessEditWarning: "無主鍵",
|
||||
keylessEditWarningHint: "此資料表沒有主鍵。更新與刪除會在 WHERE 子句中使用所有原始資料列值;完全重複的資料列可能會一起受到影響。",
|
||||
queryEditReadOnly: "唯讀結果",
|
||||
queryEditUnsupported: {
|
||||
|
||||
Reference in new issue
Block a user