fix: synchronize sidebar pin and main layout animations

This commit is contained in:
2026-10-01 13:36:19 +09:00
parent f99da27891
commit b9aaff4052
4 changed files with 94 additions and 4 deletions
@@ -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 });