Skip to content
Merged
18 changes: 18 additions & 0 deletions packages/oc-docs/e2e/components/base.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,26 @@ import type { Page, Locator } from '@playwright/test';

export abstract class BaseComponent {
readonly root: Locator;
private dragY = 0;

constructor(protected readonly page: Page, root?: Locator) {
this.root = root ?? page.locator(':root');
}

/** Press the pointer on a resize handle; the drag y is kept for later moves. */
protected async grabHandle(handle: Locator): Promise<void> {
const box = await handle.boundingBox();
this.dragY = (box?.y ?? 0) + (box?.height ?? 0) / 2;
await handle.hover();
await this.page.mouse.down();
}

/** Move the held pointer to an absolute x (keeps the grabbed y). */
async movePointerToX(x: number): Promise<void> {
await this.page.mouse.move(x, this.dragY, { steps: 10 });
}

async releasePointer(): Promise<void> {
await this.page.mouse.up();
}
}
10 changes: 10 additions & 0 deletions packages/oc-docs/e2e/components/playground.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class PlaygroundComponent extends BaseComponent {
readonly runner = this.page.getByTestId('playground-runner');
readonly loadError = this.page.getByTestId('playground-load-error');
readonly sidebarPanel = this.page.getByTestId('playground-sidebar-panel');
readonly sidebarResizer = this.page.getByTestId('playground-sidebar-resizer');
readonly sidebarBackdrop = this.page.getByTestId('playground-sidebar-backdrop');
readonly collectionNode = this.page.getByTestId('sidebar-collection-root');
readonly collectionCollapseToggle = this.collectionNode.getByRole('button', {
Expand Down Expand Up @@ -121,4 +122,13 @@ export class PlaygroundComponent extends BaseComponent {
async toggleCollapse(): Promise<void> {
await this.collapseButton.click();
}

async sidebarWidth(): Promise<number> {
const box = await this.sidebarPanel.boundingBox();
return box?.width ?? 0;
}

async grabSidebarResizer(): Promise<void> {
await this.grabHandle(this.sidebarResizer);
}
}
10 changes: 10 additions & 0 deletions packages/oc-docs/e2e/components/sidebar.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export class SidebarComponent extends BaseComponent {
readonly environments = this.page.getByTestId('sidebar-environments');
readonly collapseButton = this.page.getByTestId('sidebar-collapse');
readonly expandButton = this.page.getByTestId('sidebar-expand');
readonly resizer = this.page.getByTestId('sidebar-resizer');
readonly drawer = this.page.getByTestId('sidebar-drawer');
readonly backdrop = this.page.getByTestId('sidebar-backdrop');
readonly hamburger = this.page.getByTestId('topbar-menu');
Expand Down Expand Up @@ -42,6 +43,15 @@ export class SidebarComponent extends BaseComponent {
}
}

async width(): Promise<number> {
const box = await this.inline.boundingBox();
return box?.width ?? 0;
}

async grabResizer(): Promise<void> {
await this.grabHandle(this.resizer);
}

async collapse(): Promise<void> {
await this.inline.hover();
await this.collapseButton.click();
Expand Down
109 changes: 109 additions & 0 deletions packages/oc-docs/e2e/tests/sidebar/sidebar-resize.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { test, expect } from '../../playwright';

const FOLDERS = '/?fixture=folders';
const DESKTOP = { width: 1280, height: 900 };

// The resize handle sits at the sidebar's right edge (left: var(--sidebar-width),
// default 260px), and the drag is delta-based from where it is grabbed, so moving
// the pointer to an absolute x lands the width near that x. Collapse fires when the
// pointer is dragged ~100px past the 200px min (i.e. below x=100); re-expand fires
// once it climbs back to the min (x>=200) within the same held gesture.

test.describe('docs sidebar - resize (desktop)', () => {
test.use({ viewport: DESKTOP });

test('widens when the handle is dragged right', async ({ page, sidebar }) => {
await page.goto(FOLDERS);
await expect(sidebar.inline).toBeVisible();
const before = await sidebar.width();

await sidebar.grabResizer();
await sidebar.movePointerToX(400);
await sidebar.releasePointer();

const after = await sidebar.width();
expect(after).toBeGreaterThan(before);
expect(after).toBeGreaterThan(360);
});

test('clamps to the max width (480px)', async ({ page, sidebar }) => {
await page.goto(FOLDERS);
await sidebar.grabResizer();
await sidebar.movePointerToX(900);
await sidebar.releasePointer();

const after = await sidebar.width();
expect(after).toBeGreaterThan(470);
expect(after).toBeLessThanOrEqual(482);
});

test('persists the resized width across a reload (sessionStorage)', async ({ page, sidebar }) => {
await page.goto(FOLDERS);
await sidebar.grabResizer();
await sidebar.movePointerToX(380);
await sidebar.releasePointer();
const resized = await sidebar.width();
expect(resized).toBeGreaterThan(360);

await page.reload();
await expect(sidebar.inline).toBeVisible();
expect(Math.abs((await sidebar.width()) - resized)).toBeLessThan(5);
});

test('collapses when dragged past the min, and can be re-opened', async ({ page, sidebar }) => {
await page.goto(FOLDERS);
await sidebar.grabResizer();
await sidebar.movePointerToX(60);
await sidebar.releasePointer();

await expect(sidebar.inline).toHaveCount(0);
await expect(sidebar.expandButton).toBeVisible();

await sidebar.expand();
await expect(sidebar.inline).toBeVisible();
});

test('re-expands within the same held drag after collapsing', async ({ page, sidebar }) => {
await page.goto(FOLDERS);
await sidebar.grabResizer();

await sidebar.movePointerToX(60);
await expect(sidebar.inline).toHaveCount(0);

await sidebar.movePointerToX(320);
await expect(sidebar.inline).toBeVisible();

await sidebar.releasePointer();
await expect(sidebar.inline).toBeVisible();
});
});

test.describe('playground sidebar - resize (bottom dock)', () => {
test.use({ viewport: DESKTOP });

test('widens when the handle is dragged right', async ({ playground }) => {
await playground.open('bottom');
await expect(playground.sidebarPanel).toBeVisible();
const before = await playground.sidebarWidth();

await playground.grabSidebarResizer();
await playground.movePointerToX(400);
await playground.releasePointer();

expect(await playground.sidebarWidth()).toBeGreaterThan(before);
});

test('collapses when dragged past the min, and re-opens from the toggle', async ({ playground }) => {
await playground.open('bottom');
await expect(playground.sidebarPanel).toBeVisible();

await playground.grabSidebarResizer();
await playground.movePointerToX(60);
await playground.releasePointer();

await expect(playground.sidebarPanel).toHaveCount(0);

await playground.sidebarToggle.click();
await expect(playground.sidebarPanel).toBeVisible();
});
});
22 changes: 20 additions & 2 deletions packages/oc-docs/src/components/AppShell/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { ChevronLeftIcon, ChevronRightIcon } from '../../assets/icons';
import PageRouter from '../PageRouter/PageRouter';
import Playground from '../Playground/Playground';
import SearchBar from '../Search/SearchBar/SearchBar';
import { useSearchHotkey, usePlaygroundUrlState, useElementWidth } from '../../hooks';
import { useSearchHotkey, usePlaygroundUrlState, useElementWidth, useResizableSidebar } from '../../hooks';
import { useAppSelector } from '../../store/hooks';
import { selectDocsCollection } from '../../store/slices/docs';
import { selectGitCollectionUrl } from '../../store/slices/app';
Expand Down Expand Up @@ -56,6 +56,11 @@ const AppShell: React.FC<AppShellProps> = ({ logo, testId = 'app-shell' }) => {
const isDesktop = mode === 'desktop';
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(false);
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const { width: sidebarWidth, dragging: sidebarDragging, startDrag: startSidebarResize } = useResizableSidebar(
'oc-docs:docsSidebarWidth',
() => setSidebarCollapsed(true),
() => setSidebarCollapsed(false)
);
const { pathname } = useLocation();

const { open: playgroundOpen, dock: playgroundDock, openPlayground, setRequestExample } = usePlaygroundUrlState();
Expand Down Expand Up @@ -104,7 +109,11 @@ const AppShell: React.FC<AppShellProps> = ({ logo, testId = 'app-shell' }) => {
data-testid={testId}
data-dock={playgroundOpen ? playgroundDock : 'none'}
>
<div className="appshell-body" ref={bodyRef}>
<div
className="appshell-body"
ref={bodyRef}
style={{ '--sidebar-width': `${sidebarWidth}px` } as React.CSSProperties}
>
<Topbar
layoutMode={mode}
collectionName={collection?.info?.name || 'API Collection'}
Expand Down Expand Up @@ -138,6 +147,15 @@ const AppShell: React.FC<AppShellProps> = ({ logo, testId = 'app-shell' }) => {
<aside className="appshell-sidebar" data-testid="app-sidebar">
<Sidebar />
</aside>
<div
className="appshell-sidebar-resizer"
data-testid="sidebar-resizer"
data-dragging={sidebarDragging ? 'true' : undefined}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
onPointerDown={startSidebarResize}
/>
<IconButton
className="appshell-collapse"
label="Collapse sidebar"
Expand Down
29 changes: 29 additions & 0 deletions packages/oc-docs/src/components/AppShell/StyledWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,35 @@ export const StyledWrapper = styled.div`
background-color: var(--oc-background-base);
}

.appshell-sidebar-resizer {
position: absolute;
top: 0;
bottom: 0;
left: var(--sidebar-width);
width: 0.5625rem;
transform: translateX(-0.25rem);
z-index: calc(var(--z-sidebar, 5) + 1);
cursor: col-resize;
touch-action: none;
}

.appshell-sidebar-resizer::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 0.0625rem;
transform: translateX(-50%);
background-color: transparent;
}

.appshell-sidebar-resizer:hover::before,
.appshell-sidebar-resizer[data-dragging='true']::before {
width: 0.125rem;
background-color: var(--oc-border-border2);
}

.appshell-content {
flex: 1;
min-width: 0;
Expand Down
1 change: 1 addition & 0 deletions packages/oc-docs/src/components/Playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ const Playground: React.FC<PlaygroundProps> = ({ openNonce }) => {
sidebarOpen={sidebarOpen}
dock={effectiveDock}
onCloseSidebar={() => setSidebarOpen(false)}
onOpenSidebar={() => setSidebarOpen(true)}
appliedSlugRef={appliedSlugRef}
/>
</Suspense>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe('PlaygroundBody example view', () => {
sidebarOpen={false}
dock="modal"
onCloseSidebar={() => {}}
onOpenSidebar={() => {}}
appliedSlugRef={ref as any}
/>
</MemoryRouter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {
import { selectActiveEnvName } from '../../../store/slices/env';
import type { ExampleHighlight } from '../../Docs/Sidebar/SidebarTree/SidebarTree';
import { useNavModel } from '../../../routing/hooks';
import { usePlaygroundUrlState, useElementWidth, useClickOutside } from '../../../hooks';
import { usePlaygroundUrlState, useElementWidth, useResizableSidebar, useClickOutside } from '../../../hooks';
import { getItemUuid, findItemByUuid } from '../../../utils/itemUtils';
import { isFolder } from '../../../utils/schemaHelpers';
import { exampleIndexForSlug, exampleSlugForIndex } from '../../../routing/slug';
Expand Down Expand Up @@ -48,6 +48,7 @@ interface PlaygroundBodyProps {
sidebarOpen: boolean;
dock: DockMode;
onCloseSidebar: () => void;
onOpenSidebar: () => void;
// Tracks the applied request (+example) key across dock-switch remounts; owned
// by Playground so it survives a dock switch but resets on close (see there).
appliedSlugRef: React.MutableRefObject<string | null>;
Expand All @@ -59,6 +60,7 @@ const PlaygroundBody: React.FC<PlaygroundBodyProps> = ({
sidebarOpen,
dock,
onCloseSidebar,
onOpenSidebar,
appliedSlugRef,
}) => {
const dispatch = useAppDispatch();
Expand Down Expand Up @@ -99,19 +101,22 @@ const PlaygroundBody: React.FC<PlaygroundBodyProps> = ({

const viewRef = useRef<HTMLDivElement>(null);
const viewWidth = useElementWidth(viewRef);
const { width: sidebarWidth, dragging: sidebarDragging, startDrag: startSidebarResize } =
useResizableSidebar('oc-docs:playgroundSidebarWidth', onCloseSidebar, onOpenSidebar);
const orientation = viewWidth > 0 && viewWidth < ORIENTATION_BREAKPOINT ? 'vertical' : 'horizontal';

// Close the inline-dock overlay when the pointer goes down anywhere outside
// the sidebar, including outside the playground. The backdrop still handles
// clicks over the view (so they don't reach a control underneath); this adds
// the rest of the page. The toggle is excluded so closing via it isn't undone
// by its own click reopening the sidebar.
// by its own click reopening the sidebar; the resize handle sits just outside
// the sidebar too, so grabbing it must not dismiss the overlay mid-drag.
const sidebarRef = useRef<HTMLElement>(null);
useClickOutside(
sidebarRef,
onCloseSidebar,
sidebarOpen && dock === 'inline',
'[data-testid="playground-sidebar-toggle"]'
'[data-testid="playground-sidebar-toggle"], [data-testid="playground-sidebar-resizer"]'
);

// Reopen whatever the URL says was last open. `pgReq` holds a request, a
Expand Down Expand Up @@ -237,7 +242,11 @@ const PlaygroundBody: React.FC<PlaygroundBodyProps> = ({
})();

return (
<StyledWrapper data-testid="playground-runner" data-overlay-sidebar={dock === 'inline' ? 'true' : undefined}>
<StyledWrapper
data-testid="playground-runner"
data-overlay-sidebar={dock === 'inline' ? 'true' : undefined}
style={{ '--sidebar-width': `${sidebarWidth}px` } as React.CSSProperties}
>
{sidebarOpen && dock === 'inline' && (
// In the inline dock the sidebar overlays the view, so a click outside it
// dismisses it, same as the docs navigation drawer's backdrop.
Expand All @@ -249,22 +258,33 @@ const PlaygroundBody: React.FC<PlaygroundBodyProps> = ({
/>
)}
{sidebarOpen && (
<aside className="sidebar" data-testid="playground-sidebar-panel" ref={sidebarRef}>
<PlaygroundSidebar
collection={collection}
activeSlug={activeSlug}
uuidToSlug={uuidToSlug}
onNavigate={handleNavigate}
onToggleFolder={handleToggleFolder}
onExpandFolder={handleExpandFolder}
onOpenEnvironments={openEnvironments}
environmentsActive={viewMode === 'environments'}
onOpenCollection={openCollection}
collectionActive={viewMode === 'collection-settings'}
activeExample={activeExample}
onExampleClick={handleExampleClick}
<>
<aside className="sidebar" data-testid="playground-sidebar-panel" ref={sidebarRef}>
<PlaygroundSidebar
collection={collection}
activeSlug={activeSlug}
uuidToSlug={uuidToSlug}
onNavigate={handleNavigate}
onToggleFolder={handleToggleFolder}
onExpandFolder={handleExpandFolder}
onOpenEnvironments={openEnvironments}
environmentsActive={viewMode === 'environments'}
onOpenCollection={openCollection}
collectionActive={viewMode === 'collection-settings'}
activeExample={activeExample}
onExampleClick={handleExampleClick}
/>
</aside>
<div
className="sidebar-resizer"
data-testid="playground-sidebar-resizer"
data-dragging={sidebarDragging ? 'true' : undefined}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
onPointerDown={startSidebarResize}
/>
</aside>
</>
)}
<div className="view" data-testid="playground-view" ref={viewRef}>
{view}
Expand Down
Loading
Loading