Skip to content

feat(scraps): Adopt SplitPanel with composable API redesign#116274

Draft
priscilawebdev wants to merge 8 commits into
masterfrom
priscilawebdev/feat/scraps-adopt-splitpanel
Draft

feat(scraps): Adopt SplitPanel with composable API redesign#116274
priscilawebdev wants to merge 8 commits into
masterfrom
priscilawebdev/feat/scraps-adopt-splitpanel

Conversation

@priscilawebdev
Copy link
Copy Markdown
Member

@priscilawebdev priscilawebdev commented May 27, 2026

Adopts SplitPanel into @sentry/scraps/splitPanel and redesigns its API in the same change. The old prop-config shape was clunky enough at the call site (every caller had to thread availableSize in by hand and pass a left/right/top/bottom config object) that promoting it as-is would have locked the suboptimal shape into the design system. Better to redesign now, while there are only two consumers.

Two precedents in the same vein: #113489 (feat(scraps): adopt GlobalDrawer component into design system) collapsed boolean props into mode and dropped unused overrides during its promotion, for the same reason.

The new API

<SplitPanel orientation="horizontal" sizeStorageKey="conv-split">
  <SplitPanel.Panel defaultSize={300} minSize={200} maxSize={600}>
    <LeftContent />
  </SplitPanel.Panel>
  <SplitPanel.Divider />
  <SplitPanel.Panel>
    <RightContent />
  </SplitPanel.Panel>
</SplitPanel>
  • Composable<SplitPanel.Panel> and <SplitPanel.Divider> as subcomponents instead of left={{...}} right={...} prop bags.
  • Self-measuring — the root attaches the ref to its own outer element and uses useDimensions internally. Callers drop the useRef + useDimensions + width > 0 gate pattern.
  • A11y baked in<SplitPanel.Divider> renders with role=\"separator\", aria-orientation, aria-valuemin/max/now, and arrow-key resize (Shift for a coarser step, Home / End to snap to bounds).
  • Custom dividers via useSplitPanelDivider — for the 1px-line look the conversation layout wants, the consumer renders its own styled element and spreads props from the hook (ARIA + event handlers + tabIndex + role).
  • Imperative controls via useSplitPanel — descendants can call maximiseSize / minimiseSize / resetSize and read isMaximized / isMinimized.
  • Breaking change — no shim for the old API. Both real consumers are migrated in this PR.

Scope decisions

  • 2-panel only. Both consumers are 2-panel; N-panel adds size-redistribution math, ResizeTrigger IDs, and per-panel size tracking with no current consumer to validate against. The composable shape keeps the door open for N-panel later without rewriting the surface.
  • Keyboard resize on the divider. Arrow keys nudge by 10px (Shift → 50px); Home / End snap to minSize / maxSize. Standard separator a11y pattern.
  • availableSize survives only as an internal detail of the ReplaySplitPanel wrapper, where it's used to compute the resize end position as a percentage for analytics. The Replay layout already measures itself for its own grid, so threading the measurement avoids a redundant second useDimensions.

Verification

  • pnpm lint:js (touched files): clean
  • pnpm typecheck: clean
  • pnpm test-ci splitPanel.spec.tsx: 6 / 6 (covers horizontal and vertical orientations, collapse-to-fill behavior with DOM-identity preservation across rerenders, and the divider's ARIA attributes)

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<HTMLElement>` so the moved file passes the stricter
type-aware rules applied inside `static/app/components/core/`.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the Scope: Frontend Automatically applied to PRs that change frontend components label May 27, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 27, 2026

📊 Type Coverage Diff

Metric Before After Delta
Coverage 93.58% 93.58% ±0%
Typed 133,106 133,150 🟢 +44
Untyped 9,131 9,131 ±0
🔍 4 new type safety issues introduced

any-typed symbols (2 new)

File Line Detail
static/app/components/core/splitPanel/splitPanel.tsx 173 child (param)
static/app/components/core/splitPanel/splitPanel.tsx 316 child (param)

Type assertions (as) (2 new)

File Line Detail
static/app/components/core/splitPanel/splitPanel.tsx 178 as SplitPanelPanelPropschild.props as SplitPanelPanelProps
static/app/components/core/splitPanel/splitPanel.tsx 318 as SplitPanelPanelPropschild.props as SplitPanelPanelProps

This is informational only and does not block the PR.

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:

    <SplitPanel orientation="horizontal" sizeStorageKey="...">
      <SplitPanel.Panel defaultSize={300} minSize={200} maxSize={600}>
        {left}
      </SplitPanel.Panel>
      <SplitPanel.Divider />
      <SplitPanel.Panel>{right}</SplitPanel.Panel>
    </SplitPanel>

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 `<SplitPanel.Divider>` 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 <noreply@anthropic.com>
@priscilawebdev priscilawebdev changed the title feat(scraps): Adopt SplitPanel component into design system feat(scraps): Adopt SplitPanel with composable API redesign May 27, 2026
`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 <noreply@anthropic.com>
Switch the default `<SplitPanel.Divider>` 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 `<SplitPanel.Divider>` 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 `<Flex>` instead of `<Container>` 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 <noreply@anthropic.com>
priscilawebdev and others added 3 commits May 27, 2026 14:07
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 <noreply@anthropic.com>
Container is 600px; pick defaultSize=300 so both panes start equal.

Co-Authored-By: Claude <noreply@anthropic.com>
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 <noreply@anthropic.com>
`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 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant