fix: synchronize sidebar pin and main layout animations
This commit is contained in:
@@ -89,6 +89,53 @@ async function checkSidebarModeSwitch(page: Page, name: string): Promise<void> {
|
||||
await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur());
|
||||
}
|
||||
|
||||
async function checkSidebarPointerModeSwitch(page: Page, pinning: boolean): Promise<void> {
|
||||
const button = page.getByRole("button", { name: pinning ? "Pin sidebar" : "Unpin sidebar", exact: true });
|
||||
await button.hover();
|
||||
await page.mouse.down();
|
||||
await page.waitForTimeout(100);
|
||||
assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 1 : 0,
|
||||
"pointer-down must not open a preview before the mode changes on click");
|
||||
const sampling = button.evaluate(async (button) => {
|
||||
const frame = button.closest<HTMLElement>(".sidebar-frame")!;
|
||||
const panel = frame.querySelector<HTMLElement>(".sidebar-frame__bevel")!;
|
||||
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
|
||||
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
|
||||
const sample = () => ({
|
||||
frameWidth: frame.getBoundingClientRect().width,
|
||||
panelWidth: panel.getBoundingClientRect().width,
|
||||
mainWidth: main.getBoundingClientRect().width,
|
||||
expectedMainWidth: Math.min(parseFloat(getComputedStyle(main).maxWidth), innerWidth - panel.getBoundingClientRect().right),
|
||||
contentWidth: content.getBoundingClientRect().width,
|
||||
buttonLeft: button.getBoundingClientRect().left,
|
||||
buttonBottom: button.getBoundingClientRect().bottom,
|
||||
});
|
||||
const samples = [sample()];
|
||||
const start = performance.now();
|
||||
do {
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
samples.push(sample());
|
||||
} while (performance.now() - start < 450);
|
||||
return samples;
|
||||
});
|
||||
await page.mouse.up();
|
||||
const samples = await sampling;
|
||||
for (const sample of samples) {
|
||||
assert(Math.abs(sample.frameWidth - sample.panelWidth) <= 1,
|
||||
`sidebar and main must move together throughout the click: ${JSON.stringify(sample)}`);
|
||||
assert(Math.abs(sample.mainWidth - sample.expectedMainWidth) <= 1);
|
||||
assertEquals(sample.contentWidth, samples[0].contentWidth);
|
||||
assertEquals(sample.buttonLeft, samples[0].buttonLeft);
|
||||
assertEquals(sample.buttonBottom, samples[0].buttonBottom);
|
||||
}
|
||||
const first = samples[0].frameWidth;
|
||||
const last = samples.at(-1)!.frameWidth;
|
||||
assert(pinning ? last > first : last < first);
|
||||
assert(samples.some((sample) => sample.frameWidth > Math.min(first, last) && sample.frameWidth < Math.max(first, last)),
|
||||
"mode switch must animate through intermediate widths");
|
||||
assertEquals(await page.locator(".sidebar-frame.folded").count(), pinning ? 0 : 1);
|
||||
}
|
||||
|
||||
async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> {
|
||||
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region");
|
||||
const result = await toggle.evaluate(async (button, { mobile, opening }) => {
|
||||
@@ -486,6 +533,8 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
||||
await footer.hover({ position: { x: 4, y: 4 } });
|
||||
await page.locator(".sidebar-frame.folded").waitFor();
|
||||
await settleSidebar();
|
||||
await checkSidebarPointerModeSwitch(page, true);
|
||||
await checkSidebarPointerModeSwitch(page, false);
|
||||
// Use real mouse and keyboard events as well as deterministic motion sampling.
|
||||
await sidebar.hover({ position: { x: 10, y: 40 } });
|
||||
await page.locator(".sidebar-frame:not(.folded)").waitFor();
|
||||
|
||||
@@ -61,10 +61,14 @@
|
||||
function toggleMode(event: MouseEvent) {
|
||||
const next = mode === 'pinned' ? 'hover' : 'pinned';
|
||||
// A pointer click should not leave keyboard focus holding the preview open.
|
||||
if (event.detail > 0) (event.currentTarget as HTMLButtonElement).blur();
|
||||
if (event.detail > 0 && document.activeElement instanceof HTMLElement && frame.contains(document.activeElement)) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
clearCloseTimer();
|
||||
onModeChange(next);
|
||||
if (!mobile) onOpenChange(next === 'hover');
|
||||
scheduleClose();
|
||||
// Footer clicks close immediately with the layout change; only a real
|
||||
// hover or keyboard focus keeps an existing preview open.
|
||||
if (!mobile) onOpenChange(next === 'hover' && (pointerInside || focusInside));
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -105,6 +109,11 @@
|
||||
aria-label={mode === 'pinned' ? 'Unpin sidebar' : 'Pin sidebar'}
|
||||
aria-pressed={mode === 'pinned'}
|
||||
title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'}
|
||||
onpointerdown={(event) => {
|
||||
// Do not open a focus preview before a pointer click changes mode.
|
||||
// Keyboard focus still opens and holds the preview as usual.
|
||||
if (event.button === 0) event.preventDefault();
|
||||
}}
|
||||
onclick={toggleMode}
|
||||
>
|
||||
<SidebarToggleIcon open={mode === 'pinned'} />
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
.sidebar-frame .bevel.sidebar-frame__bevel {
|
||||
/* Keep the open panel's width identical in pinned and overlay modes. */
|
||||
width: var(--sidebar-width);
|
||||
transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing);
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -84,7 +85,6 @@
|
||||
width: var(--sidebar-width);
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing);
|
||||
}
|
||||
.sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel {
|
||||
width: var(--sidebar-rail-width);
|
||||
|
||||
@@ -43,6 +43,38 @@ test("unpinning changes mode, and pointer departure closes the preview", async (
|
||||
expect(folded()).toBe(true);
|
||||
});
|
||||
|
||||
test("footer unpin closes in the same update as the mode change, without the hover delay", async () => {
|
||||
render(Layout, { props });
|
||||
const button = screen.getByRole("button", { name: "Unpin sidebar" });
|
||||
button.focus();
|
||||
await fireEvent.click(button, { detail: 1 });
|
||||
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||
expect(folded()).toBe(true);
|
||||
await delay();
|
||||
expect(folded()).toBe(true);
|
||||
await fireEvent.click(button, { detail: 1 });
|
||||
expect(localStorage.getItem(storageKey)).toBe("pinned");
|
||||
expect(folded()).toBe(false);
|
||||
});
|
||||
|
||||
test("footer pointer clicks release previous sidebar link focus before unpinning", async () => {
|
||||
render(Layout, { props });
|
||||
frame().querySelector<HTMLAnchorElement>("a")!.focus();
|
||||
await tick();
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
|
||||
expect(folded()).toBe(true);
|
||||
});
|
||||
|
||||
test("keyboard mode switching preserves the focused preview", async () => {
|
||||
render(Layout, { props });
|
||||
const button = screen.getByRole("button", { name: "Unpin sidebar" });
|
||||
button.focus();
|
||||
await fireEvent.click(button, { detail: 0 });
|
||||
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||
await delay();
|
||||
expect(folded()).toBe(false);
|
||||
});
|
||||
|
||||
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => {
|
||||
localStorage.setItem(storageKey, mode);
|
||||
render(Layout, { props });
|
||||
|
||||
Reference in New Issue
Block a user