Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion packages/ui/components/ui/resizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import * as ResizablePrimitive from "react-resizable-panels"

import { cn } from "@multica/ui/lib/utils"
import { resizeHandleVariants } from "@multica/ui/components/ui/resize-handle"

function ResizablePanelGroup({
className,
Expand All @@ -24,18 +25,45 @@ function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}

// The separator owns the divider line between two panels: it renders the
// resting rule itself rather than sitting on top of a border the panels draw.
// Two elements painting the same 1px meant the hover/active states were
// invisible — they were tinting a line that was already there. Panels either
// side must not add their own edge border.
//
// Cursor and the drag-time cursor lock stay with the library, which injects
// `*, *:hover { cursor: … !important }` document-wide — hence `cursor: "none"`.
// Everything visual comes from the shared variants, so a panel divider and the
// sidebar rail cannot drift apart.
function ResizableHandle({
withHandle,
rule = true,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean
// Set false when there is no panel on the far side to divide from — a
// collapsed sidebar, say. The grab hint still appears on hover. Callers get
// this instead of a class override so that which pseudo-element draws the
// rule stays an implementation detail.
rule?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"relative flex w-0 items-center justify-center before:absolute before:inset-y-0 before:left-1/2 before:w-px before:-translate-x-1/2 before:bg-transparent before:transition-colors hover:before:bg-foreground/15 data-[separator=active]:before:bg-foreground/15 after:absolute after:inset-y-0 after:left-1/2 after:w-2 after:-translate-x-1/2 focus-visible:outline-hidden aria-[orientation=horizontal]:h-0 aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:before:inset-x-0 aria-[orientation=horizontal]:before:inset-y-auto aria-[orientation=horizontal]:before:h-px aria-[orientation=horizontal]:before:w-full aria-[orientation=horizontal]:before:translate-x-0 aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-2 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2",
resizeHandleVariants({
axis: "x",
cursor: "none",
indicator: rule ? "rule" : "line",
hitArea: "overlay",
}),
"relative flex w-0 items-center justify-center focus-visible:outline-hidden",
// The library decides orientation at runtime, so the rule and the grab
// zone are flipped here rather than through the `axis` variant.
"aria-[orientation=horizontal]:h-0 aria-[orientation=horizontal]:w-full",
"aria-[orientation=horizontal]:after:inset-x-0 aria-[orientation=horizontal]:after:inset-y-auto aria-[orientation=horizontal]:after:top-1/2 aria-[orientation=horizontal]:after:h-px aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2",
"aria-[orientation=horizontal]:before:inset-x-0 aria-[orientation=horizontal]:before:inset-y-auto aria-[orientation=horizontal]:before:top-1/2 aria-[orientation=horizontal]:before:h-2 aria-[orientation=horizontal]:before:w-full aria-[orientation=horizontal]:before:translate-x-0 aria-[orientation=horizontal]:before:-translate-y-1/2",
className
)}
{...props}
Expand Down
141 changes: 141 additions & 0 deletions packages/ui/components/ui/resize-handle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
"use client"

import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@multica/ui/lib/utils"
import {
useResizeGesture,
type ResizeAxis,
type ResizeDelta,
type ResizeEndMode,
} from "@multica/ui/hooks/use-resize-gesture"

// Every resize affordance in the product is described here — the cursor, the
// grab zone and the three indicator states — so that the hand-written handles
// and the react-resizable-panels wrapper cannot drift apart. If a value below
// is duplicated anywhere else, that is the bug.
//
// `ew-resize`/`ns-resize` (not `col-resize`/`row-resize`) because that is what
// react-resizable-panels renders on Chromium and Firefox — every Multica
// runtime. Hand-written handles have to match those, not the Safari-only
// fallback branch.
const resizeHandleVariants = cva("touch-none select-none", {
variants: {
axis: { x: "", y: "", xy: "" },
// A handle whose library already drives the cursor globally opts out:
// react-resizable-panels injects `*, *:hover { cursor: … !important }` for
// the whole document, and fighting it would only cause flicker.
cursor: { axis: "", none: "" },
// `rule` is a permanent divider between two panels that darkens on hover;
// `line` is invisible at rest and only appears on hover.
indicator: { none: "", line: "", rule: "" },
// `self`: the element is the grab zone. `overlay`: a zero-width host whose
// grab zone is a centred pseudo-element, so it costs no layout space.
hitArea: { none: "", self: "", overlay: "" },
},
compoundVariants: [
{ axis: "x", cursor: "axis", class: "cursor-ew-resize" },
{ axis: "y", cursor: "axis", class: "cursor-ns-resize" },
{ axis: "xy", cursor: "axis", class: "cursor-nwse-resize" },

// Indicator states. `data-resizing` is set by useResizeGesture and
// `data-separator` by react-resizable-panels; only ever one of them
// matches, so a single declaration serves both kinds of handle.
{
indicator: ["line", "rule"],
class:
"after:absolute after:transition-colors hover:after:bg-foreground/15 data-[resizing=true]:after:bg-foreground/25 data-[separator=active]:after:bg-foreground/25",
},
{ indicator: "line", class: "after:bg-transparent" },
{ indicator: "rule", class: "after:bg-border" },

// The rule runs along the boundary the axis moves across.
{
axis: "x",
indicator: ["line", "rule"],
class: "after:inset-y-0 after:start-1/2 after:w-px after:-translate-x-1/2",
},
{
axis: "y",
indicator: ["line", "rule"],
class: "after:inset-x-0 after:top-1/2 after:h-px after:-translate-y-1/2",
},
// A corner has no single boundary to draw a line along; the cursor is the
// whole affordance.
{ axis: "xy", indicator: ["line", "rule"], class: "after:hidden" },

// One grab zone size for the whole product: 8px, centred on the boundary.
{ axis: "x", hitArea: "self", class: "w-2" },
{ axis: "y", hitArea: "self", class: "h-2" },
{
axis: "x",
hitArea: "overlay",
class:
"before:absolute before:inset-y-0 before:left-1/2 before:w-2 before:-translate-x-1/2",
},
{
axis: "y",
hitArea: "overlay",
class:
"before:absolute before:inset-x-0 before:top-1/2 before:h-2 before:-translate-y-1/2",
},
],
defaultVariants: {
axis: "x",
cursor: "axis",
indicator: "line",
hitArea: "none",
},
})

interface ResizeHandleProps
extends Omit<React.ComponentProps<"div">, "onPointerDown">,
VariantProps<typeof resizeHandleVariants> {
axis: ResizeAxis
onResize: (delta: ResizeDelta, event: PointerEvent) => void
onResizeStart?: (event: React.PointerEvent<HTMLElement>) => boolean | void
onResizeEnd?: (mode: ResizeEndMode) => void
threshold?: number
disabled?: boolean
}

// Default shell for a resize handle. Deliberately unopinionated about
// semantics: callers supply their own role/tabIndex/aria, because a table
// column separator wants a focusable `role="separator"` while the chat
// window's edges are `aria-hidden` decorations behind a keyboard-reachable
// expand button.
function ResizeHandle({
axis,
indicator,
hitArea = "self",
onResize,
onResizeStart,
onResizeEnd,
threshold,
disabled,
className,
...props
}: ResizeHandleProps) {
const { onPointerDown } = useResizeGesture({
axis,
threshold,
disabled,
onStart: onResizeStart,
onMove: onResize,
onEnd: onResizeEnd,
})

return (
<div
data-slot="resize-handle"
onPointerDown={onPointerDown}
className={cn(
resizeHandleVariants({ axis, indicator, hitArea }),
className
)}
{...props}
/>
)
}

export { ResizeHandle, resizeHandleVariants }
Loading
Loading