diff --git a/static/app/components/core/splitPanel/index.tsx b/static/app/components/core/splitPanel/index.tsx new file mode 100644 index 00000000000000..cba4aaf7ca914e --- /dev/null +++ b/static/app/components/core/splitPanel/index.tsx @@ -0,0 +1,8 @@ +export { + SplitPanel, + type SplitPanelDividerProps, + type SplitPanelPanelProps, + type SplitPanelProps, + useSplitPanel, + useSplitPanelDivider, +} from './splitPanel'; diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx new file mode 100644 index 00000000000000..62dae652f4bb45 --- /dev/null +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -0,0 +1,266 @@ +--- +title: SplitPanel +description: A resizable two-pane layout with a draggable, keyboard-accessible divider, supporting horizontal (left/right) and vertical (top/bottom) orientations. +category: layout +source: '@sentry/scraps/splitPanel' +resources: + js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/splitPanel/splitPanel.tsx +--- + +import {useState} from 'react'; + +import {Flex} from '@sentry/scraps/layout'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; +import {Text} from '@sentry/scraps/text'; + +import * as Storybook from 'sentry/stories'; + +export const documentation = import('!!type-loader!@sentry/scraps/splitPanel'); + +export function HorizontalSplitDemo() { + const CONTAINER_WIDTH = 600; + const [size, setSize] = useState(CONTAINER_WIDTH / 2); + const leftPct = (size / CONTAINER_WIDTH) * 100; + const rightPct = 100 - leftPct; + return ( + + + + + + Left + + + + + + Right + + + + + + Left: {leftPct.toFixed(1)}% | Right: {rightPct.toFixed(1)}% + + + ); +} + +export function VerticalSplitDemo() { + const CONTAINER_HEIGHT = 240; + const [size, setSize] = useState(CONTAINER_HEIGHT / 2); + const topPct = (size / CONTAINER_HEIGHT) * 100; + const bottomPct = 100 - topPct; + return ( + + + + + + Top + + + + + + Bottom + + + + + + Top: {topPct.toFixed(1)}% | Bottom: {bottomPct.toFixed(1)}% + + + ); +} + +`SplitPanel` is a composable two-pane layout with a draggable divider. The pane that declares `defaultSize` is the sized pane; the other fills the remaining space. The root self-measures, so callers don't need to thread the container width or height in. + +## Basic Usage + +```tsx + + + + + + + + + +``` + +The composable shape makes the call site read top-to-bottom: first pane, divider, second pane. There's no `availableSize` prop — the root measures itself with `useDimensions`. + +## Horizontal Split + +The first pane is sized; the second fills. + + + + + +```tsx + + + + + + + + + +``` + +## Vertical Split + +Use `orientation="vertical"` to split top/bottom. The first pane is still the sized one. + + + + + +```tsx + + + + + + + + + +``` + +## Collapsing a Pane + +To collapse the fill pane, omit the `` and second ``. The sized pane's DOM identity is preserved across collapses, so its content state is not lost. + +```tsx + + + + + {!isCollapsed && ( + <> + + + + + + )} + +``` + +## Persisting Size + +`SplitPanel` is a layout primitive — persistence is the consumer's job. Wire `onResize` to your own storage (`useLocalStorageState`, a database, a query param, etc.) and feed the stored value back as `defaultSize`. + +```tsx +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; + +function ResizableLayout() { + const [storedSize, setStoredSize] = useLocalStorageState('my-feature.split-size', 300); + + return ( + + + + + + + + + + ); +} +``` + +## Custom Divider + +For most cases, use `` — a thin 1px line matching the rest of the app. If you need a different visual (e.g. a grab handle, a thicker bar, a colored line for a critical section), build your own divider with the `useSplitPanelDivider` hook. It returns the ARIA props, event handlers, and `isHeld` state needed to make any element behave like the divider. + +```tsx +import styled from '@emotion/styled'; +import {useSplitPanelDivider} from '@sentry/scraps/splitPanel'; + +function GrabHandleDivider() { + const {props, isHeld} = useSplitPanelDivider(); + return ( + + + + ); +} + +const GrabHandle = styled('div')` + display: grid; + place-items: center; + width: ${p => p.theme.space.xl}; + cursor: ew-resize; +`; +``` + +Then drop your component in place of `` inside the `` children. + +## Tracking Resize Events + +`onMouseDown` fires when the user starts dragging (receives the current size as a percentage). `onResize` fires as the size changes (receives the new size in pixels). Use these for analytics or to drive linked UI. + +```tsx + trackStart(sizePct)} + onResize={newSize => trackEnd(newSize)} +> + + + + + + + + +``` + +## Imperative Controls + +Use the `useSplitPanel` hook from any descendant to maximise, minimise, or reset the sized pane. + +```tsx +import {useSplitPanel} from '@sentry/scraps/splitPanel'; + +function PaneToolbar() { + const {maximiseSize, minimiseSize, resetSize, isMaximized, isMinimized} = + useSplitPanel(); + + return ( + + + + + + ); +} +``` + +## Accessibility + +- The divider has `role="separator"` with `aria-orientation`, `aria-valuemin`, `aria-valuemax`, and `aria-valuenow` reflecting the current size. +- The divider is focusable (`tabIndex={0}`) and supports keyboard resize: ← / → for horizontal, ↑ / ↓ for vertical, with `Shift` for a coarser step. `Home` snaps to `minSize`; `End` snaps to `maxSize`. +- Double-clicking the divider resets the sized pane to its `defaultSize`. diff --git a/static/app/components/core/splitPanel/splitPanel.spec.tsx b/static/app/components/core/splitPanel/splitPanel.spec.tsx new file mode 100644 index 00000000000000..5c91af8b600ea3 --- /dev/null +++ b/static/app/components/core/splitPanel/splitPanel.spec.tsx @@ -0,0 +1,134 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {SplitPanel} from '@sentry/scraps/splitPanel'; + +describe('SplitPanel', () => { + describe('horizontal orientation', () => { + it('renders both panels and a divider', () => { + render( + + +
left
+
+ + +
right
+
+
+ ); + + expect(screen.getByTestId('left-content')).toBeInTheDocument(); + expect(screen.getByTestId('right-content')).toBeInTheDocument(); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('renders only the first panel when the second is omitted', () => { + render( + + +
left
+
+
+ ); + + expect(screen.getByTestId('left-content')).toBeInTheDocument(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); + }); + + it('preserves DOM identity of the sized panel when toggling the fill panel', () => { + const {rerender} = render( + + +
left
+
+ + +
right
+
+
+ ); + + const leftBefore = screen.getByTestId('left-content'); + + rerender( + + +
left
+
+
+ ); + + expect(screen.getByTestId('left-content')).toBe(leftBefore); + + rerender( + + +
left
+
+ + +
right
+
+
+ ); + + expect(screen.getByTestId('left-content')).toBe(leftBefore); + }); + }); + + describe('vertical orientation', () => { + it('renders both panels and a divider', () => { + render( + + +
top
+
+ + +
bottom
+
+
+ ); + + expect(screen.getByTestId('top-content')).toBeInTheDocument(); + expect(screen.getByTestId('bottom-content')).toBeInTheDocument(); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('renders only the first panel when the second is omitted', () => { + render( + + +
top
+
+
+ ); + + expect(screen.getByTestId('top-content')).toBeInTheDocument(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); + }); + }); + + describe('divider accessibility', () => { + it('exposes separator role with orientation and value attributes', () => { + render( + + +
left
+
+ + +
right
+
+
+ ); + + const separator = screen.getByRole('separator'); + // Horizontal split → vertical divider line + expect(separator).toHaveAttribute('aria-orientation', 'vertical'); + expect(separator).toHaveAttribute('aria-valuemin', '100'); + expect(separator).toHaveAttribute('aria-valuemax', '600'); + expect(separator).toHaveAttribute('tabindex', '0'); + }); + }); +}); diff --git a/static/app/components/core/splitPanel/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx new file mode 100644 index 00000000000000..d036026e953dd7 --- /dev/null +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -0,0 +1,434 @@ +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useMemo, + useRef, +} from 'react'; +import styled from '@emotion/styled'; + +import {useDimensions} from 'sentry/utils/useDimensions'; +import {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; + +type Orientation = 'horizontal' | 'vertical'; + +type SplitPanelContextValue = { + isHeld: boolean; + isMaximized: boolean; + isMinimized: boolean; + isReady: boolean; + max: number; + maximiseSize: () => void; + min: number; + minimiseSize: () => void; + onDoubleClick: React.MouseEventHandler; + onKeyDown: React.KeyboardEventHandler; + onMouseDown: React.MouseEventHandler; + orientation: Orientation; + resetSize: () => void; + size: number; +}; + +const SplitPanelContext = createContext(null); + +function useSplitPanelContext(component: string): SplitPanelContextValue { + const ctx = useContext(SplitPanelContext); + if (!ctx) { + throw new Error(`${component} must be rendered inside `); + } + return ctx; +} + +/** + * Imperative controls for the surrounding ``. Use from any + * descendant to programmatically maximise, minimise, or reset the sized pane. + */ +export function useSplitPanel() { + const {isMaximized, isMinimized, maximiseSize, minimiseSize, resetSize} = + useSplitPanelContext('useSplitPanel'); + return {isMaximized, isMinimized, maximiseSize, minimiseSize, resetSize}; +} + +/** + * Returns everything needed to render a custom divider element. Spread + * `props` on the divider's root element to get ARIA, event handlers, and + * `tabIndex`. Use `isHeld` and `orientation` for styling. + */ +export function useSplitPanelDivider() { + const {isHeld, max, min, onDoubleClick, onKeyDown, onMouseDown, orientation, size} = + useSplitPanelContext('useSplitPanelDivider'); + return { + isHeld, + orientation, + props: { + 'aria-orientation': + orientation === 'horizontal' ? ('vertical' as const) : ('horizontal' as const), + 'aria-valuemax': Number.isFinite(max) ? max : undefined, + 'aria-valuemin': min, + 'aria-valuenow': size, + onDoubleClick, + onKeyDown, + onMouseDown, + role: 'separator' as const, + tabIndex: 0, + }, + }; +} + +export type SplitPanelProps = { + /** + * Exactly one `` followed by an optional + * `` and a second ``. When only one + * `` is rendered, it fills the container. + */ + children: React.ReactNode; + /** + * Fires when the user starts dragging the divider. Receives the current + * size of the sized pane as a percentage of the container. + */ + onMouseDown?: (sizePct: `${number}%`) => void; + /** + * Fires as the user drags. Receives the new size in pixels. Wire this to + * your own persistence layer if you want to remember the size across + * reloads (e.g. `useLocalStorageState`). + */ + onResize?: (newSize: number) => void; + /** + * Layout direction. `horizontal` splits left/right; `vertical` splits + * top/bottom. + */ + orientation?: Orientation; +}; + +export type SplitPanelPanelProps = { + children: React.ReactNode; + /** + * Initial size of this pane in pixels. The pane that declares `defaultSize` + * is the sized pane; the other fills the remaining space. Only one pane may + * be sized. + */ + defaultSize?: number; + /** + * Maximum size in pixels the user may drag this pane to. Only meaningful on + * the sized pane. + */ + maxSize?: number; + /** + * Minimum size in pixels the user may drag this pane to. Only meaningful on + * the sized pane. + */ + minSize?: number; +}; + +export type SplitPanelDividerProps = Record; + +function Panel({children}: SplitPanelPanelProps) { + const {isReady, orientation, size} = useSplitPanelContext('SplitPanel.Panel'); + const isSized = useIsSizedPanel(); + + return ( + + {children} + + ); +} + +const IsSizedPanelContext = createContext(false); +function useIsSizedPanel() { + return useContext(IsSizedPanelContext); +} + +function Divider() { + const {isHeld, max, min, onDoubleClick, onKeyDown, onMouseDown, orientation, size} = + useSplitPanelContext('SplitPanel.Divider'); + + return ( + + ); +} + +function findSizedPanelProps(children: React.ReactNode): SplitPanelPanelProps | null { + let result: SplitPanelPanelProps | null = null; + Children.forEach(children, child => { + if (result) { + return; + } + if (isValidElement(child) && child.type === Panel) { + const props = child.props as SplitPanelPanelProps; + if (props.defaultSize !== undefined) { + result = props; + } + } + }); + return result; +} + +export function SplitPanel({ + children, + orientation = 'horizontal', + onMouseDown, + onResize, +}: SplitPanelProps) { + const containerRef = useRef(null); + const dims = useDimensions({elementRef: containerRef}); + const availableSize = orientation === 'horizontal' ? dims.width : dims.height; + + const sizedProps = useMemo(() => findSizedPanelProps(children), [children]); + const min = sizedProps?.minSize ?? 0; + const explicitMax = sizedProps?.maxSize ?? Number.POSITIVE_INFINITY; + // Cap the sized pane at the container size so it can never overflow the + // parent. Matches Zag/Chakra's `maxSize = 100%` default. Until we've + // measured, fall back to the explicit max (or Infinity) so the hook can + // accept the initial size without clamping it to zero. + const max = availableSize > 0 ? Math.min(explicitMax, availableSize) : explicitMax; + const initialSize = sizedProps?.defaultSize ?? 0; + + // useResizableDrawer only enforces `min`, not `max`, so its internal size can + // drift past `max` while the user keeps dragging. We clamp before forwarding + // to the consumer's `onResize` and snap the hook back when it drifts, so + // dragging in the opposite direction responds immediately. + const setSizeRef = useRef<(size: number, userEvent?: boolean) => void>(() => {}); + const handleHookResize = useCallback( + (newSize: number) => { + const clamped = Math.min(Math.max(newSize, min), max); + if (clamped !== newSize) { + setSizeRef.current(clamped, true); + return; + } + onResize?.(newSize); + }, + [onResize, min, max] + ); + + const { + isHeld, + onDoubleClick, + onMouseDown: onDragStart, + setSize, + size: containerSize, + } = useResizableDrawer({ + direction: orientation === 'horizontal' ? 'left' : 'down', + initialSize, + min, + onResize: handleHookResize, + }); + setSizeRef.current = setSize; + + const clampedSize = Math.min(containerSize, max); + const sizePct = `${ + availableSize > 0 ? (clampedSize / availableSize) * 100 : 0 + }%` as const; + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + onMouseDown?.(sizePct); + onDragStart(event); + }, + [onDragStart, sizePct, onMouseDown] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + const step = event.shiftKey ? 50 : 10; + const isHorizontal = orientation === 'horizontal'; + const decreaseKey = isHorizontal ? 'ArrowLeft' : 'ArrowUp'; + const increaseKey = isHorizontal ? 'ArrowRight' : 'ArrowDown'; + + if (event.key === decreaseKey) { + event.preventDefault(); + setSize(Math.max(min, containerSize - step), true); + } else if (event.key === increaseKey) { + event.preventDefault(); + setSize(Math.min(max, containerSize + step), true); + } else if (event.key === 'Home') { + event.preventDefault(); + setSize(min, true); + } else if (event.key === 'End' && Number.isFinite(max)) { + event.preventDefault(); + setSize(max, true); + } + }, + [orientation, containerSize, min, max, setSize] + ); + + const isReady = availableSize > 0; + const isMaximized = containerSize >= max; + const isMinimized = containerSize <= min; + + const contextValue = useMemo( + () => ({ + isHeld, + isMaximized, + isMinimized, + isReady, + max, + maximiseSize: () => setSize(max), + min, + minimiseSize: () => setSize(min), + onDoubleClick, + onKeyDown: handleKeyDown, + onMouseDown: handleMouseDown, + orientation, + resetSize: () => setSize(initialSize), + size: isReady ? clampedSize : 0, + }), + [ + clampedSize, + handleKeyDown, + handleMouseDown, + initialSize, + isHeld, + isMaximized, + isMinimized, + isReady, + max, + min, + onDoubleClick, + orientation, + setSize, + ] + ); + + // Tag each Panel child with whether it's the sized one. We can't read the + // tag inside Panel without an extra Context, so this drives IsSizedPanelContext. + let sizedPanelMarked = false; + const wrappedChildren = Children.map(children, child => { + if (isValidElement(child) && child.type === Panel) { + const childProps = child.props as SplitPanelPanelProps; + const isThisPanelSized = !sizedPanelMarked && childProps.defaultSize !== undefined; + if (isThisPanelSized) { + sizedPanelMarked = true; + } + return ( + + {child} + + ); + } + return child; + }); + + return ( + + + {wrappedChildren} + + + ); +} + +SplitPanel.Panel = Panel; +SplitPanel.Divider = Divider; + +const SplitPanelRoot = styled('div')` + position: relative; + display: flex; + width: 100%; + height: 100%; + min-height: 0; + min-width: 0; + flex-grow: 1; + + &[data-orientation='horizontal'] { + flex-direction: row; + } + &[data-orientation='vertical'] { + flex-direction: column; + } + + /* + * Disable iframe pointer events while dragging so the divider doesn't lose + * the cursor when crossing an embedded iframe (e.g. the Replay player). + */ + &&.disable-iframe-pointer iframe { + pointer-events: none !important; + } +`; + +const PanelContainer = styled('div')<{sizePx: number | undefined}>` + display: flex; + flex-direction: column; + min-height: 0; + min-width: 0; + ${p => (p.sizePx === undefined ? 'flex: 1 1 0;' : `flex: 0 0 ${p.sizePx}px;`)} +`; + +const DividerLine = styled('div')` + position: relative; + flex-shrink: 0; + user-select: none; + + /* Invisible wider hit area for dragging */ + &::before { + content: ''; + position: absolute; + z-index: 1; + } + + &[data-orientation='horizontal'] { + width: 0; + height: 100%; + cursor: ew-resize; + border-left: 1px solid ${p => p.theme.tokens.border.primary}; + + &::before { + top: 0; + bottom: 0; + left: -5px; + width: 11px; + } + + &:hover, + &[data-is-held='true'] { + border-left-color: ${p => p.theme.tokens.border.accent.moderate}; + } + } + + &[data-orientation='vertical'] { + width: 100%; + height: 0; + cursor: ns-resize; + border-top: 1px solid ${p => p.theme.tokens.border.primary}; + + &::before { + left: 0; + right: 0; + top: -5px; + height: 11px; + } + + &:hover, + &[data-is-held='true'] { + border-top-color: ${p => p.theme.tokens.border.accent.moderate}; + } + } + + &:focus-visible { + outline: 2px solid ${p => p.theme.tokens.focus.default}; + } +`; diff --git a/static/app/components/splitPanel.spec.tsx b/static/app/components/splitPanel.spec.tsx deleted file mode 100644 index b3ad5b40b6faa3..00000000000000 --- a/static/app/components/splitPanel.spec.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import {render, screen} from 'sentry-test/reactTestingLibrary'; - -import {SplitPanel} from 'sentry/components/splitPanel'; - -const defaultLeftSide = { - content:
left
, - default: 200, - min: 100, - max: 600, -}; -const defaultTopSide = { - content:
top
, - default: 200, - min: 100, - max: 600, -}; - -describe('SplitPanel', () => { - describe('left/right', () => { - it('renders left, divider, and right when right is provided', () => { - render( - right} - /> - ); - - expect(screen.getByTestId('left-content')).toBeInTheDocument(); - expect(screen.getByTestId('right-content')).toBeInTheDocument(); - }); - - it('omits the divider and right pane when right is null', () => { - render(); - - expect(screen.getByTestId('left-content')).toBeInTheDocument(); - expect(screen.queryByTestId('right-content')).not.toBeInTheDocument(); - }); - - it('preserves DOM identity of left.content when toggling right between content and null', () => { - const {rerender} = render( - right} - /> - ); - - const leftBefore = screen.getByTestId('left-content'); - - rerender(); - - const leftAfterCollapse = screen.getByTestId('left-content'); - expect(leftAfterCollapse).toBe(leftBefore); - - rerender( - right} - /> - ); - - const leftAfterExpand = screen.getByTestId('left-content'); - expect(leftAfterExpand).toBe(leftBefore); - }); - }); - - describe('top/bottom', () => { - it('renders top, divider, and bottom when bottom is provided', () => { - render( - bottom} - /> - ); - - expect(screen.getByTestId('top-content')).toBeInTheDocument(); - expect(screen.getByTestId('bottom-content')).toBeInTheDocument(); - }); - - it('omits the divider and bottom pane when bottom is null', () => { - render(); - - expect(screen.getByTestId('top-content')).toBeInTheDocument(); - expect(screen.queryByTestId('bottom-content')).not.toBeInTheDocument(); - }); - - it('preserves DOM identity of top.content when toggling bottom between content and null', () => { - const {rerender} = render( - bottom} - /> - ); - - const topBefore = screen.getByTestId('top-content'); - - rerender(); - - const topAfterCollapse = screen.getByTestId('top-content'); - expect(topAfterCollapse).toBe(topBefore); - - rerender( - bottom} - /> - ); - - const topAfterExpand = screen.getByTestId('top-content'); - expect(topAfterExpand).toBe(topBefore); - }); - }); -}); diff --git a/static/app/components/splitPanel.tsx b/static/app/components/splitPanel.tsx deleted file mode 100644 index 6c7ebb2f45941a..00000000000000 --- a/static/app/components/splitPanel.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import {createContext, Fragment, useCallback, useMemo} from 'react'; -import styled from '@emotion/styled'; - -import {Stack} from '@sentry/scraps/layout'; - -import {IconGrabbable} from 'sentry/icons'; -import {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; - -type DividerProps = { - 'data-is-held': boolean; - 'data-slide-direction': 'leftright' | 'updown'; - onDoubleClick: React.MouseEventHandler; - onMouseDown: React.MouseEventHandler; - icon?: React.ReactNode; -} & React.DOMAttributes; - -const BaseSplitDivider = styled(({icon, ...props}: DividerProps) => ( -
{icon || }
-))` - display: grid; - place-items: center; - height: 100%; - width: 100%; - - user-select: inherit; - background: inherit; - - &:hover { - background: ${p => p.theme.tokens.interactive.transparent.neutral.background.hover}; - } - &[data-is-held='true'] { - user-select: none; - background: ${p => p.theme.tokens.interactive.transparent.neutral.background.active}; - } - - &[data-slide-direction='leftright'] { - cursor: ew-resize; - height: 100%; - width: ${p => p.theme.space.xl}; - } - &[data-slide-direction='updown'] { - cursor: ns-resize; - width: 100%; - height: ${p => p.theme.space.xl}; - - & > svg { - transform: rotate(90deg); - } - } -`; - -const SplitPanelContext = createContext({ - isMaximized: false, - isMinimized: false, - maximiseSize: () => {}, - minimiseSize: () => {}, - resetSize: () => {}, -}); - -type Side = { - content: React.ReactNode; - default: number; - max: number; - min: number; -}; - -type CommonProps = { - availableSize: number; - SplitDivider?: React.ComponentType; - onMouseDown?: (sizePct: `${number}%`) => void; - onResize?: (newSize: number) => void; - sizeStorageKey?: string; -}; - -export type SplitPanelProps = CommonProps & - ( - | { - availableSize: number; - /** - * Content on the left side of the split - */ - left: Side; - /** - * Content on the right side of the split - */ - right: React.ReactNode; - } - | { - availableSize: number; - /** - * Content below the split - */ - bottom: React.ReactNode; - /** - * Content above of the split - */ - top: Side; - } - ); - -export function SplitPanel(props: SplitPanelProps) { - const { - availableSize, - SplitDivider = BaseSplitDivider, - onMouseDown, - onResize, - sizeStorageKey, - } = props; - const isLeftRight = 'left' in props; - const initialSize = isLeftRight ? props.left.default : props.top.default; - const min = isLeftRight ? props.left.min : props.top.min; - const max = isLeftRight ? props.left.max : props.top.max; - - const { - isHeld, - onDoubleClick, - onMouseDown: onDragStart, - size: containerSize, - setSize, - } = useResizableDrawer({ - direction: isLeftRight ? 'left' : 'down', - initialSize, - min, - onResize: onResize ?? (() => {}), - sizeStorageKey, - }); - - const sizePct = `${(Math.min(containerSize, max) / availableSize) * 100}%` as const; - - const handleMouseDown = useCallback( - (event: any) => { - onMouseDown?.(sizePct); - onDragStart(event); - }, - [onDragStart, sizePct, onMouseDown] - ); - - const isMaximized = containerSize >= max; - const isMinimized = containerSize <= min; - - const contextValue = useMemo( - () => ({ - isMaximized, - isMinimized, - maximiseSize: () => setSize(max), - minimiseSize: () => setSize(min), - resetSize: () => setSize(initialSize), - }), - [isMaximized, isMinimized, setSize, max, min, initialSize] - ); - - const [a, b, direction, orientation] = isLeftRight - ? ([props.left, props.right, 'leftright', 'columns'] as const) - : ([props.top, props.bottom, 'updown', 'rows'] as const); - - const isCollapsed = b === null || b === undefined; - - return ( - - - - {a.content} - - {isCollapsed ? null : ( - - - - {b} - - - )} - - - ); -} - -const SplitPanelContainer = styled('div')<{ - orientation: 'rows' | 'columns'; - size: `${number}px` | `${number}%`; -}>` - min-height: 0; - min-width: 0; - flex-grow: 1; - - position: relative; - display: grid; - grid-template-${p => p.orientation}: ${p => p.size} auto 1fr; - - /* - * This is more specific, with && than the foundational rule: - * &[data-inspectable='true'] .replayer-wrapper > iframe - */ - &&.disable-iframe-pointer iframe { - pointer-events: none !important; - } -`; diff --git a/static/app/views/explore/conversations/components/conversationLayout.tsx b/static/app/views/explore/conversations/components/conversationLayout.tsx index e21b00d2e3f012..f35e229c505519 100644 --- a/static/app/views/explore/conversations/components/conversationLayout.tsx +++ b/static/app/views/explore/conversations/components/conversationLayout.tsx @@ -1,12 +1,12 @@ import type React from 'react'; import {useRef} from 'react'; -import styled from '@emotion/styled'; import {Container, Flex} from '@sentry/scraps/layout'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; import {Placeholder} from 'sentry/components/placeholder'; -import {SplitPanel} from 'sentry/components/splitPanel'; import {useDimensions} from 'sentry/utils/useDimensions'; +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import {useOrganization} from 'sentry/utils/useOrganization'; import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; @@ -15,45 +15,6 @@ const RIGHT_PANEL_MIN = 400; const DIVIDER_WIDTH = 1; const DEFAULT_STORAGE_KEY = 'conversation-split-size'; -/** - * Minimal resize divider matching the trace drawer style: - * a 1px border line with an invisible wider hit area for dragging. - */ -const BorderDivider = styled( - ({ - icon: _icon, - ...props - }: { - 'data-is-held': boolean; - 'data-slide-direction': 'leftright' | 'updown'; - onDoubleClick: React.MouseEventHandler; - onMouseDown: React.MouseEventHandler; - icon?: React.ReactNode; - }) =>
-)` - width: ${DIVIDER_WIDTH}px; - height: 100%; - position: relative; - user-select: none; - background: ${p => p.theme.tokens.border.primary}; - - /* Invisible wider hit area for dragging */ - &::before { - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: -5px; - width: 11px; - cursor: ew-resize; - z-index: 1; - } - - &[data-is-held='true'] { - background: ${p => p.theme.tokens.border.accent.moderate}; - } -`; - /** * Resizable two-column layout for conversation views. * Left panel holds messages/spans, right panel holds span details. @@ -71,29 +32,27 @@ export function ConversationSplitLayout({ const measureRef = useRef(null); const {width} = useDimensions({elementRef: measureRef}); - const hasSize = width > 0; const maxLeft = Math.max(LEFT_PANEL_MIN, width - RIGHT_PANEL_MIN - DIVIDER_WIDTH); const defaultLeft = Math.min( maxLeft, Math.max(LEFT_PANEL_MIN, (width - DIVIDER_WIDTH) * 0.5) ); + const [storedSize, setStoredSize] = useLocalStorageState(sizeStorageKey, defaultLeft); + return ( - {hasSize ? ( - - ) : null} + + + {left} + + + {right} + ); } diff --git a/static/app/views/explore/replays/detail/layout/replayLayout.tsx b/static/app/views/explore/replays/detail/layout/replayLayout.tsx index f960b66837668d..be8581b8f503e2 100644 --- a/static/app/views/explore/replays/detail/layout/replayLayout.tsx +++ b/static/app/views/explore/replays/detail/layout/replayLayout.tsx @@ -1,7 +1,8 @@ -import {useRef} from 'react'; +import {Fragment, useRef} from 'react'; import styled from '@emotion/styled'; import {Stack} from '@sentry/scraps/layout'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; import {TooltipContext} from '@sentry/scraps/tooltip'; import {ErrorBoundary} from 'sentry/components/errorBoundary'; @@ -20,7 +21,7 @@ import {useFullscreen} from 'sentry/utils/window/useFullscreen'; import {ViewportConstrainedPage} from 'sentry/views/explore/components/viewportConstrainedPage'; import {FocusArea} from 'sentry/views/explore/replays/detail/layout/focusArea'; import {FocusTabs} from 'sentry/views/explore/replays/detail/layout/focusTabs'; -import {ReplaySplitPanel as SplitPanel} from 'sentry/views/explore/replays/detail/layout/splitPanel'; +import {ReplaySplitPanel} from 'sentry/views/explore/replays/detail/layout/splitPanel'; import type {ReplayRecord} from 'sentry/views/explore/replays/types'; const MIN_CONTENT_WIDTH = 340; @@ -124,32 +125,42 @@ function ReplayLayoutBody({ const effectiveLayout = isFocusAreaCollapsed ? defaultLayout : layout; const isLeftRight = effectiveLayout === LayoutKey.SIDEBAR_LEFT; - const splitPanelProps = isLeftRight - ? { - availableSize: width, - left: { - content: {video}, - default: width * 0.5, - min: MIN_SIDEBAR_WIDTH, - max: width - MIN_CONTENT_WIDTH, - }, - right: isFocusAreaCollapsed ? null : focusArea, - } - : { - availableSize: height, - top: { - content: {video}, - default: (height - DIVIDER_SIZE) * 0.5, - min: MIN_VIDEO_HEIGHT, - max: height - DIVIDER_SIZE - MIN_CONTENT_HEIGHT, - }, - bottom: isFocusAreaCollapsed ? null : focusArea, - }; + const sizedPanel = isLeftRight ? ( + + {video} + + ) : ( + + {video} + + ); return ( - {hasSize ? : null} + {hasSize ? ( + + {sizedPanel} + {!isFocusAreaCollapsed && ( + + + {focusArea} + + )} + + ) : null} {controller} diff --git a/static/app/views/explore/replays/detail/layout/splitPanel.tsx b/static/app/views/explore/replays/detail/layout/splitPanel.tsx index ae4bcf4027a955..b71dec9420cbe9 100644 --- a/static/app/views/explore/replays/detail/layout/splitPanel.tsx +++ b/static/app/views/explore/replays/detail/layout/splitPanel.tsx @@ -1,22 +1,32 @@ -import {useCallback} from 'react'; +import {useMemo} from 'react'; import debounce from 'lodash/debounce'; -import type {SplitPanelProps} from 'sentry/components/splitPanel'; -import {SplitPanel} from 'sentry/components/splitPanel'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; + import {trackAnalytics} from 'sentry/utils/analytics'; import type {LayoutKey} from 'sentry/utils/replays/hooks/useReplayLayout'; import {useSplitPanelTracking} from 'sentry/utils/replays/hooks/useSplitPanelTracking'; import {useOrganization} from 'sentry/utils/useOrganization'; -export function ReplaySplitPanel({ - layout, - ...props -}: SplitPanelProps & {layout: LayoutKey}) { - const {availableSize} = props; - const isLeftRight = 'left' in props; +type Orientation = 'horizontal' | 'vertical'; + +type Props = { + /** + * Measured size of the container along the split axis (width for + * horizontal, height for vertical). Used to log the end position as a + * percentage for analytics. The parent already measures the layout for + * its own grid, so threading it through avoids a second measurement. + */ + availableSize: number; + children: React.ReactNode; + layout: LayoutKey; + orientation: Orientation; +}; + +export function ReplaySplitPanel({availableSize, children, layout, orientation}: Props) { const organization = useOrganization(); const {setStartPosition, logEndPosition} = useSplitPanelTracking({ - slideDirection: isLeftRight ? 'leftright' : 'updown', + slideDirection: orientation === 'horizontal' ? 'leftright' : 'updown', track: ({slideMotion}) => { trackAnalytics('replay.details-resized-panel', { organization, @@ -26,18 +36,22 @@ export function ReplaySplitPanel({ }, }); - // eslint-disable-next-line react-hooks/exhaustive-deps - const handleResize = useCallback( - debounce(newSize => logEndPosition(`${(newSize / availableSize) * 100}%`), 750), + const handleResize = useMemo( + () => + debounce( + (newSize: number) => logEndPosition(`${(newSize / availableSize) * 100}%`), + 750 + ), [logEndPosition, availableSize] ); - const handleMouseDown = useCallback( - (sizePct: `${number}%`) => { - setStartPosition(sizePct); - }, - [setStartPosition] + return ( + + {children} + ); - - return ; }