feat: animate main layout and separate sidebar controls
This commit is contained in:
@@ -44,19 +44,26 @@ async function historyRequestCount(baseUrl: string): Promise<number> {
|
||||
async function checkSidebarModeSwitch(page: Page, name: string): Promise<void> {
|
||||
const button = page.getByRole("button", { name, exact: true });
|
||||
await button.hover();
|
||||
await button.focus();
|
||||
const samples = await button.evaluate(async (button) => {
|
||||
const frame = button.closest<HTMLElement>(".sidebar-frame")!;
|
||||
await Promise.all(frame.getAnimations({ subtree: true }).map((animation) => animation.finished));
|
||||
const panel = frame.querySelector<HTMLElement>(".sidebar-frame__bevel")!;
|
||||
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
|
||||
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
|
||||
const sample = () => ({
|
||||
panel: panel.getBoundingClientRect().toJSON(),
|
||||
content: content.getBoundingClientRect().toJSON(),
|
||||
button: button.getBoundingClientRect().toJSON(),
|
||||
folded: frame.classList.contains("folded"),
|
||||
motion: frame.getAnimations({ subtree: true }).filter((animation) =>
|
||||
animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty)
|
||||
).length,
|
||||
mainLeft: main.getBoundingClientRect().left,
|
||||
mainWidth: main.getBoundingClientRect().width,
|
||||
stationary: {
|
||||
panel: panel.getBoundingClientRect().toJSON(),
|
||||
content: content.getBoundingClientRect().toJSON(),
|
||||
button: button.getBoundingClientRect().toJSON(),
|
||||
folded: frame.classList.contains("folded"),
|
||||
unclipped: panel.contains(document.elementFromPoint(panel.getBoundingClientRect().right - 8, 40)),
|
||||
motion: panel.getAnimations({ subtree: true }).filter((animation) =>
|
||||
animation instanceof CSSTransition && ["width", "transform"].includes(animation.transitionProperty)
|
||||
).length,
|
||||
},
|
||||
});
|
||||
const samples = [sample()];
|
||||
(button as HTMLButtonElement).click();
|
||||
@@ -68,14 +75,22 @@ async function checkSidebarModeSwitch(page: Page, name: string): Promise<void> {
|
||||
return samples;
|
||||
});
|
||||
for (const sample of samples) {
|
||||
assertEquals(sample, samples[0], `${name} must keep the open panel stationary without slide animation`);
|
||||
assertEquals(sample.folded, false);
|
||||
assertEquals(sample.motion, 0);
|
||||
assertEquals(sample.stationary, samples[0].stationary, `${name} must keep the open panel stationary without slide animation`);
|
||||
assertEquals(sample.stationary.folded, false);
|
||||
assertEquals(sample.stationary.motion, 0);
|
||||
assertEquals(sample.stationary.unclipped, true);
|
||||
}
|
||||
const first = samples[0];
|
||||
const last = samples.at(-1)!;
|
||||
assert(samples.some((sample) => sample.mainLeft > Math.min(first.mainLeft, last.mainLeft) &&
|
||||
sample.mainLeft < Math.max(first.mainLeft, last.mainLeft)), "main position must animate through intermediate coordinates");
|
||||
assert(samples.some((sample) => sample.mainWidth > Math.min(first.mainWidth, last.mainWidth) &&
|
||||
sample.mainWidth < Math.max(first.mainWidth, last.mainWidth)), "main width must animate through intermediate sizes");
|
||||
await page.locator(".sidebar-fold-button").evaluate((button) => (button as HTMLButtonElement).blur());
|
||||
}
|
||||
|
||||
async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> {
|
||||
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-frame");
|
||||
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-hover-region");
|
||||
const result = await toggle.evaluate(async (button, { mobile, opening }) => {
|
||||
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
|
||||
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
|
||||
@@ -440,6 +455,20 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
||||
const mainBeforeHover = await page.locator(".app-shell__main").boundingBox();
|
||||
await checkSidebarSlide(page, false, true);
|
||||
await checkSidebarSlide(page, false, false);
|
||||
// The entire footer, including its button, is outside hover activation.
|
||||
const footer = sidebar.locator(".sidebar-control-row");
|
||||
await footer.hover({ position: { x: 4, y: 4 } });
|
||||
await page.waitForTimeout(250);
|
||||
assertEquals(await page.locator(".sidebar-frame.folded").count(), 1);
|
||||
await page.getByRole("button", { name: "Pin sidebar", exact: true }).hover();
|
||||
await page.waitForTimeout(250);
|
||||
assertEquals(await page.locator(".sidebar-frame.folded").count(), 1);
|
||||
await sidebar.locator(".sidebar-hover-region").hover({ position: { x: 10, y: 24 } });
|
||||
await page.locator(".sidebar-frame:not(.folded)").waitFor();
|
||||
await settleSidebar();
|
||||
await footer.hover({ position: { x: 4, y: 4 } });
|
||||
await page.locator(".sidebar-frame.folded").waitFor();
|
||||
await settleSidebar();
|
||||
// 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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, type Snippet } from 'svelte';
|
||||
import Bevel from '$lib/workspace/ui/Bevel.svelte';
|
||||
import SidebarToggleIcon from './SidebarToggleIcon.svelte';
|
||||
import './sidebar.css';
|
||||
|
||||
type Props = {
|
||||
@@ -81,16 +82,20 @@
|
||||
class:hover-mode={mode === 'hover'}
|
||||
aria-label="Sidebar"
|
||||
bind:this={frame}
|
||||
onpointerenter={enter}
|
||||
onpointerleave={leave}
|
||||
onpointercancel={leave}
|
||||
onfocusin={focusIn}
|
||||
onfocusout={focusOut}
|
||||
>
|
||||
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false}>
|
||||
<div class="sidebar-frame__surface">
|
||||
<div class="sidebar-frame-content" inert={!open} aria-hidden={!open}>
|
||||
{@render children()}
|
||||
<div
|
||||
class="sidebar-hover-region"
|
||||
onpointerenter={enter}
|
||||
onpointerleave={leave}
|
||||
onpointercancel={leave}
|
||||
>
|
||||
<div class="sidebar-frame-content" inert={!open} aria-hidden={!open}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-control-row">
|
||||
@@ -102,10 +107,7 @@
|
||||
title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'}
|
||||
onclick={toggleMode}
|
||||
>
|
||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
<path d="M9 3H15L14 9L18 13V15H6V13L10 9L9 3ZM12 15V21" />
|
||||
{#if mode === 'hover'}<path d="M3 3L21 21" />{/if}
|
||||
</svg>
|
||||
<SidebarToggleIcon open={mode === 'pinned'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
let { open }: { open: boolean } = $props();
|
||||
</script>
|
||||
|
||||
<!-- Lucide panel-right-close / panel-right-open; ISC license: /licenses/lucide.txt -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-icon={open ? 'panel-right-close' : 'panel-right-open'}
|
||||
>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M15 3v18" />
|
||||
<path d={open ? 'm8 9 3 3-3 3' : 'm10 15-3-3 3-3'} />
|
||||
</svg>
|
||||
@@ -13,14 +13,18 @@
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
width: var(--sidebar-width);
|
||||
/* Reserving layout space is a mode switch, not a panel open/close. */
|
||||
/* Animate reserved main-content space, independently of the panel. */
|
||||
transition: width var(--sidebar-motion-duration) var(--sidebar-motion-easing);
|
||||
position: relative;
|
||||
z-index: 40;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
}
|
||||
.sidebar-frame .bevel.sidebar-frame__bevel {
|
||||
/* Keep the open panel's width identical in pinned and overlay modes. */
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -31,7 +35,7 @@
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding-block: var(--space-4);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-frame {
|
||||
@@ -43,8 +47,14 @@
|
||||
width: var(--sidebar-rail-width);
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-hover-region {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-frame-content {
|
||||
flex: 1 1 auto;
|
||||
height: 100%;
|
||||
width: calc(var(--sidebar-width) - 1px);
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
@@ -134,12 +144,11 @@
|
||||
}
|
||||
.sidebar-control-row {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.sidebar-control-row {
|
||||
margin-top: auto;
|
||||
padding: var(--space-4) var(--space-2) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
padding: var(--space-4) var(--space-2);
|
||||
}
|
||||
.workspace-label {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
} from '$lib/workspace/header/context';
|
||||
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
|
||||
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
|
||||
import SidebarToggleIcon from '$lib/workspace/sidebar/SidebarToggleIcon.svelte';
|
||||
import { SIDEBAR_CONTEXT, type SidebarController, type SidebarSnippet } from '$lib/workspace/sidebar/context';
|
||||
import { createOverrideStack } from '$lib/workspace/sidebar/override-stack';
|
||||
import '../app.css';
|
||||
@@ -102,15 +103,7 @@
|
||||
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||
onclick={toggleSidebar}
|
||||
>
|
||||
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
{#if !sidebarOpen}
|
||||
<path d="m6 17 5-5-5-5" />
|
||||
<path d="m13 17 5-5-5-5" />
|
||||
{:else}
|
||||
<path d="m11 17-5-5 5-5" />
|
||||
<path d="m18 17-5-5 5-5" />
|
||||
{/if}
|
||||
</svg>
|
||||
<SidebarToggleIcon open={sidebarOpen} />
|
||||
</button>
|
||||
<a class="app-shell__icon-button" href="/account" aria-label="Open Account" title="Account">
|
||||
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||
|
||||
@@ -16,8 +16,9 @@ const props = {
|
||||
};
|
||||
const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
|
||||
const folded = () => frame().classList.contains("folded");
|
||||
const enter = () => fireEvent.pointerEnter(frame(), { pointerType: "mouse" });
|
||||
const leave = () => fireEvent.pointerLeave(frame(), { pointerType: "mouse" });
|
||||
const hoverRegion = () => frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
|
||||
const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
|
||||
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
||||
const delay = () => vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
|
||||
@@ -87,6 +88,27 @@ test("hover keeps the same content node, delays closing, and cancels closing on
|
||||
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||
});
|
||||
|
||||
test("footer hover does not open or hold the preview, but its button still changes mode", async () => {
|
||||
localStorage.setItem(storageKey, "hover");
|
||||
render(Layout, { props });
|
||||
const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
|
||||
const button = screen.getByRole("button", { name: "Pin sidebar" });
|
||||
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull();
|
||||
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
|
||||
await fireEvent.pointerEnter(button, { pointerType: "mouse" });
|
||||
await delay();
|
||||
expect(folded()).toBe(true);
|
||||
await enter();
|
||||
expect(folded()).toBe(false);
|
||||
await leave();
|
||||
await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
|
||||
await delay();
|
||||
expect(folded()).toBe(true);
|
||||
await fireEvent.click(button, { detail: 1 });
|
||||
expect(folded()).toBe(false);
|
||||
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull();
|
||||
});
|
||||
|
||||
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
|
||||
localStorage.setItem(storageKey, "hover");
|
||||
render(Layout, { props });
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Lucide panel-right-close and panel-right-open
|
||||
https://lucide.dev/icons/panel-right-close
|
||||
https://lucide.dev/icons/panel-right-open
|
||||
|
||||
ISC License
|
||||
|
||||
Copyright (c) 2026 Lucide Icons and Contributors
|
||||
|
||||
Permission to use, copy, modify, and/or distribute this software for any
|
||||
purpose with or without fee is hereby granted, provided that the above
|
||||
copyright notice and this permission notice appear in all copies.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
||||
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
||||
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
||||
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
||||
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
Reference in New Issue
Block a user