diff --git a/apps/docs/src/components/playground/panel-resize.browser.test.tsx b/apps/docs/src/components/playground/panel-resize.browser.test.tsx
new file mode 100644
index 00000000..f883176e
--- /dev/null
+++ b/apps/docs/src/components/playground/panel-resize.browser.test.tsx
@@ -0,0 +1,92 @@
+import '../../styles/app.css';
+import { act } from 'react';
+import type { Root } from 'react-dom/client';
+import { createRoot } from 'react-dom/client';
+import { Group, Panel, Separator } from 'react-resizable-panels';
+import { afterEach, expect, test } from 'vite-plus/test';
+import { commands, page } from 'vite-plus/test/context';
+import { RESIZE_TARGET_MINIMUM_SIZE } from './resize-target';
+
+const GROUP_WIDTH = 600;
+const GROUP_HEIGHT = 300;
+
+let container: HTMLElement | undefined;
+let root: Root | undefined;
+
+afterEach(() => {
+ if (root) act(() => root?.unmount());
+ container?.remove();
+ container = undefined;
+ root = undefined;
+});
+
+test('resizes when the drag starts at the edge of the pointer hit target', async () => {
+ // Match the iframe's content viewport to the harness size below so
+ // Playwright's page-space coordinates map 1:1 onto the separator's own
+ // coordinate system (otherwise the harness page can be scaled to fit the
+ // runner viewport, throwing off the pixel-precise offsets this test relies
+ // on).
+ await page.viewport(GROUP_WIDTH, GROUP_HEIGHT);
+ const firstPanel = renderLayout();
+ const widthBefore = firstPanel.current?.getBoundingClientRect().width ?? 0;
+
+ // 7px out from the separator's centre: inside the 16px band we configure,
+ // outside the 10px the library defaults to. Deliberately a literal rather
+ // than derived from RESIZE_TARGET_MINIMUM_SIZE — a derived offset would
+ // shrink along with the band and pass no matter how narrow it got.
+ await commands.dragFromSeparator(7, -100);
+
+ await expect
+ .poll(() => firstPanel.current?.getBoundingClientRect().width ?? 0)
+ .toBeLessThan(widthBefore - 50);
+});
+
+test('does not resize when the drag starts outside the pointer hit target', async () => {
+ await page.viewport(GROUP_WIDTH, GROUP_HEIGHT);
+ const firstPanel = renderLayout();
+ const widthBefore = firstPanel.current?.getBoundingClientRect().width ?? 0;
+
+ // 12px out, past the edge of the band. Pins the upper bound so the grab
+ // area cannot quietly swallow clicks meant for the panes either side.
+ await commands.dragFromSeparator(12, -100);
+
+ const widthAfter = firstPanel.current?.getBoundingClientRect().width ?? 0;
+ expect(Math.abs(widthAfter - widthBefore)).toBeLessThan(3);
+});
+
+function renderLayout() {
+ container = document.body.appendChild(document.createElement('div'));
+ root = createRoot(container);
+
+ const firstPanel: { current: HTMLDivElement | null } = { current: null };
+
+ act(() => {
+ root?.render();
+ });
+
+ return firstPanel;
+}
+
+function Harness({ firstPanelRef }: { firstPanelRef: { current: HTMLDivElement | null } }) {
+ return (
+
+ {
+ firstPanelRef.current = element;
+ }}
+ minSize={80}
+ />
+
+
+
+ );
+}
diff --git a/apps/docs/src/components/playground/playground-layout.browser.test.ts b/apps/docs/src/components/playground/playground-layout.browser.test.ts
index 09561226..83d453fd 100644
--- a/apps/docs/src/components/playground/playground-layout.browser.test.ts
+++ b/apps/docs/src/components/playground/playground-layout.browser.test.ts
@@ -15,7 +15,6 @@ test('uses the desktop panel layout before JavaScript corrects the server orient
const separator = renderPanelLayout('column');
expect(getComputedStyle(group as HTMLDivElement).flexDirection).toBe('row');
- expect(getComputedStyle(separator).cursor).toBe('col-resize');
expect(getComputedStyle(separator).inlineSize).toBe('1px');
expect(getComputedStyle(separator).blockSize).toBe('400px');
expect(getComputedStyle(separator, '::after').width).toBe('6px');
@@ -27,7 +26,6 @@ test('uses the mobile panel layout independently of the JavaScript orientation',
const separator = renderPanelLayout('row');
expect(getComputedStyle(group as HTMLDivElement).flexDirection).toBe('column');
- expect(getComputedStyle(separator).cursor).toBe('row-resize');
expect(getComputedStyle(separator).inlineSize).toBe('390px');
expect(getComputedStyle(separator).blockSize).toBe('1px');
expect(getComputedStyle(separator, '::after').width).toBe('64px');
@@ -41,7 +39,7 @@ function renderPanelLayout(inlineDirection: 'column' | 'row') {
const separator = document.createElement('div');
separator.className =
- "relative z-10 shrink-0 [block-size:1px] [inline-size:auto] cursor-row-resize bg-fd-border before:absolute before:[inset-block:-0.5rem] before:[inset-inline:0] before:content-[''] after:absolute after:[block-size:0.375rem] after:[inline-size:4rem] after:rounded-full after:bg-fd-border after:transition-colors after:-translate-x-1/2 after:-translate-y-1/2 after:inset-bs-[50%] after:inset-s-[50%] after:content-[''] data-[separator=active]:after:bg-fd-muted-foreground/80 data-[separator=focus]:after:bg-fd-muted-foreground/80 data-[separator=hover]:after:bg-fd-muted-foreground/65 md:[block-size:auto] md:[inline-size:1px] md:cursor-col-resize md:before:[inset-block:0] md:before:[inset-inline:-0.5rem] md:after:[block-size:4rem] md:after:[inline-size:0.375rem]";
+ "relative z-10 shrink-0 [block-size:1px] [inline-size:auto] bg-fd-border after:absolute after:[block-size:0.375rem] after:[inline-size:4rem] after:rounded-full after:bg-fd-border after:transition-colors after:-translate-x-1/2 after:-translate-y-1/2 after:inset-bs-[50%] after:inset-s-[50%] after:content-[''] data-[separator=active]:after:bg-fd-muted-foreground/80 data-[separator=focus]:after:bg-fd-muted-foreground/80 data-[separator=hover]:after:bg-fd-muted-foreground/65 md:[block-size:auto] md:[inline-size:1px] md:after:[block-size:4rem] md:after:[inline-size:0.375rem]";
group.append(separator);
document.body.append(group);
diff --git a/apps/docs/src/components/playground/resize-target.ts b/apps/docs/src/components/playground/resize-target.ts
new file mode 100644
index 00000000..3c6dc8bc
--- /dev/null
+++ b/apps/docs/src/components/playground/resize-target.ts
@@ -0,0 +1,10 @@
+/**
+ * The pointer hit target for the panel separator, in pixels. The separator
+ * itself is 1px wide, so react-resizable-panels pads this out around it.
+ * The library owns hit-testing at the document level, which makes this the
+ * only place the grab band is set — a CSS hit area here would not agree with
+ * it. The library's 10px fine-pointer default is too narrow to hit comfortably
+ * with a mouse; `coarse` matches its default and is only spelled out because
+ * the prop takes both.
+ */
+export const RESIZE_TARGET_MINIMUM_SIZE = { coarse: 20, fine: 16 };
diff --git a/apps/docs/src/lib/monaco-setup.ts b/apps/docs/src/lib/monaco-setup.ts
index 231664ea..aa865eb9 100644
--- a/apps/docs/src/lib/monaco-setup.ts
+++ b/apps/docs/src/lib/monaco-setup.ts
@@ -24,6 +24,14 @@ self.MonacoEnvironment = {
loader.config({ monaco });
+// Monaco binds Ctrl/Cmd+L to `expandLineSelection` and preventDefaults it,
+// which stops the browser shortcut for focusing the address bar. A playground
+// is not an IDE — give the shortcut back to the browser.
+monaco.editor.addKeybindingRule({
+ command: null,
+ keybinding: monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyL,
+});
+
/**
* The official Catppuccin palette mapped onto Monaco's coarser token
* vocabulary per the Catppuccin style guide (there is no official Monaco
diff --git a/apps/docs/src/routes/__root.tsx b/apps/docs/src/routes/__root.tsx
index 08f66581..439c81df 100644
--- a/apps/docs/src/routes/__root.tsx
+++ b/apps/docs/src/routes/__root.tsx
@@ -77,7 +77,7 @@ function RootDocument({ children }: { children: ReactNode }) {
{children}
diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx
index 6b3cc252..ef11fa20 100644
--- a/apps/docs/src/routes/playground/index.tsx
+++ b/apps/docs/src/routes/playground/index.tsx
@@ -10,6 +10,7 @@ import {
LoadingPill,
} from '../../components/playground/editor-skeleton';
import { PreviewToolbar } from '../../components/playground/preview-toolbar';
+import { RESIZE_TARGET_MINIMUM_SIZE } from '../../components/playground/resize-target';
import { useIsDesktop } from '../../components/playground/use-is-desktop';
import type { ViewportWidth } from '../../components/playground/viewport-toggle';
import { SiteNav } from '../../components/site-nav.js';
@@ -142,6 +143,7 @@ function Playground() {
{/* Pane backgrounds match the Catppuccin Latte/Mocha `editor.background` values in monaco-setup.ts. */}
+ {/* react-resizable-panels owns hit-testing and the resize cursor at the document level; the grab band is configured by resizeTargetMinimumSize on Group above. */}
Promise;
+ }
+}
diff --git a/apps/docs/vitest.config.ts b/apps/docs/vitest.config.ts
index d5f3980b..5ee0d37e 100644
--- a/apps/docs/vitest.config.ts
+++ b/apps/docs/vitest.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
'next-themes',
'react-aria-components/ToggleButton',
'react-aria-components/ToggleButtonGroup',
+ 'react-resizable-panels',
],
},
test: {
@@ -27,6 +28,18 @@ export default defineConfig({
extends: true,
test: {
browser: {
+ commands: {
+ dragFromSeparator: async ({ iframe, page }, offsetX: number, dragBy: number) => {
+ const box = await iframe.locator('[role="separator"]').boundingBox();
+ if (!box) throw new Error('separator not found');
+ const x = box.x + box.width / 2 + offsetX;
+ const y = box.y + box.height / 2;
+ await page.mouse.move(x, y);
+ await page.mouse.down();
+ await page.mouse.move(x + dragBy, y, { steps: 10 });
+ await page.mouse.up();
+ },
+ },
enabled: true,
headless: true,
instances: [{ browser: 'chromium' }],