feat: animate main layout and separate sidebar controls
This commit is contained in:
@@ -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