mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 01:15:55 +00:00
feat(shell): resizable stacks sidebar
Add a desktop resize boundary for the stacks sidebar. When the sidebar mode preference is resizable, the shell wraps the sidebar in a width- owned pane with a draggable separator: pointer drag updates the pane live and persists one sanitized width on release; pointercancel, lost pointer capture, and unmount clean up without committing. The separator is keyboard accessible (arrows, Home, End) and exposes role=separator ARIA values that track the effective bounds. The effective width clamps to the viewport through a ResizeObserver on the shell flex row (sidebar plus workspace), reserving a minimum workspace width. Narrowing the window shrinks the pane live without rewriting the stored preference; widening restores the preferred width. Fixed mode (the default) renders the original shell DOM unchanged, and mobile is untouched.
This commit is contained in:
@@ -44,6 +44,8 @@ import { useAuth } from '@/context/AuthContext';
|
||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { StackSidebar } from '@/components/sidebar/StackSidebar';
|
||||
import { SidebarResizePane } from '@/components/sidebar/SidebarResizePane';
|
||||
import { useSidebarLayout } from '@/hooks/use-sidebar-layout';
|
||||
import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
|
||||
import { useSidebarActivitySummary } from '@/components/sidebar/useSidebarActivitySummary';
|
||||
import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun';
|
||||
@@ -462,6 +464,8 @@ export default function EditorLayout() {
|
||||
// full-screen stack detail. `mobileView` is explicit state, decoupled from
|
||||
// `activeView`, so 'dashboard' still maps to HomeDashboard everywhere.
|
||||
const isMobile = useIsMobile();
|
||||
const { sidebarMode, sidebarWidth, setSidebarWidth } = useSidebarLayout();
|
||||
const commitSidebarWidth = useCallback((width: number) => setSidebarWidth(width), [setSidebarWidth]);
|
||||
const [mobileView, setMobileView] = useState<MobileView>('list');
|
||||
const [mobileSettingsSection, setMobileSettingsSection] = useState<SectionId | null>(null);
|
||||
// Optimistically flip to the detail surface the instant a row is tapped,
|
||||
@@ -1039,9 +1043,21 @@ export default function EditorLayout() {
|
||||
onClearSelection={clearSelection}
|
||||
onBulkAction={handleBulkAction}
|
||||
showUpdatesChip={sidebarIndicators}
|
||||
fluid={sidebarMode === 'resizable'}
|
||||
/>
|
||||
);
|
||||
|
||||
// Desktop resizable branch: the pane owns the width and adds the
|
||||
// separator; Fixed keeps the original shell DOM untouched.
|
||||
const sidebarSlotEl = !isMobile && sidebarMode === 'resizable' ? (
|
||||
<SidebarResizePane
|
||||
sidebarWidth={sidebarWidth}
|
||||
onCommitWidth={commitSidebarWidth}
|
||||
>
|
||||
{sidebarEl}
|
||||
</SidebarResizePane>
|
||||
) : sidebarEl;
|
||||
|
||||
const notificationsEl = (
|
||||
<NotificationPanel
|
||||
notifications={notifications}
|
||||
@@ -1382,9 +1398,9 @@ export default function EditorLayout() {
|
||||
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
||||
{commandPaletteEl}
|
||||
{/* Left Sidebar (Stacks) */}
|
||||
{sidebarEl}
|
||||
{sidebarSlotEl}
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
|
||||
{topBarEl}
|
||||
{/* Main Workspace */}
|
||||
{workspaceEl}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react';
|
||||
import { SIDEBAR_WIDTH, sanitizeSidebarWidth } from '@/hooks/use-sidebar-layout';
|
||||
|
||||
/**
|
||||
* Desktop sidebar resize boundary. Wraps the stacks sidebar in a width-owned
|
||||
* pane and renders the draggable separator beside it. This component is the
|
||||
* single resize owner: EditorLayout never holds drag state.
|
||||
*
|
||||
* Three widths stay separate: the preferred width (the persisted preference,
|
||||
* never mutated by the viewport), the in-flight live drag width, and the
|
||||
* effective width actually applied to the pane (preferred clamped to what the
|
||||
* viewport allows). Narrowing the window shrinks the pane live but does not
|
||||
* rewrite the preference; widening restores the preferred width.
|
||||
*/
|
||||
|
||||
/** Workspace px reserved to the right of the sidebar before clamping bites. */
|
||||
const MIN_WORKSPACE = 560;
|
||||
/** Separator hit area in px between sidebar and workspace. */
|
||||
const HANDLE_FOOTPRINT = 12;
|
||||
/** Keyboard step per arrow press, in px. */
|
||||
const KEY_STEP = 16;
|
||||
|
||||
interface SidebarResizePaneProps {
|
||||
sidebarWidth: number;
|
||||
onCommitWidth: (width: number) => void;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarResizePane({ sidebarWidth, onCommitWidth, children }: SidebarResizePaneProps) {
|
||||
const paneId = useId();
|
||||
const paneRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startWidth: number;
|
||||
lastWidth: number;
|
||||
committed: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// The pane's flex row (its parent: sidebar pane + separator + workspace)
|
||||
// is the viewport proxy; measuring it avoids window.innerWidth.
|
||||
useEffect(() => {
|
||||
const el = paneRef.current?.parentElement;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width > 0) setContainerWidth(rect.width);
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width;
|
||||
if (width !== undefined && width > 0) setContainerWidth(width);
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// effectiveMax floors the viewport-derived bound to an integer; at zero
|
||||
// (before the first measurement) the unclamped bounds apply.
|
||||
const effectiveMax = containerWidth > 0
|
||||
? Math.floor(Math.max(SIDEBAR_WIDTH.min, Math.min(SIDEBAR_WIDTH.max, containerWidth - MIN_WORKSPACE - HANDLE_FOOTPRINT)))
|
||||
: SIDEBAR_WIDTH.max;
|
||||
const effectiveMin = SIDEBAR_WIDTH.min;
|
||||
const effectiveWidth = Math.min(
|
||||
effectiveMax,
|
||||
Math.max(effectiveMin, sanitizeSidebarWidth(sidebarWidth)),
|
||||
);
|
||||
|
||||
const applyPaneWidth = useCallback((width: number): void => {
|
||||
if (paneRef.current) paneRef.current.style.width = `${width}px`;
|
||||
}, []);
|
||||
|
||||
// Centralized, idempotent teardown for every termination path. A trailing
|
||||
// lostpointercapture after a committed pointerup is a no-op because the
|
||||
// committed pointerup cleared dragRef first.
|
||||
const endDrag = useCallback((commit: boolean): void => {
|
||||
const drag = dragRef.current;
|
||||
dragRef.current = null;
|
||||
if (drag !== null && commit && !drag.committed) {
|
||||
drag.committed = true;
|
||||
onCommitWidth(Math.round(drag.lastWidth));
|
||||
}
|
||||
try {
|
||||
if (drag !== null) paneRef.current?.releasePointerCapture(drag.pointerId);
|
||||
} catch {
|
||||
/* capture may already be released */
|
||||
}
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
setDragging(false);
|
||||
}, [onCommitWidth]);
|
||||
|
||||
// Unmount mid-drag still cleans the body styles.
|
||||
useEffect(() => () => endDrag(false), [endDrag]);
|
||||
|
||||
// The pane is declaratively owned except mid-drag, when pointermove writes
|
||||
// the style directly (no React state, so only the boundary moves).
|
||||
useEffect(() => {
|
||||
if (!dragging && paneRef.current) paneRef.current.style.width = `${effectiveWidth}px`;
|
||||
}, [effectiveWidth, dragging]);
|
||||
|
||||
const onSeparatorPointerDown = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startWidth: effectiveWidth,
|
||||
lastWidth: effectiveWidth,
|
||||
committed: false,
|
||||
};
|
||||
setDragging(true);
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
document.body.style.cursor = 'col-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
}, [effectiveWidth]);
|
||||
|
||||
const onSeparatorPointerMove = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return;
|
||||
const raw = drag.startWidth + (event.clientX - drag.startX);
|
||||
const next = Math.min(effectiveMax, Math.max(effectiveMin, raw));
|
||||
drag.lastWidth = next;
|
||||
applyPaneWidth(next);
|
||||
}, [effectiveMax, effectiveMin, applyPaneWidth]);
|
||||
|
||||
const onSeparatorPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (drag === null || drag.pointerId !== event.pointerId) return;
|
||||
// Snapshot and mark committed BEFORE releasing capture so the trailing
|
||||
// lostpointercapture finds no drag and cleans up without a second commit.
|
||||
drag.committed = true;
|
||||
onCommitWidth(Math.round(drag.lastWidth));
|
||||
endDrag(false);
|
||||
}, [endDrag, onCommitWidth]);
|
||||
|
||||
const onSeparatorKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
let next: number | null = null;
|
||||
if (event.key === 'Home') {
|
||||
next = effectiveMin;
|
||||
} else if (event.key === 'End') {
|
||||
next = effectiveMax;
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||
const delta = event.key === 'ArrowRight' ? KEY_STEP : -KEY_STEP;
|
||||
next = Math.min(effectiveMax, Math.max(effectiveMin, effectiveWidth + delta));
|
||||
}
|
||||
if (next === null) return;
|
||||
event.preventDefault();
|
||||
applyPaneWidth(next);
|
||||
onCommitWidth(next);
|
||||
}, [effectiveMax, effectiveMin, effectiveWidth, applyPaneWidth, onCommitWidth]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={paneRef}
|
||||
id={paneId}
|
||||
data-testid="sidebar-resize-pane"
|
||||
className="h-full shrink-0 min-w-0 overflow-hidden"
|
||||
style={{ width: effectiveWidth }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="Resize stacks sidebar"
|
||||
aria-controls={paneId}
|
||||
aria-valuenow={effectiveWidth}
|
||||
aria-valuemin={effectiveMin}
|
||||
aria-valuemax={effectiveMax}
|
||||
aria-valuetext={`${effectiveWidth} pixels`}
|
||||
tabIndex={0}
|
||||
data-testid="sidebar-resize-separator"
|
||||
className="relative z-10 w-px shrink-0 cursor-col-resize touch-none bg-glass-border outline-none hover:bg-brand focus-visible:bg-brand focus-visible:ring-1 focus-visible:ring-brand/50"
|
||||
onPointerDown={onSeparatorPointerDown}
|
||||
onPointerMove={onSeparatorPointerMove}
|
||||
onPointerUp={onSeparatorPointerUp}
|
||||
onPointerCancel={() => endDrag(false)}
|
||||
onLostPointerCapture={() => endDrag(false)}
|
||||
onKeyDown={onSeparatorKeyDown}
|
||||
>
|
||||
<span className="absolute inset-y-0 left-0 -right-1.5 z-10" aria-hidden />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -41,6 +41,9 @@ export interface StackSidebarProps {
|
||||
filterStale?: boolean;
|
||||
/** False while status evidence is not authoritative; disables bulk buttons. */
|
||||
actionsReady?: boolean;
|
||||
/** Fill the parent pane instead of the fixed desktop width (the resizable
|
||||
* shell pane owns the width); mobile classes are unaffected. */
|
||||
fluid?: boolean;
|
||||
}
|
||||
|
||||
export function StackSidebar(props: StackSidebarProps) {
|
||||
@@ -52,6 +55,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
showUpdatesChip = true,
|
||||
filterStale = false,
|
||||
actionsReady = false,
|
||||
fluid = false,
|
||||
} = props;
|
||||
|
||||
const [filtersVisible, setFiltersVisible] = useState(() => {
|
||||
@@ -72,7 +76,7 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
return (
|
||||
<div
|
||||
data-sn-chrome="sidebar"
|
||||
className="w-64 max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col"
|
||||
className={`${fluid ? 'w-full' : 'w-64'} max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col`}
|
||||
>
|
||||
{/* On mobile the status masthead leads (it carries the node switcher as
|
||||
its kicker chip), so the in-sidebar brand and node rows are redundant
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Coverage for SidebarResizePane, the desktop sidebar resize boundary.
|
||||
*
|
||||
* The pane owns the width: dragging writes the pane style directly (no React
|
||||
* state per move) and only a pointerup commits a preference write; every
|
||||
* cancellation path (pointercancel, lost capture, unmount) cleans the body
|
||||
* styles without committing. A ResizeObserver on the shell flex row drives
|
||||
* the effective bounds so a narrow window clamps live without rewriting the
|
||||
* stored width.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SidebarResizePane } from '../SidebarResizePane';
|
||||
import { SIDEBAR_WIDTH, SIDEBAR_WIDTH_KEY, useSidebarLayout } from '@/hooks/use-sidebar-layout';
|
||||
import { subscribeToPreferenceWrites } from '@/lib/preferences/preferenceEvents';
|
||||
|
||||
const OriginalResizeObserver = globalThis.ResizeObserver;
|
||||
const observed: { el: Element; cb: ResizeObserverCallback }[] = [];
|
||||
let shellRowWidth = 0;
|
||||
|
||||
function shellRowRect(width: number): DOMRect {
|
||||
return {
|
||||
width,
|
||||
height: 800,
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 800,
|
||||
right: width,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect;
|
||||
}
|
||||
|
||||
function notifyShellRowResize(): void {
|
||||
for (const { el, cb } of [...observed]) {
|
||||
cb(
|
||||
[{
|
||||
target: el,
|
||||
contentRect: el.getBoundingClientRect(),
|
||||
borderBoxSize: [],
|
||||
contentBoxSize: [],
|
||||
devicePixelContentBoxSize: [],
|
||||
} as unknown as ResizeObserverEntry],
|
||||
{} as ResizeObserver,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pane(): HTMLElement {
|
||||
return screen.getByTestId('sidebar-resize-pane');
|
||||
}
|
||||
|
||||
function separator(): HTMLElement {
|
||||
return screen.getByRole('separator', { name: 'Resize stacks sidebar' });
|
||||
}
|
||||
|
||||
function dragSeparator(fromX: number, toX: number): void {
|
||||
const sep = separator();
|
||||
fireEvent.pointerDown(sep, { pointerId: 1, clientX: fromX });
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: toX });
|
||||
fireEvent.pointerUp(sep, { pointerId: 1, clientX: toX });
|
||||
}
|
||||
|
||||
// Harness mirroring the real wiring: width state comes from the shared
|
||||
// preference hook, so a commit lands in localStorage and notifies the bus
|
||||
// exactly like the shell does.
|
||||
function Harness({ onCommit }: { onCommit?: (w: number) => void }) {
|
||||
const { sidebarWidth, setSidebarWidth } = useSidebarLayout();
|
||||
return (
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<SidebarResizePane
|
||||
sidebarWidth={sidebarWidth}
|
||||
onCommitWidth={(w) => { setSidebarWidth(w); onCommit?.(w); }}
|
||||
>
|
||||
<div data-testid="sidebar-content">sidebar</div>
|
||||
</SidebarResizePane>
|
||||
<div data-testid="workspace">workspace</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(onCommit?: (w: number) => void) {
|
||||
return render(<Harness onCommit={onCommit} />);
|
||||
}
|
||||
|
||||
describe('SidebarResizePane', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
observed.length = 0;
|
||||
shellRowWidth = 1200;
|
||||
const origRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
// The pane's parent is the shell flex row (pane + separator + workspace).
|
||||
if (this.querySelector?.('[data-testid="workspace"]')) return shellRowRect(shellRowWidth);
|
||||
return origRect.call(this);
|
||||
});
|
||||
globalThis.ResizeObserver = class MockShellRowResizeObserver {
|
||||
cb: ResizeObserverCallback;
|
||||
constructor(cb: ResizeObserverCallback) {
|
||||
this.cb = cb;
|
||||
}
|
||||
observe(el: Element) {
|
||||
observed.push({ el, cb: this.cb });
|
||||
this.cb(
|
||||
[{
|
||||
target: el,
|
||||
contentRect: el.getBoundingClientRect(),
|
||||
borderBoxSize: [],
|
||||
contentBoxSize: [],
|
||||
devicePixelContentBoxSize: [],
|
||||
} as unknown as ResizeObserverEntry],
|
||||
this as unknown as ResizeObserver,
|
||||
);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = OriginalResizeObserver;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('applies the preferred width on mount and renders the separator with live ARIA bounds', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '320');
|
||||
setup();
|
||||
await waitFor(() => {
|
||||
expect(pane().style.width).toBe('320px');
|
||||
expect(separator()).toHaveAttribute('aria-valuenow', '320');
|
||||
expect(separator()).toHaveAttribute('aria-valuemin', String(SIDEBAR_WIDTH.min));
|
||||
expect(separator()).toHaveAttribute('aria-valuemax', String(SIDEBAR_WIDTH.max));
|
||||
});
|
||||
});
|
||||
|
||||
it('clamps the live drag to the viewport bound without rewriting storage', async () => {
|
||||
shellRowWidth = 900; // effectiveMax = 900 - 560 - 12 = 328
|
||||
const commits: number[] = [];
|
||||
setup((w) => commits.push(w));
|
||||
await waitFor(() => expect(separator()).toHaveAttribute('aria-valuemax', '328'));
|
||||
dragSeparator(300, 900);
|
||||
expect(pane().style.width).toBe('328px');
|
||||
expect(commits).toEqual([328]);
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('328');
|
||||
});
|
||||
|
||||
it('emits exactly one sanitized integer commit per drag', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
|
||||
const commits: number[] = [];
|
||||
setup((w) => commits.push(w));
|
||||
const sep = separator();
|
||||
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 321.6 }); // fractional pointer delta
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 332.2 });
|
||||
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 332.2 });
|
||||
expect(commits).toEqual([312]);
|
||||
expect(Number.isInteger(commits[0])).toBe(true);
|
||||
expect(pane().style.width).toBe('312px');
|
||||
});
|
||||
|
||||
it('writes pane style per move without any preference write until release', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
|
||||
const notify = vi.fn();
|
||||
const unsub = subscribeToPreferenceWrites(notify);
|
||||
const commits: number[] = [];
|
||||
setup((w) => commits.push(w));
|
||||
const sep = separator();
|
||||
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 });
|
||||
expect(pane().style.width).toBe('360px');
|
||||
expect(commits).toEqual([]);
|
||||
expect(notify).not.toHaveBeenCalled();
|
||||
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 380 });
|
||||
expect(commits).toEqual([360]);
|
||||
expect(notify).toHaveBeenCalledTimes(1);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it('tears down on pointercancel without committing and restores the effective width', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
|
||||
const commits: number[] = [];
|
||||
setup((w) => commits.push(w));
|
||||
const sep = separator();
|
||||
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 });
|
||||
expect(document.body.style.cursor).toBe('col-resize');
|
||||
expect(document.body.style.userSelect).toBe('none');
|
||||
fireEvent.pointerCancel(sep, { pointerId: 1, clientX: 0 });
|
||||
expect(commits).toEqual([]);
|
||||
expect(pane().style.width).toBe('280px');
|
||||
expect(document.body.style.cursor).toBe('');
|
||||
expect(document.body.style.userSelect).toBe('');
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 500 });
|
||||
fireEvent.pointerUp(sep, { pointerId: 1, clientX: 500 });
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('finishes on lostpointercapture without committing and ignores a trailing one after pointerup', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '280');
|
||||
const commits: number[] = [];
|
||||
setup((w) => commits.push(w));
|
||||
const sep = separator();
|
||||
// Unexpected capture loss mid-drag: no commit.
|
||||
fireEvent.pointerDown(sep, { pointerId: 1, clientX: 300 });
|
||||
fireEvent.pointerMove(sep, { pointerId: 1, clientX: 380 });
|
||||
fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 });
|
||||
expect(commits).toEqual([]);
|
||||
expect(pane().style.width).toBe('280px');
|
||||
expect(document.body.style.cursor).toBe('');
|
||||
// Committed pointerup, then the browser's trailing lostpointercapture.
|
||||
dragSeparator(300, 340);
|
||||
fireEvent.lostPointerCapture(sep, { pointerId: 1, clientX: 0 });
|
||||
expect(commits).toEqual([320]);
|
||||
expect(pane().style.width).toBe('320px');
|
||||
});
|
||||
|
||||
it('clears body resize styles if the pane unmounts mid-drag', async () => {
|
||||
const { unmount } = setup();
|
||||
fireEvent.pointerDown(separator(), { pointerId: 1, clientX: 300 });
|
||||
expect(document.body.style.cursor).toBe('col-resize');
|
||||
unmount();
|
||||
expect(document.body.style.cursor).toBe('');
|
||||
expect(document.body.style.userSelect).toBe('');
|
||||
});
|
||||
|
||||
it('shrinks the live pane on a narrow shell without rewriting storage, then restores on widen', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '400');
|
||||
shellRowWidth = 900; // effectiveMax = 328
|
||||
const commits: number[] = [];
|
||||
setup((w) => commits.push(w));
|
||||
await waitFor(() => expect(pane().style.width).toBe('328px'));
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('400');
|
||||
shellRowWidth = 1200;
|
||||
act(() => { notifyShellRowResize(); });
|
||||
await waitFor(() => expect(pane().style.width).toBe('400px'));
|
||||
expect(localStorage.getItem(SIDEBAR_WIDTH_KEY)).toBe('400');
|
||||
expect(commits).toEqual([]);
|
||||
});
|
||||
|
||||
it('commits Home/End and arrow-key widths and updates ARIA live', async () => {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_KEY, '300');
|
||||
const user = userEvent.setup();
|
||||
const commits: number[] = [];
|
||||
shellRowWidth = 1200;
|
||||
setup((w) => commits.push(w));
|
||||
separator().focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(commits).toEqual([316]);
|
||||
expect(separator()).toHaveAttribute('aria-valuenow', '316');
|
||||
await user.keyboard('{Home}');
|
||||
expect(pane().style.width).toBe(`${SIDEBAR_WIDTH.min}px`);
|
||||
await user.keyboard('{End}');
|
||||
expect(pane().style.width).toBe(`${SIDEBAR_WIDTH.max}px`);
|
||||
expect(separator()).toHaveAttribute('aria-valuemax', String(SIDEBAR_WIDTH.max));
|
||||
expect(commits).toEqual([316, SIDEBAR_WIDTH.min, SIDEBAR_WIDTH.max]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user