From be365cdfd8fa231058e63b87d617039c2f680406 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 09:38:10 +0200 Subject: [PATCH 1/8] feat(scraps): Adopt SplitPanel component into design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `SplitPanel` from `sentry/components/splitPanel` into `@sentry/scraps/splitPanel`. Adds the standard scraps companions — `index.tsx` barrel and `splitPanel.mdx` stories — and updates the two existing consumers (the conversation layout and the replay panel wrapper) to import from the new alias. The Replay wrapper no longer wraps `setStartPosition` in `useCallback`; scraps components are intentionally unmemoized, so the wrapper provided no benefit and ESLint's `@sentry/no-unnecessary-use-callback` rule flagged it. Switches `handleResize` from `useCallback` to `useMemo` so the debounced function reference stays stable across renders without tripping the same rule. Tightens the divider `onMouseDown` event type from `any` to `React.MouseEvent` so the moved file passes the stricter type-aware rules applied inside `static/app/components/core/`. Co-Authored-By: Claude --- .../app/components/core/splitPanel/index.tsx | 1 + .../components/core/splitPanel/splitPanel.mdx | 205 ++++++++++++++++++ .../{ => core/splitPanel}/splitPanel.spec.tsx | 2 +- .../{ => core/splitPanel}/splitPanel.tsx | 2 +- .../components/conversationLayout.tsx | 2 +- .../replays/detail/layout/splitPanel.tsx | 25 +-- 6 files changed, 220 insertions(+), 17 deletions(-) create mode 100644 static/app/components/core/splitPanel/index.tsx create mode 100644 static/app/components/core/splitPanel/splitPanel.mdx rename static/app/components/{ => core/splitPanel}/splitPanel.spec.tsx (98%) rename static/app/components/{ => core/splitPanel}/splitPanel.tsx (99%) diff --git a/static/app/components/core/splitPanel/index.tsx b/static/app/components/core/splitPanel/index.tsx new file mode 100644 index 000000000000..436cc538852c --- /dev/null +++ b/static/app/components/core/splitPanel/index.tsx @@ -0,0 +1 @@ +export {SplitPanel, type SplitPanelProps} 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 000000000000..1fc31a740097 --- /dev/null +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -0,0 +1,205 @@ +--- +title: SplitPanel +description: A resizable two-pane layout with a draggable 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 {Container, 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'); + +The `SplitPanel` component renders two panes separated by a draggable divider. It supports either a horizontal split (`left` / `right`) or a vertical split (`top` / `bottom`). The sized side accepts `default`, `min`, and `max` constraints; the opposite side fills the remaining space. + +## Basic Usage + +Pass `availableSize` (the measured width or height of the container) along with the sized side's constraints and the other side's content. + +```jsx +, default: 300, min: 200, max: 600}} + right={} +/> +``` + +## Horizontal Split (left / right) + +The `left` pane is sized; `right` fills the remaining space. + + + + + Left + + ), + default: 240, + min: 120, + max: 480, + }} + right={ + + Right + + } + /> + + + +```jsx +, + default: 240, + min: 120, + max: 480, + }} + right={} +/> +``` + +## Vertical Split (top / bottom) + +The `top` pane is sized; `bottom` fills the remaining space. + + + + + Top + + ), + default: 100, + min: 60, + max: 180, + }} + bottom={ + + Bottom + + } + /> + + + +```jsx +, + default: 100, + min: 60, + max: 180, + }} + bottom={} +/> +``` + +## Measuring the container + +`availableSize` is typically the measured width (for left/right) or height (for top/bottom) of the parent container. Pair `SplitPanel` with `useDimensions` to keep the size in sync with the layout. + +```tsx +import {useRef} from 'react'; + +import {Flex} from '@sentry/scraps/layout'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; + +import {useDimensions} from 'sentry/utils/useDimensions'; + +function ResizableLayout({left, right}: {left: React.ReactNode; right: React.ReactNode}) { + const ref = useRef(null); + const {width} = useDimensions({elementRef: ref}); + + return ( + + {width > 0 ? ( + + ) : null} + + ); +} +``` + +## Collapsing a pane + +Passing `null` for the unsized side hides the divider and expands the sized pane to fill the full width. The sized pane's DOM identity is preserved across collapses. + +```jsx +, default: 300, min: 200, max: 600}} + right={isCollapsed ? null : } +/> +``` + +## Persisting size + +Pass `sizeStorageKey` to persist the user's drag size in `localStorage` across reloads. + +```jsx +, default: 300, min: 200, max: 600}} + right={} +/> +``` + +## Custom divider + +The default divider shows a grab handle and changes background on hover/drag. Pass a custom `SplitDivider` component to swap in a different appearance (for example, a thin 1px border line). + +```jsx +import styled from '@emotion/styled'; + +const BorderDivider = styled('div')` + width: 1px; + height: 100%; + background: ${p => p.theme.tokens.border.primary}; + cursor: ew-resize; + + &[data-is-held='true'] { + background: ${p => p.theme.tokens.border.accent.moderate}; + } +`; + +, default: 300, min: 200, max: 600}} + right={} +/>; +``` + +## Tracking resize events + +`onMouseDown` fires when the user starts dragging (receives the size as a percentage string). `onResize` fires as the size changes (receives the new size in pixels). Use these for analytics or to drive linked UI. + +```jsx + trackStart(sizePct)} + onResize={newSize => trackEnd(newSize)} + left={{content: , default: 300, min: 200, max: 600}} + right={} +/> +``` diff --git a/static/app/components/splitPanel.spec.tsx b/static/app/components/core/splitPanel/splitPanel.spec.tsx similarity index 98% rename from static/app/components/splitPanel.spec.tsx rename to static/app/components/core/splitPanel/splitPanel.spec.tsx index b3ad5b40b6fa..18b17ba81031 100644 --- a/static/app/components/splitPanel.spec.tsx +++ b/static/app/components/core/splitPanel/splitPanel.spec.tsx @@ -1,6 +1,6 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; -import {SplitPanel} from 'sentry/components/splitPanel'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; const defaultLeftSide = { content:
left
, diff --git a/static/app/components/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx similarity index 99% rename from static/app/components/splitPanel.tsx rename to static/app/components/core/splitPanel/splitPanel.tsx index 6c7ebb2f4594..4b8ef0032d3b 100644 --- a/static/app/components/splitPanel.tsx +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -128,7 +128,7 @@ export function SplitPanel(props: SplitPanelProps) { const sizePct = `${(Math.min(containerSize, max) / availableSize) * 100}%` as const; const handleMouseDown = useCallback( - (event: any) => { + (event: React.MouseEvent) => { onMouseDown?.(sizePct); onDragStart(event); }, diff --git a/static/app/views/explore/conversations/components/conversationLayout.tsx b/static/app/views/explore/conversations/components/conversationLayout.tsx index e21b00d2e3f0..1e04339efeeb 100644 --- a/static/app/views/explore/conversations/components/conversationLayout.tsx +++ b/static/app/views/explore/conversations/components/conversationLayout.tsx @@ -3,9 +3,9 @@ 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 {useOrganization} from 'sentry/utils/useOrganization'; import type {AITraceSpanNode} from 'sentry/views/insights/pages/agents/utils/types'; diff --git a/static/app/views/explore/replays/detail/layout/splitPanel.tsx b/static/app/views/explore/replays/detail/layout/splitPanel.tsx index ae4bcf4027a9..e14dfa9cc4eb 100644 --- a/static/app/views/explore/replays/detail/layout/splitPanel.tsx +++ b/static/app/views/explore/replays/detail/layout/splitPanel.tsx @@ -1,8 +1,9 @@ -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 type {SplitPanelProps} from '@sentry/scraps/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'; @@ -26,18 +27,14 @@ 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 ; + return ; } From 86253151899c1f6f357ebda3bb1e819f0f3cf85f Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 13:29:11 +0200 Subject: [PATCH 2/8] feat(scraps): Redesign SplitPanel with composable API Trade the old prop-config API (`left={{content, default, min, max}}` / `right=...`, plus an `availableSize` prop the caller had to thread in) for a composable shape: {left} {right} The root now self-measures with `useDimensions`, so callers no longer have to thread the container width or height in. The pane that declares `defaultSize` is the sized pane; the other fills. Adds a focusable `` with `role="separator"`, `aria-orientation`, `aria-valuemin/max/now`, and arrow-key resize (Shift for coarse step, Home / End to snap to bounds). Exposes a `useSplitPanelDivider` hook for consumers that need a custom divider visual (e.g. the conversation layout's 1px border line) -- the hook returns the ARIA props, event handlers, and the `isHeld` state. Migrates both consumers: - The conversation layout swaps its `BorderDivider` prop to a small component that uses `useSplitPanelDivider` and renders the line via `border-left` (background tokens cannot reference `border.*` per the scraps token rules). - The Replay layout composes the two panes as children. The `ReplaySplitPanel` wrapper keeps its tracking responsibility but now accepts `children` + `orientation` + `availableSize` instead of the old prop bag, so the layout file builds the panes once instead of constructing two divergent prop objects. Co-Authored-By: Claude --- .../app/components/core/splitPanel/index.tsx | 9 +- .../components/core/splitPanel/splitPanel.mdx | 274 ++++++----- .../core/splitPanel/splitPanel.spec.tsx | 162 +++--- .../components/core/splitPanel/splitPanel.tsx | 464 +++++++++++++----- .../components/conversationLayout.tsx | 54 +- .../replays/detail/layout/replayLayout.tsx | 59 ++- .../replays/detail/layout/splitPanel.tsx | 35 +- 7 files changed, 650 insertions(+), 407 deletions(-) diff --git a/static/app/components/core/splitPanel/index.tsx b/static/app/components/core/splitPanel/index.tsx index 436cc538852c..cba4aaf7ca91 100644 --- a/static/app/components/core/splitPanel/index.tsx +++ b/static/app/components/core/splitPanel/index.tsx @@ -1 +1,8 @@ -export {SplitPanel, type SplitPanelProps} from './splitPanel'; +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 index 1fc31a740097..a8d000057182 100644 --- a/static/app/components/core/splitPanel/splitPanel.mdx +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -1,6 +1,6 @@ --- title: SplitPanel -description: A resizable two-pane layout with a draggable divider, supporting horizontal (left/right) and vertical (top/bottom) orientations. +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: @@ -15,191 +15,193 @@ import * as Storybook from 'sentry/stories'; export const documentation = import('!!type-loader!@sentry/scraps/splitPanel'); -The `SplitPanel` component renders two panes separated by a draggable divider. It supports either a horizontal split (`left` / `right`) or a vertical split (`top` / `bottom`). The sized side accepts `default`, `min`, and `max` constraints; the opposite side fills the remaining space. +`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 -Pass `availableSize` (the measured width or height of the container) along with the sized side's constraints and the other side's content. - -```jsx -, default: 300, min: 200, max: 600}} - right={} -/> +```tsx + + + + + + + + + ``` -## Horizontal Split (left / right) +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`. -The `left` pane is sized; `right` fills the remaining space. +## Horizontal Split + +The first pane is sized; the second fills. - - Left - - ), - default: 240, - min: 120, - max: 480, - }} - right={ + + + + Left + + + + Right - } - /> + + -```jsx -, - default: 240, - min: 120, - max: 480, - }} - right={} -/> +```tsx + + + + + + + + + ``` -## Vertical Split (top / bottom) +## Vertical Split -The `top` pane is sized; `bottom` fills the remaining space. +Use `orientation="vertical"` to split top/bottom. The first pane is still the sized one. - - Top - - ), - default: 100, - min: 60, - max: 180, - }} - bottom={ + + + + Top + + + + Bottom - } - /> + + -```jsx -, - default: 100, - min: 60, - max: 180, - }} - bottom={} -/> +```tsx + + + + + + + + + ``` -## Measuring the container +## Collapsing a Pane -`availableSize` is typically the measured width (for left/right) or height (for top/bottom) of the parent container. Pair `SplitPanel` with `useDimensions` to keep the size in sync with the layout. +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 -import {useRef} from 'react'; - -import {Flex} from '@sentry/scraps/layout'; -import {SplitPanel} from '@sentry/scraps/splitPanel'; + + + + + {!isCollapsed && ( + <> + + + + + + )} + +``` -import {useDimensions} from 'sentry/utils/useDimensions'; +## Persisting Size -function ResizableLayout({left, right}: {left: React.ReactNode; right: React.ReactNode}) { - const ref = useRef(null); - const {width} = useDimensions({elementRef: ref}); +Pass `sizeStorageKey` to persist the user's drag size in `localStorage` across reloads. - return ( - - {width > 0 ? ( - - ) : null} - - ); -} +```tsx + + + + + + + + + ``` -## Collapsing a pane +## Custom Divider -Passing `null` for the unsized side hides the divider and expands the sized pane to fill the full width. The sized pane's DOM identity is preserved across collapses. +Pass an `icon` to replace the default grab handle. The ARIA roles, keyboard handlers, and hit area stay intact. -```jsx -, default: 300, min: 200, max: 600}} - right={isCollapsed ? null : } -/> +```tsx + + + + + } /> + + + + ``` -## Persisting size +For a divider with a different visual treatment (for example, a 1px border line), wrap the `` in a styled component that overrides the appearance. The divider itself is a flex item, so it can be styled in place. -Pass `sizeStorageKey` to persist the user's drag size in `localStorage` across reloads. +## Tracking Resize Events -```jsx +`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 , default: 300, min: 200, max: 600}} - right={} -/> + orientation="horizontal" + onMouseDown={sizePct => trackStart(sizePct)} + onResize={newSize => trackEnd(newSize)} +> + + + + + + + + ``` -## Custom divider - -The default divider shows a grab handle and changes background on hover/drag. Pass a custom `SplitDivider` component to swap in a different appearance (for example, a thin 1px border line). +## Imperative Controls -```jsx -import styled from '@emotion/styled'; +Use the `useSplitPanel` hook from any descendant to maximise, minimise, or reset the sized pane. -const BorderDivider = styled('div')` - width: 1px; - height: 100%; - background: ${p => p.theme.tokens.border.primary}; - cursor: ew-resize; +```tsx +import {useSplitPanel} from '@sentry/scraps/splitPanel'; - &[data-is-held='true'] { - background: ${p => p.theme.tokens.border.accent.moderate}; - } -`; +function PaneToolbar() { + const {maximiseSize, minimiseSize, resetSize, isMaximized, isMinimized} = + useSplitPanel(); -, default: 300, min: 200, max: 600}} - right={} -/>; + return ( + + + + + + ); +} ``` -## Tracking resize events - -`onMouseDown` fires when the user starts dragging (receives the size as a percentage string). `onResize` fires as the size changes (receives the new size in pixels). Use these for analytics or to drive linked UI. +## Accessibility -```jsx - trackStart(sizePct)} - onResize={newSize => trackEnd(newSize)} - left={{content: , default: 300, min: 200, max: 600}} - right={} -/> -``` +- 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 index 18b17ba81031..5c91af8b600e 100644 --- a/static/app/components/core/splitPanel/splitPanel.spec.tsx +++ b/static/app/components/core/splitPanel/splitPanel.spec.tsx @@ -2,117 +2,133 @@ import {render, screen} from 'sentry-test/reactTestingLibrary'; import {SplitPanel} from '@sentry/scraps/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', () => { + describe('horizontal orientation', () => { + it('renders both panels and a divider', () => { render( - right} - /> + + +
left
+
+ + +
right
+
+
); expect(screen.getByTestId('left-content')).toBeInTheDocument(); expect(screen.getByTestId('right-content')).toBeInTheDocument(); + expect(screen.getByRole('separator')).toBeInTheDocument(); }); - it('omits the divider and right pane when right is null', () => { - render(); + it('renders only the first panel when the second is omitted', () => { + render( + + +
left
+
+
+ ); expect(screen.getByTestId('left-content')).toBeInTheDocument(); - expect(screen.queryByTestId('right-content')).not.toBeInTheDocument(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); }); - it('preserves DOM identity of left.content when toggling right between content and null', () => { + it('preserves DOM identity of the sized panel when toggling the fill panel', () => { const {rerender} = render( - right} - /> + + +
left
+
+ + +
right
+
+
); const leftBefore = screen.getByTestId('left-content'); - rerender(); + rerender( + + +
left
+
+
+ ); - const leftAfterCollapse = screen.getByTestId('left-content'); - expect(leftAfterCollapse).toBe(leftBefore); + expect(screen.getByTestId('left-content')).toBe(leftBefore); rerender( - right} - /> + + +
left
+
+ + +
right
+
+
); - const leftAfterExpand = screen.getByTestId('left-content'); - expect(leftAfterExpand).toBe(leftBefore); + expect(screen.getByTestId('left-content')).toBe(leftBefore); }); }); - describe('top/bottom', () => { - it('renders top, divider, and bottom when bottom is provided', () => { + describe('vertical orientation', () => { + it('renders both panels and a divider', () => { render( - bottom} - /> + + +
top
+
+ + +
bottom
+
+
); expect(screen.getByTestId('top-content')).toBeInTheDocument(); expect(screen.getByTestId('bottom-content')).toBeInTheDocument(); + expect(screen.getByRole('separator')).toBeInTheDocument(); }); - it('omits the divider and bottom pane when bottom is null', () => { - render(); + it('renders only the first panel when the second is omitted', () => { + render( + + +
top
+
+
+ ); expect(screen.getByTestId('top-content')).toBeInTheDocument(); - expect(screen.queryByTestId('bottom-content')).not.toBeInTheDocument(); + expect(screen.queryByRole('separator')).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} - /> + describe('divider accessibility', () => { + it('exposes separator role with orientation and value attributes', () => { + render( + + +
left
+
+ + +
right
+
+
); - const topAfterExpand = screen.getByTestId('top-content'); - expect(topAfterExpand).toBe(topBefore); + 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 index 4b8ef0032d3b..96689b3809f4 100644 --- a/static/app/components/core/splitPanel/splitPanel.tsx +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -1,131 +1,235 @@ -import {createContext, Fragment, useCallback, useMemo} from 'react'; +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useMemo, + useRef, +} from 'react'; import styled from '@emotion/styled'; -import {Stack} from '@sentry/scraps/layout'; - import {IconGrabbable} from 'sentry/icons'; +import {useDimensions} from 'sentry/utils/useDimensions'; import {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; -type DividerProps = { - 'data-is-held': boolean; - 'data-slide-direction': 'leftright' | 'updown'; +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; - icon?: React.ReactNode; -} & React.DOMAttributes; - -const BaseSplitDivider = styled(({icon, ...props}: DividerProps) => ( -
{icon || }
-))` - display: grid; - place-items: center; - height: 100%; - width: 100%; + orientation: Orientation; + resetSize: () => void; + size: number; +}; - user-select: inherit; - background: inherit; +const SplitPanelContext = createContext(null); - &:hover { - background: ${p => p.theme.tokens.interactive.transparent.neutral.background.hover}; +function useSplitPanelContext(component: string): SplitPanelContextValue { + const ctx = useContext(SplitPanelContext); + if (!ctx) { + throw new Error(`${component} must be rendered inside `); } - &[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}; + return ctx; +} - & > svg { - transform: rotate(90deg); - } - } -`; +/** + * 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}; +} -const SplitPanelContext = createContext({ - isMaximized: false, - isMinimized: false, - maximiseSize: () => {}, - minimiseSize: () => {}, - resetSize: () => {}, -}); - -type Side = { - content: React.ReactNode; - default: number; - max: number; - min: number; -}; +/** + * 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, + }, + }; +} -type CommonProps = { - availableSize: number; - SplitDivider?: React.ComponentType; +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. + */ onResize?: (newSize: number) => void; + /** + * Layout direction. `horizontal` splits left/right; `vertical` splits + * top/bottom. + */ + orientation?: Orientation; + /** + * Persist the user's drag size in `localStorage` under this key so it + * survives reloads. + */ 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 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 = { + /** + * Replace the default grab icon. The divider element itself (ARIA roles, + * keyboard handlers, hit area) is unchanged. + */ + icon?: React.ReactNode; +}; + +function Panel({children}: SplitPanelPanelProps) { + const {isReady, orientation, size} = useSplitPanelContext('SplitPanel.Panel'); + const isSized = useIsSizedPanel(); + + return ( + + {children} + ); +} -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 IsSizedPanelContext = createContext(false); +function useIsSizedPanel() { + return useContext(IsSizedPanelContext); +} + +function Divider({icon}: SplitPanelDividerProps) { + const {isHeld, max, min, onDoubleClick, onKeyDown, onMouseDown, orientation, size} = + useSplitPanelContext('SplitPanel.Divider'); + + return ( + + {icon ?? } + + ); +} + +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', + sizeStorageKey, + 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 max = sizedProps?.maxSize ?? Number.POSITIVE_INFINITY; + const initialSize = sizedProps?.defaultSize ?? 0; const { isHeld, onDoubleClick, onMouseDown: onDragStart, - size: containerSize, setSize, + size: containerSize, } = useResizableDrawer({ - direction: isLeftRight ? 'left' : 'down', + direction: orientation === 'horizontal' ? 'left' : 'down', initialSize, min, onResize: onResize ?? (() => {}), sizeStorageKey, }); - const sizePct = `${(Math.min(containerSize, max) / availableSize) * 100}%` as const; + const clampedSize = Math.min(containerSize, max); + const sizePct = `${ + availableSize > 0 ? (clampedSize / availableSize) * 100 : 0 + }%` as const; const handleMouseDown = useCallback( (event: React.MouseEvent) => { @@ -135,71 +239,165 @@ export function SplitPanel(props: SplitPanelProps) { [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( + 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, }), - [isMaximized, isMinimized, setSize, max, min, initialSize] + [ + clampedSize, + handleKeyDown, + handleMouseDown, + initialSize, + isHeld, + isMaximized, + isMinimized, + isReady, + max, + min, + onDoubleClick, + orientation, + setSize, + ] ); - 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; + // 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 ( - - + - - {a.content} - - {isCollapsed ? null : ( - - - - {b} - - - )} - - + {wrappedChildren} + + ); } -const SplitPanelContainer = styled('div')<{ - orientation: 'rows' | 'columns'; - size: `${number}px` | `${number}%`; -}>` +SplitPanel.Panel = Panel; +SplitPanel.Divider = Divider; + +const SplitPanelRoot = styled('div')` + position: relative; + display: flex; min-height: 0; min-width: 0; flex-grow: 1; - position: relative; - display: grid; - grid-template-${p => p.orientation}: ${p => p.size} auto 1fr; + &[data-orientation='horizontal'] { + flex-direction: row; + } + &[data-orientation='vertical'] { + flex-direction: column; + } /* - * This is more specific, with && than the foundational rule: - * &[data-inspectable='true'] .replayer-wrapper > iframe + * 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 DividerHandle = styled('div')` + display: grid; + place-items: center; + flex-shrink: 0; + user-select: inherit; + background: inherit; + + &:focus-visible { + outline: 2px solid ${p => p.theme.tokens.focus.default}; + outline-offset: -2px; + } + &: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-orientation='horizontal'] { + cursor: ew-resize; + height: 100%; + width: ${p => p.theme.space.xl}; + } + &[data-orientation='vertical'] { + cursor: ns-resize; + width: 100%; + height: ${p => p.theme.space.xl}; + + & > svg { + transform: rotate(90deg); + } + } +`; diff --git a/static/app/views/explore/conversations/components/conversationLayout.tsx b/static/app/views/explore/conversations/components/conversationLayout.tsx index 1e04339efeeb..68e8f29c958e 100644 --- a/static/app/views/explore/conversations/components/conversationLayout.tsx +++ b/static/app/views/explore/conversations/components/conversationLayout.tsx @@ -3,7 +3,7 @@ import {useRef} from 'react'; import styled from '@emotion/styled'; import {Container, Flex} from '@sentry/scraps/layout'; -import {SplitPanel} from '@sentry/scraps/splitPanel'; +import {SplitPanel, useSplitPanelDivider} from '@sentry/scraps/splitPanel'; import {Placeholder} from 'sentry/components/placeholder'; import {useDimensions} from 'sentry/utils/useDimensions'; @@ -19,23 +19,19 @@ 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; +function BorderDivider() { + const {props, isHeld, orientation} = useSplitPanelDivider(); + return ( + + ); +} + +const BorderDividerLine = styled('div')` + width: 0; height: 100%; position: relative; user-select: none; - background: ${p => p.theme.tokens.border.primary}; + border-left: ${DIVIDER_WIDTH}px solid ${p => p.theme.tokens.border.primary}; /* Invisible wider hit area for dragging */ &::before { @@ -50,7 +46,7 @@ const BorderDivider = styled( } &[data-is-held='true'] { - background: ${p => p.theme.tokens.border.accent.moderate}; + border-left-color: ${p => p.theme.tokens.border.accent.moderate}; } `; @@ -71,7 +67,6 @@ 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, @@ -80,20 +75,17 @@ export function ConversationSplitLayout({ 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 f960b6683766..be8581b8f503 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 e14dfa9cc4eb..b71dec9420cb 100644 --- a/static/app/views/explore/replays/detail/layout/splitPanel.tsx +++ b/static/app/views/explore/replays/detail/layout/splitPanel.tsx @@ -1,7 +1,6 @@ import {useMemo} from 'react'; import debounce from 'lodash/debounce'; -import type {SplitPanelProps} from '@sentry/scraps/splitPanel'; import {SplitPanel} from '@sentry/scraps/splitPanel'; import {trackAnalytics} from 'sentry/utils/analytics'; @@ -9,15 +8,25 @@ 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, @@ -36,5 +45,13 @@ export function ReplaySplitPanel({ [logEndPosition, availableSize] ); - return ; + return ( + + {children} + + ); } From a85471ece7efdb816f49f3f552a27d812a68616e Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 13:36:35 +0200 Subject: [PATCH 3/8] ref(scraps): Drop sizeStorageKey from SplitPanel `SplitPanel` is a layout primitive; persistence is behavior and composes from `defaultSize` + `onResize`. Chakra's Splitter takes the same line -- no built-in storage prop -- so this matches the precedent for splitter primitives in modern design systems. The conversation layout (the only consumer that used the prop) now calls `useLocalStorageState` itself and threads the value in via `defaultSize` / `onResize`. ~3 lines extra at the call site, no behavior change. Co-Authored-By: Claude --- .../components/core/splitPanel/splitPanel.mdx | 28 ++++++++++++------- .../components/core/splitPanel/splitPanel.tsx | 11 ++------ .../components/conversationLayout.tsx | 7 +++-- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx index a8d000057182..28703689310f 100644 --- a/static/app/components/core/splitPanel/splitPanel.mdx +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -123,18 +123,26 @@ To collapse the fill pane, omit the `` and second ` - - - - - - - - +import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; + +function ResizableLayout() { + const [storedSize, setStoredSize] = useLocalStorageState('my-feature.split-size', 300); + + return ( + + + + + + + + + + ); +} ``` ## Custom Divider diff --git a/static/app/components/core/splitPanel/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx index 96689b3809f4..3213c232691d 100644 --- a/static/app/components/core/splitPanel/splitPanel.tsx +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -91,7 +91,9 @@ export type SplitPanelProps = { */ onMouseDown?: (sizePct: `${number}%`) => void; /** - * Fires as the user drags. Receives the new size in pixels. + * 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; /** @@ -99,11 +101,6 @@ export type SplitPanelProps = { * top/bottom. */ orientation?: Orientation; - /** - * Persist the user's drag size in `localStorage` under this key so it - * survives reloads. - */ - sizeStorageKey?: string; }; export type SplitPanelPanelProps = { @@ -199,7 +196,6 @@ function findSizedPanelProps(children: React.ReactNode): SplitPanelPanelProps | export function SplitPanel({ children, orientation = 'horizontal', - sizeStorageKey, onMouseDown, onResize, }: SplitPanelProps) { @@ -223,7 +219,6 @@ export function SplitPanel({ initialSize, min, onResize: onResize ?? (() => {}), - sizeStorageKey, }); const clampedSize = Math.min(containerSize, max); diff --git a/static/app/views/explore/conversations/components/conversationLayout.tsx b/static/app/views/explore/conversations/components/conversationLayout.tsx index 68e8f29c958e..d33fc80cf1aa 100644 --- a/static/app/views/explore/conversations/components/conversationLayout.tsx +++ b/static/app/views/explore/conversations/components/conversationLayout.tsx @@ -7,6 +7,7 @@ import {SplitPanel, useSplitPanelDivider} from '@sentry/scraps/splitPanel'; import {Placeholder} from 'sentry/components/placeholder'; 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'; @@ -73,11 +74,13 @@ export function ConversationSplitLayout({ Math.max(LEFT_PANEL_MIN, (width - DIVIDER_WIDTH) * 0.5) ); + const [storedSize, setStoredSize] = useLocalStorageState(sizeStorageKey, defaultLeft); + return ( - + From 62c2a390345839daaee198a5ee6f49205e65979e Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 13:52:05 +0200 Subject: [PATCH 4/8] ref(scraps): Make SplitPanel.Divider a thin line by default Switch the default `` from a grab-handle (with `IconGrabbable`) to a thin 1px line, matching the trace-drawer / conversations house style. The line uses a wider invisible hit area (`::before`) for comfortable dragging, and changes color on hover and while held via `border.accent.moderate`. Conversations no longer needs its custom `BorderDivider` -- the default now renders the same look, so it drops the custom component and uses `` directly. The `useSplitPanelDivider` hook stays exported for consumers that genuinely need a different visual (e.g. a grab-handle in a feature where extra affordance is wanted) -- documented in the mdx with a worked example. Also makes `SplitPanelRoot` self-fill (`height: 100%; width: 100%`) so the component works in block parents too. The mdx demos wrap the SplitPanel in `` instead of `` so the demos render at the full container height, and both panes in the demos now use `background="primary"` for a uniform look. Co-Authored-By: Claude --- .../components/core/splitPanel/splitPanel.mdx | 45 ++++++----- .../components/core/splitPanel/splitPanel.tsx | 81 +++++++++++-------- .../components/conversationLayout.tsx | 40 +-------- 3 files changed, 76 insertions(+), 90 deletions(-) diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx index 28703689310f..2f59b976c9b6 100644 --- a/static/app/components/core/splitPanel/splitPanel.mdx +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -7,7 +7,7 @@ resources: js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/splitPanel/splitPanel.tsx --- -import {Container, Flex} from '@sentry/scraps/layout'; +import {Flex} from '@sentry/scraps/layout'; import {SplitPanel} from '@sentry/scraps/splitPanel'; import {Text} from '@sentry/scraps/text'; @@ -38,10 +38,10 @@ The composable shape makes the call site read top-to-bottom: first pane, divider The first pane is sized; the second fills. - + - + Left @@ -52,7 +52,7 @@ The first pane is sized; the second fills. - + ```tsx @@ -72,10 +72,10 @@ The first pane is sized; the second fills. Use `orientation="vertical"` to split top/bottom. The first pane is still the sized one. - + - + Top @@ -86,7 +86,7 @@ Use `orientation="vertical"` to split top/bottom. The first pane is still the si - + ```tsx @@ -147,21 +147,30 @@ function ResizableLayout() { ## Custom Divider -Pass an `icon` to replace the default grab handle. The ARIA roles, keyboard handlers, and hit area stay intact. +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; +`; ``` -For a divider with a different visual treatment (for example, a 1px border line), wrap the `` in a styled component that overrides the appearance. The divider itself is a flex item, so it can be styled in place. +Then drop your component in place of `` inside the `` children. ## Tracking Resize Events diff --git a/static/app/components/core/splitPanel/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx index 3213c232691d..b5e0ba9ea6cf 100644 --- a/static/app/components/core/splitPanel/splitPanel.tsx +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -9,7 +9,6 @@ import { } from 'react'; import styled from '@emotion/styled'; -import {IconGrabbable} from 'sentry/icons'; import {useDimensions} from 'sentry/utils/useDimensions'; import {useResizableDrawer} from 'sentry/utils/useResizableDrawer'; @@ -123,13 +122,7 @@ export type SplitPanelPanelProps = { minSize?: number; }; -export type SplitPanelDividerProps = { - /** - * Replace the default grab icon. The divider element itself (ARIA roles, - * keyboard handlers, hit area) is unchanged. - */ - icon?: React.ReactNode; -}; +export type SplitPanelDividerProps = Record; function Panel({children}: SplitPanelPanelProps) { const {isReady, orientation, size} = useSplitPanelContext('SplitPanel.Panel'); @@ -154,12 +147,12 @@ function useIsSizedPanel() { return useContext(IsSizedPanelContext); } -function Divider({icon}: SplitPanelDividerProps) { +function Divider() { const {isHeld, max, min, onDoubleClick, onKeyDown, onMouseDown, orientation, size} = useSplitPanelContext('SplitPanel.Divider'); return ( - - {icon ?? } - + /> ); } @@ -334,6 +325,8 @@ SplitPanel.Divider = Divider; const SplitPanelRoot = styled('div')` position: relative; display: flex; + width: 100%; + height: 100%; min-height: 0; min-width: 0; flex-grow: 1; @@ -362,37 +355,57 @@ const PanelContainer = styled('div')<{sizePx: number | undefined}>` ${p => (p.sizePx === undefined ? 'flex: 1 1 0;' : `flex: 0 0 ${p.sizePx}px;`)} `; -const DividerHandle = styled('div')` - display: grid; - place-items: center; +const DividerLine = styled('div')` + position: relative; flex-shrink: 0; - user-select: inherit; - background: inherit; + user-select: none; - &:focus-visible { - outline: 2px solid ${p => p.theme.tokens.focus.default}; - outline-offset: -2px; - } - &: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}; + /* Invisible wider hit area for dragging */ + &::before { + content: ''; + position: absolute; + z-index: 1; } &[data-orientation='horizontal'] { - cursor: ew-resize; + width: 0; height: 100%; - width: ${p => p.theme.space.xl}; + 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'] { - cursor: ns-resize; width: 100%; - height: ${p => p.theme.space.xl}; + height: 0; + cursor: ns-resize; + border-top: 1px solid ${p => p.theme.tokens.border.primary}; - & > svg { - transform: rotate(90deg); + &::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/views/explore/conversations/components/conversationLayout.tsx b/static/app/views/explore/conversations/components/conversationLayout.tsx index d33fc80cf1aa..f35e229c5055 100644 --- a/static/app/views/explore/conversations/components/conversationLayout.tsx +++ b/static/app/views/explore/conversations/components/conversationLayout.tsx @@ -1,9 +1,8 @@ import type React from 'react'; import {useRef} from 'react'; -import styled from '@emotion/styled'; import {Container, Flex} from '@sentry/scraps/layout'; -import {SplitPanel, useSplitPanelDivider} from '@sentry/scraps/splitPanel'; +import {SplitPanel} from '@sentry/scraps/splitPanel'; import {Placeholder} from 'sentry/components/placeholder'; import {useDimensions} from 'sentry/utils/useDimensions'; @@ -16,41 +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. - */ -function BorderDivider() { - const {props, isHeld, orientation} = useSplitPanelDivider(); - return ( - - ); -} - -const BorderDividerLine = styled('div')` - width: 0; - height: 100%; - position: relative; - user-select: none; - border-left: ${DIVIDER_WIDTH}px solid ${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'] { - border-left-color: ${p => p.theme.tokens.border.accent.moderate}; - } -`; - /** * Resizable two-column layout for conversation views. * Left panel holds messages/spans, right panel holds span details. @@ -86,7 +50,7 @@ export function ConversationSplitLayout({ > {left} - + {right} From dbabe4d6e3a108fbf39e471c3d7950934618e0ad Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 14:07:35 +0200 Subject: [PATCH 5/8] ref(scraps): Cap SplitPanel sized pane at the container size Matches Zag/Chakra's splitter: `maxSize` defaults to "the container size" (100% in their percentage model), so a panel can never grow beyond its parent. We were defaulting to `Infinity`, which let `useResizableDrawer`'s drag accumulate past the container and produce overflow glitches. Explicit `maxSize` still wins -- the cap is `min(maxSize, availableSize)` once the container is measured. Pre-measurement, the cap is just the explicit max so the hook can accept the initial size without clamping it to zero on the very first render. Also drops the artificial `maxSize` from the mdx demos so the user can drag the sized pane to fill the container, instead of bottoming out at an arbitrary demo value. Co-Authored-By: Claude --- static/app/components/core/splitPanel/splitPanel.mdx | 4 ++-- static/app/components/core/splitPanel/splitPanel.tsx | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx index 2f59b976c9b6..e4ee939b7c7f 100644 --- a/static/app/components/core/splitPanel/splitPanel.mdx +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -40,7 +40,7 @@ The first pane is sized; the second fills. - + Left @@ -74,7 +74,7 @@ Use `orientation="vertical"` to split top/bottom. The first pane is still the si - + Top diff --git a/static/app/components/core/splitPanel/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx index b5e0ba9ea6cf..62223360bdec 100644 --- a/static/app/components/core/splitPanel/splitPanel.tsx +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -196,7 +196,12 @@ export function SplitPanel({ const sizedProps = useMemo(() => findSizedPanelProps(children), [children]); const min = sizedProps?.minSize ?? 0; - const max = sizedProps?.maxSize ?? Number.POSITIVE_INFINITY; + 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; const { From 4415c99742b2f62ba4da4926fdc2768a616c1fcf Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 14:10:36 +0200 Subject: [PATCH 6/8] docs(scraps): Start SplitPanel horizontal demo at 50/50 Container is 600px; pick defaultSize=300 so both panes start equal. Co-Authored-By: Claude --- static/app/components/core/splitPanel/splitPanel.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx index e4ee939b7c7f..be3671203912 100644 --- a/static/app/components/core/splitPanel/splitPanel.mdx +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -40,7 +40,7 @@ The first pane is sized; the second fills. - + Left @@ -57,7 +57,7 @@ The first pane is sized; the second fills. ```tsx - + From e7e290479f1d7c32e28f7207800dfc07887561bb Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 14:13:49 +0200 Subject: [PATCH 7/8] docs(scraps): Add live percentage indicator to SplitPanel demos Both the horizontal and vertical demos now start at 50/50 and render a `Left: X% | Right: Y%` (or `Top` / `Bottom`) line that updates as the user drags the divider. Demonstrates `onResize` end-to-end: SplitPanel passes the new pixel size; the demo divides by the known container size and re-renders the indicator. Each demo is a local `export function` in the mdx so the state lives inside the component -- no separate demos file needed. Co-Authored-By: Claude --- .../components/core/splitPanel/splitPanel.mdx | 104 ++++++++++++------ 1 file changed, 73 insertions(+), 31 deletions(-) diff --git a/static/app/components/core/splitPanel/splitPanel.mdx b/static/app/components/core/splitPanel/splitPanel.mdx index be3671203912..62dae652f4bb 100644 --- a/static/app/components/core/splitPanel/splitPanel.mdx +++ b/static/app/components/core/splitPanel/splitPanel.mdx @@ -7,6 +7,8 @@ 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'; @@ -15,6 +17,74 @@ 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 @@ -38,21 +108,7 @@ The composable shape makes the call site read top-to-bottom: first pane, divider The first pane is sized; the second fills. - - - - - Left - - - - - - Right - - - - + ```tsx @@ -72,26 +128,12 @@ The first pane is sized; the second fills. Use `orientation="vertical"` to split top/bottom. The first pane is still the sized one. - - - - - Top - - - - - - Bottom - - - - + ```tsx - + From 2605e6d6eeb74afcc733187ec61d022bfacaf2f3 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Wed, 27 May 2026 14:22:13 +0200 Subject: [PATCH 8/8] fix(scraps): Clamp SplitPanel onResize to [min, max] `useResizableDrawer` only enforces `min`, not `max`, so its internal size could drift past the cap while the user kept dragging -- and `onResize` fired with that raw out-of-range value. The visible panel stayed at the cap (because the component renders `min(size, max)`), but consumer callbacks saw values exceeding `availableSize`, which is why the demo's `Right: Y%` indicator could flip negative. Wraps the hook's `onResize` to clamp before forwarding, and snaps the hook's internal state back when it drifts. Snapping back also fixes a UX quirk: previously, dragging far past the cap and then back didn't produce visible motion until the cursor caught back up to the drifted internal value. Co-Authored-By: Claude --- .../components/core/splitPanel/splitPanel.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/static/app/components/core/splitPanel/splitPanel.tsx b/static/app/components/core/splitPanel/splitPanel.tsx index 62223360bdec..d036026e953d 100644 --- a/static/app/components/core/splitPanel/splitPanel.tsx +++ b/static/app/components/core/splitPanel/splitPanel.tsx @@ -204,6 +204,23 @@ export function SplitPanel({ 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, @@ -214,8 +231,9 @@ export function SplitPanel({ direction: orientation === 'horizontal' ? 'left' : 'down', initialSize, min, - onResize: onResize ?? (() => {}), + onResize: handleHookResize, }); + setSizeRef.current = setSize; const clampedSize = Math.min(containerSize, max); const sizePct = `${