mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a session. Width previews during drag now write straight to the two layout shells (CSS disables their transitions while data-sidebar-resizing is set); React only sees the single committed width on pointer-up, and framer-motion leaves the sidebar entirely. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,6 @@ import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { motion, useReducedMotion } from "motion/react"
|
||||
|
||||
import { useIsMobile } from "@multica/ui/hooks/use-mobile"
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
@@ -35,13 +34,7 @@ const SIDEBAR_WIDTH_MAX = 360
|
||||
const SIDEBAR_WIDTH_STORAGE_KEY = "sidebar_width"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_MOTION_TRANSITION = {
|
||||
type: "spring",
|
||||
stiffness: 420,
|
||||
damping: 38,
|
||||
mass: 0.8,
|
||||
} as const
|
||||
const SIDEBAR_INSTANT_TRANSITION = { duration: 0 } as const
|
||||
const SIDEBAR_DRAG_THRESHOLD = 2
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(SIDEBAR_WIDTH_MAX, width))
|
||||
@@ -58,16 +51,12 @@ type SidebarContextProps = {
|
||||
}
|
||||
|
||||
type SidebarResizeContextProps = {
|
||||
width: number
|
||||
setWidth: (width: number) => void
|
||||
persistWidth: (width: number) => void
|
||||
isResizing: boolean
|
||||
setIsResizing: (v: boolean) => void
|
||||
commitWidth: (width: number) => void
|
||||
}
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
|
||||
// Drag width changes every animation frame. Keeping it in a separate context
|
||||
// prevents controls that only consume open/mobile state from rerendering.
|
||||
// Width previews are written directly to the two layout shells during drag.
|
||||
// This context only exposes the one committed state transition on pointer-up.
|
||||
const SidebarResizeContext = React.createContext<SidebarResizeContextProps | null>(null)
|
||||
|
||||
function useSidebar() {
|
||||
@@ -109,7 +98,6 @@ function SidebarProvider({
|
||||
const [openMobile, setOpenMobile] = React.useState(false)
|
||||
|
||||
const [width, _setWidth] = React.useState(SIDEBAR_WIDTH_DEFAULT)
|
||||
const [isResizing, setIsResizing] = React.useState(false)
|
||||
React.useEffect(() => {
|
||||
const stored = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY)
|
||||
if (stored) {
|
||||
@@ -119,11 +107,10 @@ function SidebarProvider({
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
const setWidth = React.useCallback((w: number) => {
|
||||
_setWidth(clampSidebarWidth(w))
|
||||
}, [])
|
||||
const persistWidth = React.useCallback((w: number) => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clampSidebarWidth(w)))
|
||||
const commitWidth = React.useCallback((w: number) => {
|
||||
const clamped = clampSidebarWidth(w)
|
||||
_setWidth(clamped)
|
||||
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(clamped))
|
||||
}, [])
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
@@ -168,13 +155,9 @@ function SidebarProvider({
|
||||
)
|
||||
const resizeContextValue = React.useMemo<SidebarResizeContextProps>(
|
||||
() => ({
|
||||
width,
|
||||
setWidth,
|
||||
persistWidth,
|
||||
isResizing,
|
||||
setIsResizing,
|
||||
commitWidth,
|
||||
}),
|
||||
[width, setWidth, persistWidth, isResizing]
|
||||
[commitWidth]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -182,7 +165,6 @@ function SidebarProvider({
|
||||
<SidebarResizeContext.Provider value={resizeContextValue}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
data-sidebar-resizing={isResizing ? "true" : undefined}
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": `${width}px`,
|
||||
@@ -217,12 +199,6 @@ function Sidebar({
|
||||
collapsible?: "offcanvas" | "icon" | "none"
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
||||
const { isResizing, width } = useSidebarResize()
|
||||
const reducedMotion = useReducedMotion()
|
||||
const animateOffcanvas = collapsible === "offcanvas"
|
||||
const motionTransition = reducedMotion || isResizing
|
||||
? SIDEBAR_INSTANT_TRANSITION
|
||||
: SIDEBAR_MOTION_TRANSITION
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
@@ -275,49 +251,30 @@ function Sidebar({
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<motion.div
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent",
|
||||
!animateOffcanvas && !isResizing && "transition-[width] duration-200 ease-linear",
|
||||
!animateOffcanvas && "group-data-[collapsible=offcanvas]:w-0",
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-out motion-reduce:transition-none",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? !animateOffcanvas && "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: !animateOffcanvas && "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
|
||||
)}
|
||||
animate={animateOffcanvas ? { width: state === "collapsed" ? 0 : width } : undefined}
|
||||
initial={false}
|
||||
transition={motionTransition}
|
||||
/>
|
||||
<motion.div
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
|
||||
animateOffcanvas
|
||||
? side === "left"
|
||||
? "left-0"
|
||||
: "right-0"
|
||||
: "data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
!animateOffcanvas && !isResizing && "transition-[left,right,width] duration-200 ease-linear",
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-out motion-reduce:transition-none md:flex",
|
||||
"data-[side=left]:left-0 data-[side=left]:group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)] data-[side=right]:right-0 data-[side=right]:group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className
|
||||
)}
|
||||
animate={
|
||||
animateOffcanvas
|
||||
? {
|
||||
x: state === "collapsed" ? (side === "left" ? -width : width) : 0,
|
||||
width,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={motionTransition}
|
||||
{...(props as React.ComponentProps<typeof motion.div>)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
@@ -326,7 +283,7 @@ function Sidebar({
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -360,82 +317,126 @@ function SidebarTrigger({
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { persistWidth, setWidth, setIsResizing } = useSidebarResize()
|
||||
const { commitWidth } = useSidebarResize()
|
||||
const { t } = useTranslation("ui")
|
||||
const toggleLabel = t(($) => $.toggle_sidebar)
|
||||
const didDragRef = React.useRef(false)
|
||||
const dragRef = React.useRef<{
|
||||
pointerId: number
|
||||
startX: number
|
||||
startWidth: number
|
||||
latestWidth: number
|
||||
direction: 1 | -1
|
||||
wrapperEl: HTMLElement
|
||||
gapEl: HTMLElement
|
||||
containerEl: HTMLElement
|
||||
} | null>(null)
|
||||
const pendingWidthRef = React.useRef<number | null>(null)
|
||||
const resizeFrameRef = React.useRef<number | null>(null)
|
||||
const cancelActiveDragRef = React.useRef<(() => void) | null>(null)
|
||||
|
||||
// Native mousemove can fire several times between paints. Only commit the
|
||||
// latest width once per frame so React and layout do no redundant work.
|
||||
const scheduleWidth = React.useCallback((width: number) => {
|
||||
pendingWidthRef.current = width
|
||||
if (resizeFrameRef.current !== null) return
|
||||
React.useEffect(() => () => cancelActiveDragRef.current?.(), [])
|
||||
|
||||
resizeFrameRef.current = requestAnimationFrame(() => {
|
||||
resizeFrameRef.current = null
|
||||
const pendingWidth = pendingWidthRef.current
|
||||
pendingWidthRef.current = null
|
||||
if (pendingWidth !== null) setWidth(pendingWidth)
|
||||
})
|
||||
}, [setWidth])
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (resizeFrameRef.current !== null) {
|
||||
cancelAnimationFrame(resizeFrameRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onMouseDown = React.useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const onPointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
if (e.button !== 0 || e.isPrimary === false) return
|
||||
e.preventDefault()
|
||||
cancelActiveDragRef.current?.()
|
||||
didDragRef.current = false
|
||||
const sidebarEl = (e.target as HTMLElement).closest("[data-slot='sidebar']")
|
||||
const containerEl = sidebarEl?.querySelector("[data-slot='sidebar-container']")
|
||||
if (!containerEl) return
|
||||
const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width)
|
||||
dragRef.current = { startX: e.clientX, startWidth, latestWidth: startWidth }
|
||||
setWidth(startWidth)
|
||||
setIsResizing(true)
|
||||
const railEl = e.currentTarget
|
||||
const sidebarEl = railEl.closest<HTMLElement>("[data-slot='sidebar']")
|
||||
const wrapperEl = railEl.closest<HTMLElement>("[data-slot='sidebar-wrapper']")
|
||||
const gapEl = sidebarEl?.querySelector<HTMLElement>("[data-slot='sidebar-gap']")
|
||||
const containerEl = sidebarEl?.querySelector<HTMLElement>("[data-slot='sidebar-container']")
|
||||
if (!sidebarEl || !wrapperEl || !gapEl || !containerEl) return
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!dragRef.current) return
|
||||
didDragRef.current = true
|
||||
const delta = ev.clientX - dragRef.current.startX
|
||||
const nextWidth = clampSidebarWidth(dragRef.current.startWidth + delta)
|
||||
dragRef.current.latestWidth = nextWidth
|
||||
scheduleWidth(nextWidth)
|
||||
const startWidth = clampSidebarWidth(containerEl.getBoundingClientRect().width)
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
startWidth,
|
||||
latestWidth: startWidth,
|
||||
direction: sidebarEl.dataset.side === "right" ? -1 : 1,
|
||||
wrapperEl,
|
||||
gapEl,
|
||||
containerEl,
|
||||
}
|
||||
const onMouseUp = () => {
|
||||
const finalWidth = dragRef.current?.latestWidth
|
||||
if (resizeFrameRef.current !== null) {
|
||||
cancelAnimationFrame(resizeFrameRef.current)
|
||||
resizeFrameRef.current = null
|
||||
}
|
||||
pendingWidthRef.current = null
|
||||
if (didDragRef.current && finalWidth !== undefined) {
|
||||
setWidth(finalWidth)
|
||||
persistWidth(finalWidth)
|
||||
|
||||
wrapperEl.setAttribute("data-sidebar-resizing", "true")
|
||||
document.documentElement.setAttribute("data-sidebar-resizing", "true")
|
||||
|
||||
let finished = false
|
||||
const finishDrag = (mode: "commit" | "cancel") => {
|
||||
if (finished) return
|
||||
finished = true
|
||||
|
||||
document.removeEventListener("pointermove", onPointerMove)
|
||||
document.removeEventListener("pointerup", onPointerUp)
|
||||
document.removeEventListener("pointercancel", onPointerCancel)
|
||||
window.removeEventListener("blur", onWindowBlur)
|
||||
railEl.removeEventListener("lostpointercapture", onLostPointerCapture)
|
||||
|
||||
const drag = dragRef.current
|
||||
if (drag) {
|
||||
if (mode === "commit" && didDragRef.current) {
|
||||
drag.wrapperEl.style.setProperty(
|
||||
"--sidebar-width",
|
||||
`${drag.latestWidth}px`
|
||||
)
|
||||
commitWidth(drag.latestWidth)
|
||||
}
|
||||
drag.gapEl.style.removeProperty("width")
|
||||
drag.containerEl.style.removeProperty("width")
|
||||
drag.wrapperEl.removeAttribute("data-sidebar-resizing")
|
||||
}
|
||||
|
||||
dragRef.current = null
|
||||
setIsResizing(false)
|
||||
document.removeEventListener("mousemove", onMouseMove)
|
||||
document.removeEventListener("mouseup", onMouseUp)
|
||||
document.body.style.cursor = ""
|
||||
document.body.style.userSelect = ""
|
||||
cancelActiveDragRef.current = null
|
||||
document.documentElement.removeAttribute("data-sidebar-resizing")
|
||||
|
||||
if (railEl.hasPointerCapture?.(e.pointerId)) {
|
||||
railEl.releasePointerCapture?.(e.pointerId)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousemove", onMouseMove)
|
||||
document.addEventListener("mouseup", onMouseUp)
|
||||
document.body.style.cursor = "col-resize"
|
||||
document.body.style.userSelect = "none"
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || event.pointerId !== drag.pointerId) return
|
||||
|
||||
const delta = (event.clientX - drag.startX) * drag.direction
|
||||
if (!didDragRef.current && Math.abs(delta) < SIDEBAR_DRAG_THRESHOLD) {
|
||||
return
|
||||
}
|
||||
|
||||
didDragRef.current = true
|
||||
const nextWidth = clampSidebarWidth(drag.startWidth + delta)
|
||||
if (nextWidth === drag.latestWidth) return
|
||||
|
||||
drag.latestWidth = nextWidth
|
||||
// Only the two layout shells depend on the live width. Direct writes
|
||||
// let the browser coalesce layout at paint time without a React commit
|
||||
// or an inherited custom-property invalidation on the whole app tree.
|
||||
drag.gapEl.style.width = `${nextWidth}px`
|
||||
drag.containerEl.style.width = `${nextWidth}px`
|
||||
}
|
||||
const onPointerUp = (event: PointerEvent) => {
|
||||
if (event.pointerId === e.pointerId) finishDrag("commit")
|
||||
}
|
||||
const onPointerCancel = (event: PointerEvent) => {
|
||||
if (event.pointerId === e.pointerId) finishDrag("cancel")
|
||||
}
|
||||
const onLostPointerCapture = (event: PointerEvent) => {
|
||||
if (event.pointerId === e.pointerId) finishDrag("cancel")
|
||||
}
|
||||
const onWindowBlur = () => finishDrag("cancel")
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove)
|
||||
document.addEventListener("pointerup", onPointerUp)
|
||||
document.addEventListener("pointercancel", onPointerCancel)
|
||||
window.addEventListener("blur", onWindowBlur)
|
||||
railEl.addEventListener("lostpointercapture", onLostPointerCapture)
|
||||
cancelActiveDragRef.current = () => finishDrag("cancel")
|
||||
railEl.setPointerCapture?.(e.pointerId)
|
||||
},
|
||||
[persistWidth, scheduleWidth, setWidth, setIsResizing]
|
||||
[commitWidth]
|
||||
)
|
||||
|
||||
const handleClick = React.useCallback(() => {
|
||||
@@ -450,11 +451,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
aria-label={toggleLabel}
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
onMouseDown={onMouseDown}
|
||||
onPointerDown={onPointerDown}
|
||||
title={toggleLabel}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-[transform,background-color] ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-col-resize in-data-[side=right]:cursor-col-resize",
|
||||
"absolute inset-y-0 z-20 hidden w-4 touch-none cursor-ew-resize transition-[transform,background-color] ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full hover:group-data-[collapsible=offcanvas]:bg-sidebar",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
|
||||
@@ -218,6 +218,26 @@
|
||||
color: var(--sidebar-accent-foreground);
|
||||
}
|
||||
|
||||
/* Sidebar resizing follows the same cursor contract as
|
||||
* react-resizable-panels: the resize cursor survives leaving the narrow rail
|
||||
* and wins over descendants with their own cursor declarations. Live width is
|
||||
* written directly to the two layout shells, so their toggle transitions must
|
||||
* stay disabled until the gesture finishes. */
|
||||
html[data-sidebar-resizing="true"],
|
||||
html[data-sidebar-resizing="true"] * {
|
||||
cursor: ew-resize !important;
|
||||
user-select: none !important;
|
||||
}
|
||||
|
||||
[data-sidebar-resizing="true"] [data-slot="sidebar-gap"],
|
||||
[data-sidebar-resizing="true"] [data-slot="sidebar-container"] {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
[data-sidebar-resizing="true"] [data-sidebar="rail"]::after {
|
||||
background-color: var(--sidebar-border);
|
||||
}
|
||||
|
||||
/* Right detail sidebars use react-resizable-panels, which sizes panels by
|
||||
* updating the outer panel's flex-grow. Animate that property for button
|
||||
* toggles only; mount-time layout restoration and resize sync should snap
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { act, fireEvent } from "@testing-library/react";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { fireEvent } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -10,45 +9,10 @@ import {
|
||||
} from "@multica/ui/components/ui/sidebar";
|
||||
import { renderWithI18n } from "../test/i18n";
|
||||
|
||||
vi.mock("motion/react", () => ({
|
||||
motion: {
|
||||
div: ({
|
||||
animate: _animate,
|
||||
children,
|
||||
initial: _initial,
|
||||
transition,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & {
|
||||
animate?: unknown;
|
||||
children?: ReactNode;
|
||||
initial?: unknown;
|
||||
transition?: unknown;
|
||||
}) => (
|
||||
<div data-motion-transition={JSON.stringify(transition)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
}));
|
||||
|
||||
describe("left sidebar resizing", () => {
|
||||
let animationFrames: Map<number, FrameRequestCallback>;
|
||||
let nextAnimationFrameId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
animationFrames = new Map();
|
||||
nextAnimationFrameId = 1;
|
||||
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId++;
|
||||
animationFrames.set(id, callback);
|
||||
return id;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => {
|
||||
animationFrames.delete(id);
|
||||
});
|
||||
document.documentElement.removeAttribute("data-sidebar-resizing");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -56,7 +20,7 @@ describe("left sidebar resizing", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("updates width once per frame without rerendering stable sidebar consumers", () => {
|
||||
it("previews width directly and commits only when the pointer is released", () => {
|
||||
const stableConsumerRender = vi.fn();
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
|
||||
@@ -78,7 +42,13 @@ describe("left sidebar resizing", () => {
|
||||
const wrapper = container.querySelector<HTMLElement>("[data-slot='sidebar-wrapper']")!;
|
||||
const sidebarContainer = container.querySelector<HTMLElement>("[data-slot='sidebar-container']")!;
|
||||
const sidebarGap = container.querySelector<HTMLElement>("[data-slot='sidebar-gap']")!;
|
||||
const sidebar = container.querySelector<HTMLElement>("[data-slot='sidebar']")!;
|
||||
const rail = container.querySelector<HTMLButtonElement>("[data-slot='sidebar-rail']")!;
|
||||
const setPointerCapture = vi.fn();
|
||||
const releasePointerCapture = vi.fn();
|
||||
rail.setPointerCapture = setPointerCapture;
|
||||
rail.hasPointerCapture = vi.fn(() => true);
|
||||
rail.releasePointerCapture = releasePointerCapture;
|
||||
|
||||
vi.spyOn(sidebarContainer, "getBoundingClientRect").mockReturnValue({
|
||||
bottom: 0,
|
||||
@@ -92,36 +62,87 @@ describe("left sidebar resizing", () => {
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(rail, { clientX: 256 });
|
||||
|
||||
expect(sidebarGap).toHaveAttribute("data-motion-transition", JSON.stringify({ duration: 0 }));
|
||||
|
||||
fireEvent.mouseMove(document, { clientX: 280 });
|
||||
fireEvent.mouseMove(document, { clientX: 300 });
|
||||
|
||||
expect(animationFrames).toHaveLength(1);
|
||||
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
const frame = animationFrames.entries().next().value;
|
||||
if (!frame) throw new Error("Expected a scheduled sidebar resize frame");
|
||||
const [id, callback] = frame;
|
||||
animationFrames.delete(id);
|
||||
callback(16);
|
||||
fireEvent.pointerDown(rail, {
|
||||
button: 0,
|
||||
clientX: 256,
|
||||
isPrimary: true,
|
||||
pointerId: 7,
|
||||
});
|
||||
|
||||
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("300px");
|
||||
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(7);
|
||||
expect(rail).toHaveClass("cursor-ew-resize");
|
||||
expect(wrapper).toHaveAttribute("data-sidebar-resizing", "true");
|
||||
expect(document.documentElement).toHaveAttribute("data-sidebar-resizing", "true");
|
||||
|
||||
fireEvent.pointerMove(document, { buttons: 1, clientX: 280, pointerId: 7 });
|
||||
fireEvent.pointerMove(document, { buttons: 1, clientX: 300, pointerId: 7 });
|
||||
|
||||
expect(sidebarGap.style.width).toBe("300px");
|
||||
expect(sidebarContainer.style.width).toBe("300px");
|
||||
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.mouseUp(document);
|
||||
fireEvent.pointerUp(document, { pointerId: 7 });
|
||||
|
||||
expect(sidebarGap.style.width).toBe("");
|
||||
expect(sidebarContainer.style.width).toBe("");
|
||||
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("300px");
|
||||
expect(setItem).toHaveBeenCalledTimes(1);
|
||||
expect(setItem).toHaveBeenCalledWith("sidebar_width", "300");
|
||||
expect(sidebarGap).toHaveAttribute(
|
||||
"data-motion-transition",
|
||||
expect.stringContaining('"type":"spring"'),
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(7);
|
||||
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
|
||||
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
|
||||
expect(stableConsumerRender).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(rail);
|
||||
expect(sidebar).toHaveAttribute("data-state", "expanded");
|
||||
});
|
||||
|
||||
it("restores the committed width and cursor state when pointer capture is cancelled", () => {
|
||||
const setItem = vi.spyOn(Storage.prototype, "setItem");
|
||||
const { container } = renderWithI18n(
|
||||
<SidebarProvider>
|
||||
<Sidebar>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
</SidebarProvider>,
|
||||
);
|
||||
|
||||
const wrapper = container.querySelector<HTMLElement>("[data-slot='sidebar-wrapper']")!;
|
||||
const sidebarContainer = container.querySelector<HTMLElement>("[data-slot='sidebar-container']")!;
|
||||
const sidebarGap = container.querySelector<HTMLElement>("[data-slot='sidebar-gap']")!;
|
||||
const rail = container.querySelector<HTMLButtonElement>("[data-slot='sidebar-rail']")!;
|
||||
rail.setPointerCapture = vi.fn();
|
||||
rail.hasPointerCapture = vi.fn(() => true);
|
||||
rail.releasePointerCapture = vi.fn();
|
||||
|
||||
vi.spyOn(sidebarContainer, "getBoundingClientRect").mockReturnValue({
|
||||
bottom: 0,
|
||||
height: 0,
|
||||
left: 0,
|
||||
right: 256,
|
||||
top: 0,
|
||||
width: 256,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
});
|
||||
|
||||
fireEvent.pointerDown(rail, {
|
||||
button: 0,
|
||||
clientX: 256,
|
||||
isPrimary: true,
|
||||
pointerId: 8,
|
||||
});
|
||||
fireEvent.pointerMove(document, { buttons: 1, clientX: 320, pointerId: 8 });
|
||||
fireEvent.pointerCancel(document, { pointerId: 8 });
|
||||
|
||||
expect(sidebarGap.style.width).toBe("");
|
||||
expect(sidebarContainer.style.width).toBe("");
|
||||
expect(wrapper.style.getPropertyValue("--sidebar-width")).toBe("256px");
|
||||
expect(setItem).not.toHaveBeenCalled();
|
||||
expect(wrapper).not.toHaveAttribute("data-sidebar-resizing");
|
||||
expect(document.documentElement).not.toHaveAttribute("data-sidebar-resizing");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user