From 0e813a0a3e2fbd43a07982e0306a17cf8d2bd51c Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 10 Oct 2026 05:00:47 +0700 Subject: [PATCH] feat : moveable and scaleable floting save --- components/admin/floating-save-panel.tsx | 228 +++++++++++++++++++++++ components/admin/save-status.test.ts | 181 ++++++++++++++++-- components/admin/save-status.tsx | 10 +- 3 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 components/admin/floating-save-panel.tsx diff --git a/components/admin/floating-save-panel.tsx b/components/admin/floating-save-panel.tsx new file mode 100644 index 0000000..249b335 --- /dev/null +++ b/components/admin/floating-save-panel.tsx @@ -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; + +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(null); + const panelRef = useRef(null); + const dragStart = useRef(null); + const [preferred, setPreferred] = useState(null); + const [viewport, setViewport] = useState({ width: 0, height: 0 }); + const [minimum, setMinimum] = useState({ 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 ( +
+ { 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; }}> + {children} + +
+ ); +} + +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; + 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(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 ( +
{ + 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} +
+ ); +} diff --git a/components/admin/save-status.test.ts b/components/admin/save-status.test.ts index 1fd7571..d2c433c 100644 --- a/components/admin/save-status.test.ts +++ b/components/admin/save-status.test.ts @@ -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[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("[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("[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("[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("[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("[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("[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("[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("[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("[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("[data-save-panel]")!.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, bubbles: true }))); + expect(container.querySelector("[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); }); }); diff --git a/components/admin/save-status.tsx b/components/admin/save-status.tsx index 0634e48..a890c61 100644 --- a/components/admin/save-status.tsx +++ b/components/admin/save-status.tsx @@ -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 ( <> -