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' }],