// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file ResizeHandle.tsx * @input direction, isReversed, hasDivider, isAlwaysVisible, pillPlacement, ResizableProps * @output Styled drag handle with WAI-ARIA separator role and keyboard support * @position Between resizable panels; consumed directly by builders * * The handle element is 1px wide (the divider line itself), with an * absolutely-positioned wider hit area for pointer interaction. * Pill grip indicator can sit on either side of the divider (or centered) * via pillPlacement. Default 'auto' places the pill on the panel side and * flips when the panel collapses to 0px so it stays accessible. * * Pill placement uses a single stylex dynamic style that accepts a direction * multiplier (-1 or 1). The pill element has its own themeProps * ('resize-handle-pill') so themes can target size/shape directly. * * While the panel is collapsed, aria-valuenow is clamped to aria-valuemin * (a value below the minimum is invalid per WCAG 4.1.2) and a localized * "Collapsed" aria-valuetext announces the real state. * * Dragging takes pointer capture on the grab zone, so the rest of the gesture * is delivered to that element instead of being hit-tested against whatever * the cursor happens to be over. A resizable region that contains an embedded * frame otherwise loses the drag the moment the cursor crosses the frame: the * events go to the guest document, and a listener on the host window never * hears them again. Captured events are delivered to the capturing element, * so the move/up/cancel handlers live on the grab zone (as in Slider), not on * window. */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import type { BaseProps } from "../BaseProps.js"; import { devWarn } from "../utils/devWarning.js"; import "../theme/tokens.stylex.js"; import { colorVars, durationVars, easeVars, radiusVars, spacingVars } from "../theme/tokens.stylex.js"; import { focusOutlineProps } from "../utils/focusOutline.stylex.js"; import { mergeProps, rtlStyles } from "../utils/index.js"; import type { ResizableProps } from "./useResizable.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; const KEYBOARD_STEP = 10; const KEYBOARD_LARGE_STEP = 50; type PillPlacement = 'start' | 'end' | 'center' | 'auto'; function resolveEffectiveSide(pillPlacement: PillPlacement, isReversed: boolean, isCollapsed: boolean): 'start' | 'end' | 'center' { if (pillPlacement !== 'auto') { return pillPlacement; } const panelSide: 'start' | 'end' = isReversed ? 'end' : 'start'; if (isCollapsed) { return panelSide === 'start' ? 'end' : 'start'; } return panelSide; } /** * Hit-area inline bias, expressed as a fraction of the pill's per-side offset. * * The visible pill is placed by `pillOffsetX`/`pillOffsetY`: anchored at the * divider's inline-start edge and pushed one grip-width-plus-gap toward the * panel side via a PHYSICAL translate (dir −1 = start, +1 = end). Because that * offset is physical, the pill sits on the same physical side of the divider in * both LTR and RTL. The grab zone must sit OVER that pill, so it has to use the * exact same anchor + physical-offset construction as the pill (just wider) — * anchoring the hit area at the divider centre (`insetInlineStart: 50%`) and * biasing with a percentage translate mixes a logical anchor (which flips under * RTL) with a physical translate (which doesn't), leaving the grab zone stranded * to one side under RTL. Returning the pill's `dir` here lets the hit area reuse * `pillOffsetX`/`pillOffsetY` so the two elements are positioned identically. */ function hitAreaBiasDir(effectiveSide: 'start' | 'end' | 'center'): number | null { if (effectiveSide === 'center') { return null; } return effectiveSide === 'start' ? -1 : 1; } /** * Drag feedback lives on ``, not on the handle: the pointer spends the * drag off the handle, over whatever the panels contain, and the resize cursor * has to hold there. Module scope so the writes stay outside the component. */ function applyBodyDragStyles(isHorizontal: boolean): void { document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize'; document.body.style.userSelect = 'none'; } function clearBodyDragStyles(): void { document.body.style.cursor = ''; document.body.style.userSelect = ''; } const styles = { handle: { kVAEAm: "x1n2onr6", kmuXW: "x2lah0s", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kWkggS: "x1m4xfpy", k1ekBW: "x15406qy", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", $$css: true as const }, overlay: { kVAEAm: "x10l6tqk", kY2c9j: "xhtitgo", kWkggS: "xjbqb8w", $$css: true as const }, overlayHorizontal: { kt4wiu: "xtijo5x", k87sOh: "x13vifvy", krVfgx: "x1ey2m1c", kzqmXN: "xkhob8h", $$css: true as const }, overlayVertical: { krVfgx: "x1ey2m1c", kLqNvP: "x1o0tod", kt4wiu: "xtijo5x", kZKoxP: "x5kfih4", $$css: true as const }, horizontal: { kzqmXN: "x1i1rx1s", kZKoxP: "x5yr21d", kkrTdU: "xicojor x16khyan", $$css: true as const }, vertical: { kZKoxP: "xjm9jq1", kzqmXN: "xh8yej3", kkrTdU: "xyozgr7 x16khyan", $$css: true as const }, noDividerHorizontal: { kWkggS: "xjbqb8w", kzqmXN: "xnalus7", $$css: true as const }, noDividerVertical: { kWkggS: "xjbqb8w", kZKoxP: "xqtp20y", $$css: true as const }, handleHover: { kWkggS: "x1m4xfpy", $$css: true as const }, handleActive: { kWkggS: "x7njt3n", $$css: true as const }, disabled: { kkrTdU: "xt0e3qv", kfzvcC: "x47corl", $$css: true as const }, hitArea: { kVAEAm: "x10l6tqk", kY2c9j: "x1vjfegm", kFalU9: "x5ve5x3", kfSwDN: "x87ps6o", $$css: true as const }, hitAreaHorizontal: { kzqmXN: "x12xnipv", k87sOh: "x13vifvy", krVfgx: "x1ey2m1c", kkrTdU: "xicojor x16khyan", $$css: true as const }, hitAreaVertical: { kZKoxP: "x6b6gus", kLqNvP: "x1o0tod", kt4wiu: "xtijo5x", kkrTdU: "xyozgr7 x16khyan", $$css: true as const }, hitAreaCenteredY: { k87sOh: "xwa60dl", k3aq6I: "x1cb1t30", $$css: true as const }, pill: { kVAEAm: "x10l6tqk", kY2c9j: "xhtitgo", kfzvcC: "x47corl", kaIpWk: "xjspbzw", kWkggS: "x1m4xfpy", k1ekBW: "x1wbls7o", kIyJzY: "xuedmi6", kAMwcw: "xlr8y92", k87sOh: "xwa60dl", $$css: true as const }, pillHorizontal: { kzqmXN: "x1g8rjiy", kZKoxP: "x18to7ep", $$css: true as const }, pillVertical: { kzqmXN: "xgc8j7m", kZKoxP: "xuoj239", $$css: true as const }, pillHidden: { kSiTet: "xg01cxk", $$css: true as const }, pillVisible: { kSiTet: "x1hc1fzr", $$css: true as const }, pillHover: { kSiTet: "x1hc1fzr", kWkggS: "x1m4xfpy", $$css: true as const }, pillActive: { kSiTet: "x1hc1fzr", kWkggS: "x7njt3n", $$css: true as const } }; // Dynamic styles — avoids inline style overrides. // Each axis gets its own function since StyleX requires static structure. const _temp = { kLqNvP: "x1o0tod", k3aq6I: "x11wx31z " + "x75qhwu", "$$css": true }; const _temp2 = { k87sOh: "x13vifvy", k3aq6I: "xsqj5wx", "$$css": true }; const _temp3 = { kLqNvP: "x1o0tod", k3aq6I: "xsqj5wx", "$$css": true }; const _temp4 = { k87sOh: "x13vifvy", k3aq6I: "xsqj5wx", "$$css": true }; const dynamicStyles = { hitAreaOffsetX: (dir: number) => [_temp, { "--x-qthpiu": `translateX(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) - 6.5px))` != null ? `translateX(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) - 6.5px))` : undefined, "--x-sj6hyi": `translateX(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) + 6.5px))` != null ? `translateX(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) + 6.5px))` : undefined }], hitAreaOffsetY: (dir: number) => [_temp2, { "--x-transform": `translateY(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) - 6.5px))` != null ? `translateY(calc(${dir} * (3px + ${spacingVars['--spacing-1']}) - 6.5px))` : undefined }], pillOffsetX: (dir: number) => [_temp3, { "--x-transform": `translate(calc(${dir} * (100% + ${spacingVars['--spacing-1']})), -50%)` != null ? `translate(calc(${dir} * (100% + ${spacingVars['--spacing-1']})), -50%)` : undefined }], pillOffsetY: (dir: number) => [_temp4, { "--x-transform": `translate(-50%, calc(${dir} * (100% + ${spacingVars['--spacing-1']})))` != null ? `translate(-50%, calc(${dir} * (100% + ${spacingVars['--spacing-1']})))` : undefined }] }; export interface ResizeHandleProps extends Omit, 'style'> { ref?: React.Ref; /** * Layout direction — determines cursor and indicator orientation. * @default 'horizontal' */ direction?: 'horizontal' | 'vertical'; /** * Positioning mode. `'inline'` (default) puts the handle in normal * flex flow between siblings. `'overlay'` uses absolute positioning * so the handle sits inside a parent panel's bounds — useful when * the parent has `overflow: clip`. * @default 'inline' */ position?: 'inline' | 'overlay'; /** /** * Reverse the drag direction. Use when the handle controls a panel * on the end/right/bottom side. * @default false */ isReversed?: boolean; /** * Whether the handle is disabled (not interactive). * @default false */ isDisabled?: boolean; /** * Show a 1px divider line. The line IS the handle — it takes only * 1px in the layout with a wider invisible hit area for interaction. * Ignored in overlay mode. * @default false */ hasDivider?: boolean; /** * Show the pill grip indicator at rest. Set to `false` to only * reveal the pill on hover/focus. * @default true */ isAlwaysVisible?: boolean; /** * Which side of the divider line the pill sits on. * - `'auto'` — panel side by default, flips when panel is collapsed to 0px * - `'start'` — left (horizontal) or top (vertical) * - `'end'` — right (horizontal) or bottom (vertical) * - `'center'` — centered on the divider line (original behavior) * @default 'auto' */ pillPlacement?: PillPlacement; /** * Accessible label for the separator. * @default 'Resize handle' */ label?: string; /** Resize props from useResizable region. */ resizable?: ResizableProps; /** Custom handle content. Overrides the default pill. */ children?: ReactNode; } /** * Draggable resize handle placed between resizable panels. Renders as a thin * divider line with a wider invisible hit area and optional pill grip indicator. * Supports keyboard resizing via arrow keys and WAI-ARIA separator role. * * The pill element uses class `astryx-resize-handle-pill` for theme targeting. * * @example * ``` * * ``` */ export function ResizeHandle({ direction = 'horizontal', position: positionMode = 'inline', isReversed = false, isDisabled = false, hasDivider = false, isAlwaysVisible = true, pillPlacement = 'auto', label: labelFromProps, resizable, children, xstyle, className, ref, ...props }: ResizeHandleProps) { const t = useTranslator(); const label = labelFromProps ?? t('@astryx.resizable.handle.label'); const handleRef = useRef(null); // The pointer that owns the in-flight drag, plus the values its deltas are // measured against. A ref so moves don't re-render, and so unmount can tell // a live drag from a finished one. const dragRef = useRef<{ pointerId: number; startPos: number; rtl: number; } | null>(null); const [isDragging, setIsDragging] = useState(false); const [isHovered, setIsHovered] = useState(false); const [isFocused, setIsFocused] = useState(false); const isHorizontal = direction === 'horizontal'; const isOverlay = positionMode === 'overlay'; const sign = isReversed ? -1 : 1; const effectiveSide = resolveEffectiveSide(pillPlacement, isReversed, resizable?._isCollapsed ?? false); // Physical offset direction shared by the pill and the grab zone (null = // centered / no bias). Keeps the two positioned identically in LTR and RTL. const hitBiasDir = hitAreaBiasDir(effectiveSide); const getRTLMultiplier = useCallback((): number => { const el = handleRef.current; if (!el) { return 1; } return getComputedStyle(el).direction === 'rtl' ? -1 : 1; }, []); const isInteracting = isHovered || isFocused; // A handle whose axis disagrees with its hook drags along one axis while the // hook measures percentage bounds on the other. `ResizableProps` is exported, // so `_direction` is optional for old hand-built objects; absent means there // is no hook axis to compare, not that the hook is horizontal. const didWarnAxisRef = useRef(false); useEffect(() => { const hookDirection = resizable?._direction; if (hookDirection != null && hookDirection !== direction && !didWarnAxisRef.current) { didWarnAxisRef.current = true; devWarn('ResizeHandle', `direction="${direction}" but its useResizable region is ` + `"${hookDirection}". They must match: the hook measures one axis ` + 'and the handle drags the other.'); } }, [direction, resizable]); // --- Pointer drag --- const clearDrag = useCallback(() => { dragRef.current = null; setIsDragging(false); clearBodyDragStyles(); }, []); const handlePointerDown = useCallback((e: React.PointerEvent) => { if (isDisabled || !resizable || dragRef.current) { return; } e.preventDefault(); e.stopPropagation(); // Take the pointer for the whole gesture. Without capture the browser // re-hit-tests every move, so a frame anywhere under the drag path // swallows the rest of it into the guest document. e.currentTarget.setPointerCapture?.(e.pointerId); dragRef.current = { pointerId: e.pointerId, startPos: isHorizontal ? e.clientX : e.clientY, rtl: isHorizontal ? getRTLMultiplier() : 1 }; setIsDragging(true); resizable._onResizeStart(); applyBodyDragStyles(isHorizontal); }, [isDisabled, resizable, isHorizontal, getRTLMultiplier]); const handlePointerMove = useCallback((e: React.PointerEvent) => { const drag = dragRef.current; if (!drag || drag.pointerId !== e.pointerId || !resizable) { return; } const currentPos = isHorizontal ? e.clientX : e.clientY; resizable._onResizeMove((currentPos - drag.startPos) * drag.rtl * sign); }, [resizable, isHorizontal, sign]); const handlePointerUp = useCallback((e: React.PointerEvent) => { if (dragRef.current?.pointerId !== e.pointerId) { return; } clearDrag(); resizable?._onResizeEnd(); }, [clearDrag, resizable]); // Serves pointercancel and lostpointercapture alike: an interrupted drag // ends without signalling a resize end. lostpointercapture also fires on the // implicit release after every pointerup/pointercancel, by which point the // drag is already cleared and this is a no-op. const cancelDrag = useCallback((e: React.PointerEvent) => { if (dragRef.current?.pointerId !== e.pointerId) { return; } clearDrag(); // Not a resize end, but still the end of the gesture: the region froze // state for the duration of the drag and has to be told it is over. resizable?._onResizeCancel?.(); }, [clearDrag, resizable]); // --- Keyboard --- const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (isDisabled || !resizable) { return; } const step = e.shiftKey ? KEYBOARD_LARGE_STEP : KEYBOARD_STEP; const rtl = isHorizontal ? getRTLMultiplier() : 1; switch (e.key) { case 'ArrowRight': case 'ArrowDown': { e.preventDefault(); resizable._onResizeStart(); resizable._onResizeMove(step * (isHorizontal ? rtl : 1) * sign); resizable._onResizeEnd(); break; } case 'ArrowLeft': case 'ArrowUp': { e.preventDefault(); resizable._onResizeStart(); resizable._onResizeMove(-step * (isHorizontal ? rtl : 1) * sign); resizable._onResizeEnd(); break; } case 'Home': { e.preventDefault(); resizable._onResizeStart(); resizable._onResizeMove(resizable._minSizePx - resizable._size); resizable._onResizeEnd(); break; } case 'End': { e.preventDefault(); if (resizable._maxSizePx !== Infinity) { resizable._onResizeStart(); resizable._onResizeMove(resizable._maxSizePx - resizable._size); resizable._onResizeEnd(); } break; } case 'Enter': { e.preventDefault(); if (resizable._collapsible) { resizable._onResizeStart(); resizable._onResizeMove(resizable._isCollapsed ? resizable._minSizePx : -resizable._size); resizable._onResizeEnd(); } break; } } }, [isDisabled, resizable, isHorizontal, getRTLMultiplier, sign]); // --- Double-click collapse --- const handleDoubleClick = useCallback(() => { if (isDisabled || !resizable || !resizable._collapsible) { return; } resizable._onResizeStart(); resizable._onResizeMove(resizable._isCollapsed ? resizable._minSizePx : -resizable._size); resizable._onResizeEnd(); }, [isDisabled, resizable]); // --- Cleanup on unmount --- // Removing the grab zone implicitly releases its captured pointer and takes // its listeners with it, so an in-flight drag stops driving the region on // its own. The body cursor/user-select overrides are not on the element, // though, so they have to be released here or they stick after the handle // is gone — and the region, which outlives this handle, still has to hear // that its gesture is over. const cancelOnUnmountRef = useRef<(() => void) | undefined>(undefined); cancelOnUnmountRef.current = resizable?._onResizeCancel; useEffect(() => { return () => { if (dragRef.current) { dragRef.current = null; clearBodyDragStyles(); cancelOnUnmountRef.current?.(); } }; }, []); // --- ARIA --- // When collapsed the panel's real size (0) sits below aria-valuemin, which // is invalid per WCAG 4.1.2. Clamp aria-valuenow to the minimum and announce // the true state via aria-valuetext instead; the valuetext is removed as // soon as the panel expands so the numeric value reads again. const isCollapsed = resizable?._isCollapsed ?? false; const ariaValueNow = resizable ? isCollapsed ? Math.max(resizable._size, resizable._minSizePx) : resizable._size : undefined; const ariaValueMin = resizable ? resizable._minSizePx : undefined; const ariaValueMax = resizable && resizable._maxSizePx !== Infinity ? resizable._maxSizePx : undefined; const ariaValueText = resizable && isCollapsed ? t('@astryx.resizable.collapsed') : undefined; return
setIsFocused(true)} onBlur={() => setIsFocused(false)} data-resizing={isDragging || undefined} {...mergeProps(themeProps('resize-handle'), focusOutlineProps.focusVisible(styles.handle, isOverlay && styles.overlay, isOverlay && (isHorizontal ? styles.overlayHorizontal : styles.overlayVertical), !isOverlay && (isHorizontal ? styles.horizontal : styles.vertical), !isOverlay && !hasDivider && (isHorizontal ? styles.noDividerHorizontal : styles.noDividerVertical), !isOverlay && hasDivider && isInteracting && !isDragging && styles.handleHover, !isOverlay && hasDivider && isDragging && styles.handleActive, isDisabled && styles.disabled, xstyle), className)} {...props} // Keyboard resizing must fire from the focusable separator, not a child: // keydown fires on the focused element and bubbles up, so a handler on a // descendant never runs (per the WAI-ARIA window-splitter pattern). // Placed after {...props} and composed with any consumer onKeyDown so // this accessibility-critical handler can't be clobbered by a passed prop. onKeyDown={e => { props.onKeyDown?.(e); handleKeyDown(e); }}> {/* Wider invisible hit area for pointer interaction */}
setIsHovered(true)} onPointerLeave={() => { if (!isDragging) { setIsHovered(false); } }} /> {/* Pill grip indicator — themed via .astryx-resize-handle-pill */} {children ??
}
; } ResizeHandle.displayName = 'ResizeHandle';