feat : moveable and scaleable floting save
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, KeyboardSensor, PointerSensor, useDraggable, useSensor, useSensors, type Modifier } from "@dnd-kit/core";
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
export const SAVE_LAYOUT_KEY = "buzz-admin-save-layout:v1";
|
||||
export interface SaveLayout { x: number; y: number; width: number; height: number }
|
||||
type PanelSize = Pick<SaveLayout, "width" | "height">;
|
||||
|
||||
export function parseSaveLayout(value: string | null): SaveLayout | null {
|
||||
try {
|
||||
const data = JSON.parse(value ?? "null");
|
||||
if (!data || data.version !== 1 || ![data.x, data.y, data.width, data.height].every((v) => typeof v === "number" && Number.isFinite(v)) || data.width <= 0 || data.height <= 0) return null;
|
||||
return { x: data.x, y: data.y, width: data.width, height: data.height };
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function clampSaveLayout(layout: SaveLayout, width: number, height: number, minimum: PanelSize): SaveLayout {
|
||||
const panelWidth = Math.min(Math.max(minimum.width, layout.width), width);
|
||||
const panelHeight = Math.min(Math.max(minimum.height, layout.height), height);
|
||||
return {
|
||||
x: Math.max(0, Math.min(layout.x, width - panelWidth)),
|
||||
y: Math.max(0, Math.min(layout.y, height - panelHeight)),
|
||||
width: panelWidth,
|
||||
height: panelHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function FloatingSavePanel({ children }: { children: ReactNode }) {
|
||||
const bounds = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const dragStart = useRef<SaveLayout | null>(null);
|
||||
const [preferred, setPreferred] = useState<SaveLayout | null>(null);
|
||||
const [viewport, setViewport] = useState({ width: 0, height: 0 });
|
||||
const [minimum, setMinimum] = useState<PanelSize>({ width: 0, height: 0 });
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), useSensor(KeyboardSensor));
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const measure = () => {
|
||||
const rect = bounds.current?.getBoundingClientRect();
|
||||
if (rect) setViewport({ width: rect.width, height: rect.height });
|
||||
};
|
||||
queueMicrotask(() => {
|
||||
if (!active) return;
|
||||
try { setPreferred(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))); } catch { /* Browser storage is optional. */ }
|
||||
measure();
|
||||
});
|
||||
const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
|
||||
if (bounds.current) observer?.observe(bounds.current);
|
||||
window.addEventListener("resize", measure);
|
||||
window.visualViewport?.addEventListener("resize", measure);
|
||||
return () => {
|
||||
active = false;
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", measure);
|
||||
window.visualViewport?.removeEventListener("resize", measure);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const constrain = (layout: SaveLayout) => clampSaveLayout(layout, bounds.current?.clientWidth ?? viewport.width, bounds.current?.clientHeight ?? viewport.height, minimum);
|
||||
const currentLayout = (): SaveLayout | null => {
|
||||
const rect = panelRef.current?.getBoundingClientRect();
|
||||
const area = bounds.current?.getBoundingClientRect();
|
||||
return rect && area ? { x: rect.left - area.left, y: rect.top - area.top, width: rect.width, height: rect.height } : null;
|
||||
};
|
||||
const commit = (layout: SaveLayout) => {
|
||||
const next = constrain(layout);
|
||||
setPreferred(next);
|
||||
try { window.localStorage.setItem(SAVE_LAYOUT_KEY, JSON.stringify({ version: 1, ...next })); } catch { /* Keep the session layout even when storage is blocked. */ }
|
||||
};
|
||||
const modifier: Modifier = ({ transform, draggingNodeRect }) => {
|
||||
const area = bounds.current?.getBoundingClientRect();
|
||||
if (!area || !draggingNodeRect) return transform;
|
||||
return { ...transform,
|
||||
x: Math.max(area.left - draggingNodeRect.left, Math.min(transform.x, area.right - draggingNodeRect.right)),
|
||||
y: Math.max(area.top - draggingNodeRect.top, Math.min(transform.y, area.bottom - draggingNodeRect.bottom)),
|
||||
};
|
||||
};
|
||||
const layout = preferred && viewport.width > 0 && viewport.height > 0 ? clampSaveLayout(preferred, viewport.width, viewport.height, minimum) : null;
|
||||
|
||||
return (
|
||||
<div ref={bounds} className="pointer-events-none fixed z-40" style={{
|
||||
top: "max(1rem, env(safe-area-inset-top))", left: "max(1rem, env(safe-area-inset-left))",
|
||||
right: "max(1rem, env(safe-area-inset-right))", bottom: "calc(1rem + env(safe-area-inset-bottom))",
|
||||
}}>
|
||||
<DndContext sensors={sensors} modifiers={[modifier]} onDragStart={() => { dragStart.current = currentLayout(); }} onDragEnd={({ delta }) => {
|
||||
const start = dragStart.current;
|
||||
if (start) commit({ ...start, x: start.x + delta.x, y: start.y + delta.y });
|
||||
dragStart.current = null;
|
||||
}} onDragCancel={() => { dragStart.current = null; }}>
|
||||
<PanelContent panelRef={panelRef} layout={layout} minimum={minimum} onMeasureMinimum={setMinimum} currentLayout={currentLayout} constrain={constrain} preview={setPreferred} commit={commit}>{children}</PanelContent>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ResizeEdges = { left: boolean; right: boolean; top: boolean; bottom: boolean };
|
||||
|
||||
function resizeEdges(element: HTMLElement, x: number, y: number): ResizeEdges {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
left: x <= rect.left + 8, right: x >= rect.right - 8,
|
||||
top: y <= rect.top + 8, bottom: y >= rect.bottom - 8,
|
||||
};
|
||||
}
|
||||
|
||||
function resizeCursor(edges: ResizeEdges) {
|
||||
if ((edges.left && edges.top) || (edges.right && edges.bottom)) return "nwse-resize";
|
||||
if ((edges.right && edges.top) || (edges.left && edges.bottom)) return "nesw-resize";
|
||||
if (edges.left || edges.right) return "ew-resize";
|
||||
if (edges.top || edges.bottom) return "ns-resize";
|
||||
return "grab";
|
||||
}
|
||||
|
||||
function PanelContent({ children, panelRef, layout, minimum, onMeasureMinimum, currentLayout, constrain, preview, commit }: {
|
||||
children: ReactNode;
|
||||
panelRef: React.RefObject<HTMLDivElement | null>;
|
||||
layout: SaveLayout | null;
|
||||
minimum: PanelSize;
|
||||
onMeasureMinimum: (size: PanelSize) => void;
|
||||
currentLayout: () => SaveLayout | null;
|
||||
constrain: (layout: SaveLayout) => SaveLayout;
|
||||
preview: (layout: SaveLayout | null) => void;
|
||||
commit: (layout: SaveLayout) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform } = useDraggable({ id: "floating-admin-save" });
|
||||
const resizing = useRef<{ id: number; x: number; y: number; edges: ResizeEdges; start: SaveLayout; previous: SaveLayout | null } | null>(null);
|
||||
const pending = useRef<SaveLayout | null>(null);
|
||||
const measuredMinimumRef = useRef(false);
|
||||
const [cursor, setCursor] = useState("grab");
|
||||
const style: CSSProperties = layout ? { left: layout.x, top: layout.y, width: layout.width, height: layout.height, right: "auto", bottom: "auto" } : {};
|
||||
style.cursor = transform ? "grabbing" : cursor;
|
||||
if (transform) style.transform = `translate3d(${transform.x}px, ${transform.y}px, 0)`;
|
||||
|
||||
const cancelResize = () => {
|
||||
if (resizing.current) preview(resizing.current.previous);
|
||||
resizing.current = null;
|
||||
pending.current = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(node) => {
|
||||
panelRef.current = node;
|
||||
setNodeRef(node);
|
||||
setActivatorNodeRef(node);
|
||||
// Capture the automatic starting size before restoring a custom layout.
|
||||
if (node && !measuredMinimumRef.current) {
|
||||
const { width, height } = node.getBoundingClientRect();
|
||||
if (width > 0 && height > 0) {
|
||||
measuredMinimumRef.current = true;
|
||||
onMeasureMinimum({ width, height });
|
||||
}
|
||||
}
|
||||
}}
|
||||
{...attributes}
|
||||
role="group"
|
||||
aria-label="ย้ายแผงบันทึก"
|
||||
aria-roledescription="แผงบันทึกที่ย้ายและปรับขนาดได้"
|
||||
title="ลากเพื่อย้าย ลากขอบเพื่อปรับขนาด หรือใช้ Alt + ปุ่มลูกศรเพื่อปรับขนาด"
|
||||
data-save-panel
|
||||
className="pointer-events-auto absolute right-0 bottom-0 flex max-h-full w-fit max-w-full touch-none flex-col items-end gap-2 overflow-hidden rounded-xl border bg-background p-3 shadow-lg sm:right-2"
|
||||
style={style}
|
||||
onPointerDown={(event) => {
|
||||
if (event.button !== 0) return;
|
||||
const edges = resizeEdges(event.currentTarget, event.clientX, event.clientY);
|
||||
if (!edges.left && !edges.right && !edges.top && !edges.bottom) {
|
||||
listeners?.onPointerDown?.(event);
|
||||
return;
|
||||
}
|
||||
const start = currentLayout();
|
||||
if (!start) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.focus();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
resizing.current = { id: event.pointerId, x: event.clientX, y: event.clientY, edges, start, previous: layout };
|
||||
pending.current = start;
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const active = resizing.current;
|
||||
if (!active) {
|
||||
setCursor(resizeCursor(resizeEdges(event.currentTarget, event.clientX, event.clientY)));
|
||||
return;
|
||||
}
|
||||
if (active.id !== event.pointerId) return;
|
||||
const dx = event.clientX - active.x;
|
||||
const dy = event.clientY - active.y;
|
||||
const { edges, start } = active;
|
||||
// Keep the opposite edge anchored while respecting the minimum size and viewport.
|
||||
const x = edges.left ? Math.max(0, Math.min(start.x + dx, start.x + start.width - minimum.width)) : start.x;
|
||||
const y = edges.top ? Math.max(0, Math.min(start.y + dy, start.y + start.height - minimum.height)) : start.y;
|
||||
const next = constrain({
|
||||
x, y,
|
||||
width: edges.left ? start.width + start.x - x : edges.right ? start.width + dx : start.width,
|
||||
height: edges.top ? start.height + start.y - y : edges.bottom ? start.height + dy : start.height,
|
||||
});
|
||||
pending.current = next;
|
||||
preview(next);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (resizing.current?.id !== event.pointerId) return;
|
||||
if (pending.current) commit(pending.current);
|
||||
resizing.current = null;
|
||||
pending.current = null;
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}}
|
||||
onPointerCancel={cancelResize}
|
||||
onLostPointerCapture={cancelResize}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (!event.altKey || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) {
|
||||
listeners?.onKeyDown?.(event);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const start = currentLayout();
|
||||
if (!start) return;
|
||||
const step = event.shiftKey ? 32 : 8;
|
||||
commit({ ...start, width: start.width + (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0), height: start.height + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0) });
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,13 +4,34 @@ import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SaveStatus } from "./save-status";
|
||||
import { SAVE_LAYOUT_KEY, clampSaveLayout, parseSaveLayout } from "./floating-save-panel";
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
const onSave = vi.fn();
|
||||
let startingWidth = 250;
|
||||
let startingHeight = 200;
|
||||
function button(name: string) {
|
||||
const result = Array.from(container.querySelectorAll("button")).find((item) => (item.getAttribute("aria-label") ?? item.textContent?.trim()) === name);
|
||||
if (!result) throw new Error(`Missing button: ${name}`);
|
||||
return result;
|
||||
}
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
vi.clearAllMocks();
|
||||
startingWidth = 250;
|
||||
startingHeight = 200;
|
||||
window.localStorage.clear();
|
||||
vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800);
|
||||
vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600);
|
||||
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(function (this: HTMLElement) {
|
||||
const isPanel = this.hasAttribute("data-save-panel");
|
||||
const x = isPanel ? Number.parseFloat(this.style.left || "550") : 0;
|
||||
const y = isPanel ? Number.parseFloat(this.style.top || "400") : 0;
|
||||
const width = isPanel ? Number.parseFloat(this.style.width || String(startingWidth)) : 800;
|
||||
const height = isPanel ? Number.parseFloat(this.style.height || String(startingHeight)) : 600;
|
||||
return { x, y, left: x, top: y, right: x + width, bottom: y + height, width, height, toJSON: () => ({}) };
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
root = createRoot(container);
|
||||
@@ -19,46 +40,182 @@ afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
async function render(props: Partial<Parameters<typeof SaveStatus>[0]> = {}) {
|
||||
await act(async () => root.render(createElement(SaveStatus, { state: "saved", canSave: false, onSave, ...props })));
|
||||
}
|
||||
|
||||
describe("floating SaveStatus", () => {
|
||||
it("can grow and shrink back to the measured starting size", async () => {
|
||||
startingWidth = 160;
|
||||
startingHeight = 96;
|
||||
await render();
|
||||
const panel = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
panel.setPointerCapture = vi.fn();
|
||||
panel.releasePointerCapture = vi.fn();
|
||||
const resize = async (fromX: number, fromY: number, toX: number, toY: number) => {
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: fromX, clientY: fromY })));
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: toX, clientY: toY })));
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })));
|
||||
};
|
||||
await resize(710, 496, 790, 580);
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 240, height: 180 });
|
||||
await resize(790, 580, 550, 400);
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 160, height: 96 });
|
||||
});
|
||||
|
||||
it("uses the original minimum after restoring a larger layout and resizing the top-left corner", async () => {
|
||||
startingWidth = 160;
|
||||
startingHeight = 96;
|
||||
window.localStorage.setItem(SAVE_LAYOUT_KEY, JSON.stringify({ version: 1, x: 40, y: 60, width: 350, height: 280 }));
|
||||
await render();
|
||||
const panel = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
panel.setPointerCapture = vi.fn();
|
||||
panel.releasePointerCapture = vi.fn();
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: 40, clientY: 60 })));
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 500, clientY: 500 })));
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })));
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 230, y: 244, width: 160, height: 96 });
|
||||
});
|
||||
|
||||
it("restores a saved layout", async () => {
|
||||
window.localStorage.setItem(SAVE_LAYOUT_KEY, JSON.stringify({ version: 1, x: 40, y: 60, width: 300, height: 220 }));
|
||||
await render();
|
||||
const panel = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
expect(panel.style.left).toBe("40px");
|
||||
expect(panel.style.height).toBe("220px");
|
||||
});
|
||||
|
||||
it("clamps a restored layout without overwriting the preference", async () => {
|
||||
const stored = JSON.stringify({ version: 1, x: 900, y: 700, width: 300, height: 220 });
|
||||
window.localStorage.setItem(SAVE_LAYOUT_KEY, stored);
|
||||
await render();
|
||||
const panel = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
expect(panel.style.left).toBe("500px");
|
||||
expect(panel.style.top).toBe("380px");
|
||||
expect(window.localStorage.getItem(SAVE_LAYOUT_KEY)).toBe(stored);
|
||||
});
|
||||
|
||||
it("resizes with the keyboard and persists without saving editor data", async () => {
|
||||
await render({ state: "unsaved", canSave: true });
|
||||
await act(async () => container.querySelector<HTMLElement>("[data-save-panel]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, bubbles: true })));
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 250, height: 200 });
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists pointer resizing only after releasing the handle", async () => {
|
||||
await render();
|
||||
const handle = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
handle.setPointerCapture = vi.fn();
|
||||
handle.releasePointerCapture = vi.fn();
|
||||
await act(async () => handle.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: 800, clientY: 600 })));
|
||||
await act(async () => handle.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 750, clientY: 580 })));
|
||||
expect(container.querySelector<HTMLElement>("[data-save-panel]")!.style.width).toBe("250px");
|
||||
expect(window.localStorage.getItem(SAVE_LAYOUT_KEY)).toBeNull();
|
||||
await act(async () => handle.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })));
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 550, y: 400, width: 250, height: 200 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
[550, 500, 570, 500, { x: 550, y: 400, width: 250, height: 200 }],
|
||||
[800, 500, 780, 500, { x: 550, y: 400, width: 250, height: 200 }],
|
||||
[650, 400, 650, 420, { x: 550, y: 400, width: 250, height: 200 }],
|
||||
[650, 600, 650, 580, { x: 550, y: 400, width: 250, height: 200 }],
|
||||
])("resizes from the edge at %i, %i", async (x, y, endX, endY, expected) => {
|
||||
await render();
|
||||
const panel = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
panel.setPointerCapture = vi.fn();
|
||||
panel.releasePointerCapture = vi.fn();
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, button: 0, clientX: x, clientY: y })));
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: endX, clientY: endY })));
|
||||
await act(async () => panel.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })));
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual(expected);
|
||||
});
|
||||
|
||||
it("moves by dragging the status content without a move icon", async () => {
|
||||
await render();
|
||||
const status = container.querySelector('[role="status"]')!;
|
||||
await act(async () => status.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, isPrimary: true, pointerId: 1, button: 0, clientX: 650, clientY: 450 })));
|
||||
await act(async () => document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 630, clientY: 430 })));
|
||||
await act(async () => document.dispatchEvent(new PointerEvent("pointermove", { bubbles: true, pointerId: 1, clientX: 630, clientY: 430 })));
|
||||
await act(async () => document.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 })));
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 530, y: 380, width: 250, height: 200 });
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
// dnd-kit briefly retains its click blocker after a pointer drag ends.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 60)); });
|
||||
});
|
||||
|
||||
it("keeps a normal Save click working without moving the panel", async () => {
|
||||
await render({ state: "unsaved", canSave: true });
|
||||
await act(async () => button("บันทึก").dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, isPrimary: true, pointerId: 1, button: 0, clientX: 650, clientY: 500 })));
|
||||
await act(async () => button("บันทึก").dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1, button: 0, clientX: 650, clientY: 500 })));
|
||||
await act(async () => button("บันทึก").click());
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(window.localStorage.getItem(SAVE_LAYOUT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("moves with the keyboard and persists the position", async () => {
|
||||
await render();
|
||||
const handle = container.querySelector<HTMLElement>("[data-save-panel]")!;
|
||||
await act(async () => {
|
||||
handle.focus();
|
||||
handle.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: " ", code: "Space" }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "ArrowLeft", code: "ArrowLeft" })));
|
||||
await act(async () => document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: " ", code: "Space" })));
|
||||
expect(parseSaveLayout(window.localStorage.getItem(SAVE_LAYOUT_KEY))).toEqual({ x: 525, y: 400, width: 250, height: 200 });
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues to resize when storage is unavailable", async () => {
|
||||
vi.spyOn(window.localStorage, "getItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
vi.spyOn(window.localStorage, "setItem").mockImplementation(() => { throw new Error("blocked"); });
|
||||
await render();
|
||||
await act(async () => container.querySelector<HTMLElement>("[data-save-panel]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, bubbles: true })));
|
||||
expect(container.querySelector<HTMLElement>("[data-save-panel]")!.style.width).toBe("250px");
|
||||
});
|
||||
|
||||
it("rejects malformed storage and keeps minimum sizes within tiny viewports", () => {
|
||||
expect(parseSaveLayout("not json")).toBeNull();
|
||||
expect(parseSaveLayout(JSON.stringify({ version: 1, x: "40", y: 0, width: 200, height: 144 }))).toBeNull();
|
||||
expect(clampSaveLayout({ x: -10, y: 500, width: 50, height: 50 }, 180, 120, { width: 250, height: 200 })).toEqual({ x: 0, y: 0, width: 180, height: 120 });
|
||||
});
|
||||
|
||||
it("keeps Save visible when saved and enables it for unsaved changes", async () => {
|
||||
await render();
|
||||
expect(container.querySelector("button")?.textContent).toContain("บันทึก");
|
||||
expect(container.querySelector("button")?.disabled).toBe(true);
|
||||
expect(button("บันทึก").textContent).toContain("บันทึก");
|
||||
expect(button("บันทึก").disabled).toBe(true);
|
||||
expect(container.querySelector(".fixed")).not.toBeNull();
|
||||
await render({ state: "unsaved", canSave: true });
|
||||
await act(async () => container.querySelector("button")!.click());
|
||||
await act(async () => button("บันทึก").click());
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows saving feedback and blocks more save requests", async () => {
|
||||
await render({ state: "saving", canSave: false });
|
||||
expect(container.querySelector("button")?.textContent).toContain("กำลังบันทึก");
|
||||
await act(async () => container.querySelector("button")!.click());
|
||||
expect(button("กำลังบันทึก…").textContent).toContain("กำลังบันทึก");
|
||||
await act(async () => button("กำลังบันทึก…").click());
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows validation issues while keeping Save disabled", async () => {
|
||||
await render({ state: "incomplete", issues: ["Missing title"] });
|
||||
const buttons = container.querySelectorAll("button");
|
||||
expect(buttons[1].disabled).toBe(true);
|
||||
await act(async () => buttons[0].click());
|
||||
expect(button("บันทึก").disabled).toBe(true);
|
||||
await act(async () => button("ตรวจสอบข้อมูลที่ยังไม่ครบ").click());
|
||||
expect(container.textContent).toContain("Missing title");
|
||||
expect(buttons[0].getAttribute("aria-expanded")).toBe("true");
|
||||
expect(button("ซ่อนรายการ").getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps conflict actions available and blocks ordinary Save", async () => {
|
||||
const onReloadLatest = vi.fn();
|
||||
const onOverwrite = vi.fn();
|
||||
await render({ state: "conflict", conflictVersion: 8, canOverwrite: true, onReloadLatest, onOverwrite });
|
||||
const buttons = container.querySelectorAll("button");
|
||||
await act(async () => { buttons[0].click(); buttons[1].click(); });
|
||||
await act(async () => { button("โหลดข้อมูลล่าสุด").click(); button("เขียนทับด้วย Draft นี้").click(); });
|
||||
expect(onReloadLatest).toHaveBeenCalledTimes(1);
|
||||
expect(onOverwrite).toHaveBeenCalledTimes(1);
|
||||
expect(buttons[2].disabled).toBe(true);
|
||||
expect(button("บันทึก").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type { SaveState } from "@/hooks/use-manual-save";
|
||||
import { FloatingSavePanel } from "./floating-save-panel";
|
||||
|
||||
interface SaveFeedbackProps {
|
||||
state: SaveState;
|
||||
@@ -144,21 +145,22 @@ export function SaveStatus({
|
||||
}: SaveFeedbackProps & { canSave: boolean; onSave: () => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="h-24" aria-hidden="true" />
|
||||
<div className="fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-40 flex max-h-[70dvh] w-fit max-w-[calc(100vw-2rem)] flex-col items-end gap-3 overflow-y-auto rounded-xl border bg-background p-3 shadow-lg sm:right-6">
|
||||
<div role="status" aria-live="polite">
|
||||
<div className="h-28" aria-hidden="true" />
|
||||
<FloatingSavePanel>
|
||||
<div role="status" aria-live="polite" className="min-h-0 max-w-full shrink touch-pan-y overflow-y-auto">
|
||||
<SaveFeedback {...props} />
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="min-h-11 w-full flex-1"
|
||||
disabled={!canSave || props.state === "saving"}
|
||||
onClick={onSave}
|
||||
>
|
||||
{props.state === "saving" ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
|
||||
{props.state === "saving" ? "กำลังบันทึก…" : props.state === "error" ? "ลองบันทึกอีกครั้ง" : "บันทึก"}
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingSavePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user