feat: add pinned and hover sidebar modes
This commit is contained in:
@@ -42,8 +42,8 @@ async function historyRequestCount(baseUrl: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> {
|
async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean): Promise<void> {
|
||||||
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-fold-button");
|
const toggle = page.locator(mobile ? ".app-shell__mobile-sidebar-toggle" : ".sidebar-frame");
|
||||||
const result = await toggle.evaluate(async (button, mobile) => {
|
const result = await toggle.evaluate(async (button, { mobile, opening }) => {
|
||||||
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
|
const frame = document.querySelector<HTMLElement>(".sidebar-frame")!;
|
||||||
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
|
const content = frame.querySelector<HTMLElement>(".sidebar-frame-content")!;
|
||||||
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
|
const main = document.querySelector<HTMLElement>(".app-shell__main")!;
|
||||||
@@ -52,11 +52,17 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
|
|||||||
x: moving.getBoundingClientRect().x,
|
x: moving.getBoundingClientRect().x,
|
||||||
width: moving.getBoundingClientRect().width,
|
width: moving.getBoundingClientRect().width,
|
||||||
frameWidth: frame.getBoundingClientRect().width,
|
frameWidth: frame.getBoundingClientRect().width,
|
||||||
|
panelWidth: frame.querySelector(".sidebar-frame__bevel")!.getBoundingClientRect().width,
|
||||||
mainWidth: main.getBoundingClientRect().width,
|
mainWidth: main.getBoundingClientRect().width,
|
||||||
mainHeight: main.getBoundingClientRect().height,
|
mainHeight: main.getBoundingClientRect().height,
|
||||||
});
|
});
|
||||||
const before = sample();
|
const before = sample();
|
||||||
(button as HTMLButtonElement).click();
|
if (mobile) {
|
||||||
|
(button as HTMLButtonElement).click();
|
||||||
|
} else {
|
||||||
|
button.dispatchEvent(new PointerEvent(opening ? "pointerenter" : "pointerleave", { pointerType: "mouse" }));
|
||||||
|
if (!opening) await new Promise((resolve) => setTimeout(resolve, 190));
|
||||||
|
}
|
||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
||||||
const animations = frame.getAnimations({ subtree: true });
|
const animations = frame.getAnimations({ subtree: true });
|
||||||
const slides = animations.filter((animation) => animation instanceof CSSTransition &&
|
const slides = animations.filter((animation) => animation instanceof CSSTransition &&
|
||||||
@@ -70,8 +76,8 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
|
|||||||
for (const animation of animations) animation.finish();
|
for (const animation of animations) animation.finish();
|
||||||
await Promise.all(animations.map((animation) => animation.finished));
|
await Promise.all(animations.map((animation) => animation.finished));
|
||||||
return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert };
|
return { before, middle, after: sample(), durations, contentInert: content.inert, mainInert: main.inert };
|
||||||
}, mobile);
|
}, { mobile, opening });
|
||||||
assert(result.durations.length >= (mobile ? 1 : 2));
|
assert(result.durations.length >= (mobile ? 1 : 2), `missing sidebar motion: ${JSON.stringify({ mobile, opening, result })}`);
|
||||||
assert(result.durations.every((duration) => duration === 220));
|
assert(result.durations.every((duration) => duration === 220));
|
||||||
const { before, middle, after } = result;
|
const { before, middle, after } = result;
|
||||||
assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x,
|
assert(opening ? before.x < middle.x && middle.x < after.x : before.x > middle.x && middle.x > after.x,
|
||||||
@@ -79,13 +85,13 @@ async function checkSidebarSlide(page: Page, mobile: boolean, opening: boolean):
|
|||||||
assertEquals(before.width, after.width, "sidebar contents must slide without reflowing");
|
assertEquals(before.width, after.width, "sidebar contents must slide without reflowing");
|
||||||
assertEquals(result.contentInert, !opening);
|
assertEquals(result.contentInert, !opening);
|
||||||
assertEquals(result.mainInert, mobile && opening);
|
assertEquals(result.mainInert, mobile && opening);
|
||||||
if (mobile) {
|
assertEquals(before.mainWidth, after.mainWidth, "hover/touch previews must not resize the main content");
|
||||||
assertEquals(before.mainWidth, after.mainWidth);
|
assertEquals(before.mainHeight, after.mainHeight);
|
||||||
assertEquals(before.mainHeight, after.mainHeight);
|
assertEquals(after.frameWidth, before.frameWidth, "hover mode keeps a fixed rail width");
|
||||||
assertEquals(after.frameWidth, before.frameWidth);
|
if (!mobile) {
|
||||||
} else {
|
assert(opening ? before.panelWidth < middle.panelWidth && middle.panelWidth < after.panelWidth
|
||||||
assert(opening ? before.frameWidth < middle.frameWidth && middle.frameWidth < after.frameWidth
|
: before.panelWidth > middle.panelWidth && middle.panelWidth > after.panelWidth,
|
||||||
: before.frameWidth > middle.frameWidth && middle.frameWidth > after.frameWidth);
|
"the overlay panel must expand past the rail and contract again");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,24 +390,55 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
|||||||
assertEquals(new Set(rowIds).size, rowIds.length);
|
assertEquals(new Set(rowIds).size, rowIds.length);
|
||||||
await page.getByRole("button", { name: "Overview", exact: true }).click();
|
await page.getByRole("button", { name: "Overview", exact: true }).click();
|
||||||
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
|
assertEquals(await transcript.getByText("searched 1 time・ran 1 command", { exact: true }).count(), 12);
|
||||||
await checkSidebarSlide(page, false, false);
|
const sidebar = page.locator(".sidebar-frame");
|
||||||
await checkSidebarSlide(page, false, true);
|
const settleSidebar = () => sidebar.evaluate(async (element) => {
|
||||||
await checkSidebarSlide(page, false, false);
|
|
||||||
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "true");
|
|
||||||
await page.reload();
|
|
||||||
await page.getByRole("button", { name: "Unfold sidebar", exact: true }).waitFor();
|
|
||||||
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
|
|
||||||
await page.setViewportSize({ width: 600, height: viewportHeight });
|
|
||||||
await page.locator(".sidebar-frame").evaluate(async (element) => {
|
|
||||||
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished));
|
await Promise.all(element.getAnimations({ subtree: true }).map((animation) => animation.finished));
|
||||||
});
|
});
|
||||||
|
await page.getByRole("button", { name: "Unpin sidebar", exact: true }).click();
|
||||||
|
await page.mouse.move(1000, 30);
|
||||||
|
await page.locator(".sidebar-frame.folded").waitFor();
|
||||||
|
await settleSidebar();
|
||||||
|
const mainBeforeHover = await page.locator(".app-shell__main").boundingBox();
|
||||||
|
await checkSidebarSlide(page, false, true);
|
||||||
|
await checkSidebarSlide(page, false, 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();
|
||||||
|
await settleSidebar();
|
||||||
|
assertEquals(await page.locator(".app-shell__main").boundingBox(), mainBeforeHover);
|
||||||
|
const sidebarLink = sidebar.locator(".sidebar-frame-content a").first();
|
||||||
|
await sidebarLink.focus();
|
||||||
|
await page.mouse.move(1000, 30);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
assertEquals(await page.locator(".sidebar-frame.folded").count(), 0, "keyboard focus must hold the preview open");
|
||||||
|
await page.getByRole("link", { name: "Open Account", exact: true }).focus();
|
||||||
|
await page.locator(".sidebar-frame.folded").waitFor();
|
||||||
|
await settleSidebar();
|
||||||
|
await sidebar.hover({ position: { x: 10, y: 40 } });
|
||||||
|
await page.getByRole("button", { name: "Pin sidebar", exact: true }).click();
|
||||||
|
await page.mouse.move(1000, 30);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
assertEquals(await page.locator(".sidebar-frame.folded").count(), 0);
|
||||||
|
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||||
|
await page.getByRole("button", { name: "Unpin sidebar", exact: true }).click();
|
||||||
|
// Hold a preview open across reload; only the mode should be restored.
|
||||||
|
await sidebarLink.focus();
|
||||||
|
await page.mouse.move(1000, 30);
|
||||||
|
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
|
||||||
|
await page.reload();
|
||||||
|
await page.getByRole("button", { name: "Pin sidebar", exact: true }).waitFor();
|
||||||
|
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
|
||||||
|
await page.setViewportSize({ width: 600, height: viewportHeight });
|
||||||
|
await settleSidebar();
|
||||||
await checkSidebarSlide(page, true, true);
|
await checkSidebarSlide(page, true, true);
|
||||||
await checkSidebarSlide(page, true, false);
|
await checkSidebarSlide(page, true, false);
|
||||||
await checkSidebarSlide(page, true, true);
|
await checkSidebarSlide(page, true, true);
|
||||||
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.folded.v1")), "false");
|
assertEquals(await page.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).waitFor();
|
await page.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||||
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 1);
|
assertEquals(await page.locator(".app-shell.sidebar-open").count(), 0);
|
||||||
|
await page.getByRole("button", { name: "Show sidebar", exact: true }).click();
|
||||||
|
await settleSidebar();
|
||||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||||
await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
|
await page.getByRole("button", { name: "Hide sidebar", exact: true }).click();
|
||||||
const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({
|
const reducedSidebar = await page.locator(".sidebar-frame").evaluate((element) => ({
|
||||||
@@ -411,6 +448,31 @@ async function checkConsoleHistory(viewportHeight: number): Promise<void> {
|
|||||||
}));
|
}));
|
||||||
assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" });
|
assertEquals(reducedSidebar, { animations: 0, right: 0, visibility: "hidden" });
|
||||||
assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
|
assertEquals(await page.locator(".app-shell__main").evaluate((element) => (element as HTMLElement).inert), false);
|
||||||
|
if (viewportHeight === 600) {
|
||||||
|
const touchContext = await browser.newContext({
|
||||||
|
viewport: { width: 600, height: 600 }, hasTouch: true, isMobile: true,
|
||||||
|
storageState: await context.storageState(),
|
||||||
|
});
|
||||||
|
const touchPage = await touchContext.newPage();
|
||||||
|
await touchPage.goto(page.url());
|
||||||
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
|
||||||
|
await touchPage.getByRole("button", { name: "Pin sidebar", exact: true }).tap();
|
||||||
|
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||||
|
await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap();
|
||||||
|
await touchPage.reload();
|
||||||
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).waitFor();
|
||||||
|
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "pinned");
|
||||||
|
// A wide touchscreen still has a temporary-open control in hover mode.
|
||||||
|
await touchPage.setViewportSize({ width: 1024, height: 600 });
|
||||||
|
await touchPage.getByRole("button", { name: "Unpin sidebar", exact: true }).tap();
|
||||||
|
await touchPage.locator(".sidebar-frame.folded").waitFor();
|
||||||
|
await touchPage.getByRole("button", { name: "Show sidebar", exact: true }).tap();
|
||||||
|
await touchPage.locator(".sidebar-frame:not(.folded)").waitFor();
|
||||||
|
await touchPage.getByRole("button", { name: "Hide sidebar", exact: true }).tap();
|
||||||
|
await touchPage.locator(".sidebar-frame.folded").waitFor();
|
||||||
|
assertEquals(await touchPage.evaluate(() => localStorage.getItem("yoi.sidebar.mode.v1")), "hover");
|
||||||
|
await touchContext.close();
|
||||||
|
}
|
||||||
assertEquals(errors, []);
|
assertEquals(errors, []);
|
||||||
await context.close();
|
await context.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1325,12 +1325,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
|||||||
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
|
"Workspace layout should load workspace data, register with the parent slot, and provide the same slot contract to children",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
sidebarFrame.includes("folded = $bindable(false)") &&
|
sidebarFrame.includes("mode: 'pinned' | 'hover'") &&
|
||||||
sidebarFrame.includes("sidebar-fold-button") &&
|
sidebarFrame.includes("sidebar-fold-button") &&
|
||||||
sidebarFrame.includes("Fold sidebar") &&
|
sidebarFrame.includes("Pin sidebar") &&
|
||||||
sidebarFrame.includes("Unfold sidebar") &&
|
sidebarFrame.includes("Unpin sidebar") &&
|
||||||
rootLayout.includes("let sidebarFolded = $state(loadSidebarFolded())") &&
|
rootLayout.includes("let sidebarMode = $state(loadSidebarMode())") &&
|
||||||
rootLayout.includes("<SidebarFrame bind:folded={sidebarFolded}>") &&
|
rootLayout.includes("mode={sidebarMode}") &&
|
||||||
!workspaceLayout.includes("sidebarFolded") &&
|
!workspaceLayout.includes("sidebarFolded") &&
|
||||||
!workspaceLayout.includes("onToggleFold") &&
|
!workspaceLayout.includes("onToggleFold") &&
|
||||||
!sidebar.includes("folded?: boolean") &&
|
!sidebar.includes("folded?: boolean") &&
|
||||||
|
|||||||
@@ -1,24 +1,95 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Snippet } from 'svelte';
|
import { onDestroy, type Snippet } from 'svelte';
|
||||||
import Bevel from '$lib/workspace/ui/Bevel.svelte';
|
import Bevel from '$lib/workspace/ui/Bevel.svelte';
|
||||||
import './sidebar.css';
|
import './sidebar.css';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: Snippet<[]>;
|
children: Snippet<[]>;
|
||||||
folded?: boolean;
|
mode: 'pinned' | 'hover';
|
||||||
|
open: boolean;
|
||||||
|
mobile: boolean;
|
||||||
|
onModeChange: (mode: 'pinned' | 'hover') => void;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { children, folded = $bindable(false) }: Props = $props();
|
let { children, mode, open, mobile, onModeChange, onOpenChange }: Props = $props();
|
||||||
|
let frame: HTMLElement;
|
||||||
|
let pointerInside = false;
|
||||||
|
let focusInside = false;
|
||||||
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function toggleFold() {
|
function clearCloseTimer() {
|
||||||
folded = !folded;
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleClose() {
|
||||||
|
clearCloseTimer();
|
||||||
|
if (mobile || pointerInside || focusInside) return;
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
if (!mobile && mode === 'hover' && !pointerInside && !focusInside) onOpenChange(false);
|
||||||
|
closeTimer = undefined;
|
||||||
|
}, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enter(event: PointerEvent) {
|
||||||
|
if (mobile || event.pointerType !== 'mouse') return;
|
||||||
|
pointerInside = true;
|
||||||
|
clearCloseTimer();
|
||||||
|
if (mode === 'hover') onOpenChange(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leave(event: PointerEvent) {
|
||||||
|
if (event.pointerType !== 'mouse') return;
|
||||||
|
pointerInside = false;
|
||||||
|
scheduleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusIn() {
|
||||||
|
focusInside = true;
|
||||||
|
clearCloseTimer();
|
||||||
|
if (!mobile && mode === 'hover') onOpenChange(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusOut(event: FocusEvent) {
|
||||||
|
if (event.relatedTarget instanceof Node && frame.contains(event.relatedTarget)) return;
|
||||||
|
focusInside = false;
|
||||||
|
scheduleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
onModeChange(next);
|
||||||
|
if (!mobile) onOpenChange(next === 'hover');
|
||||||
|
scheduleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
mobile;
|
||||||
|
pointerInside = false;
|
||||||
|
focusInside = false;
|
||||||
|
clearCloseTimer();
|
||||||
|
});
|
||||||
|
onDestroy(clearCloseTimer);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="sidebar-frame" class:folded>
|
<aside
|
||||||
<Bevel as="div" class="sidebar-frame__bevel" top={false} bottom={false} left={false} fill>
|
class="sidebar-frame"
|
||||||
<aside class="sidebar-frame__surface" aria-label="Sidebar">
|
class:folded={!open}
|
||||||
<div class="sidebar-frame-content" inert={folded} aria-hidden={folded}>
|
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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -26,24 +97,17 @@
|
|||||||
<button
|
<button
|
||||||
class="sidebar-fold-button"
|
class="sidebar-fold-button"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
|
aria-label={mode === 'pinned' ? 'Unpin sidebar' : 'Pin sidebar'}
|
||||||
aria-expanded={!folded}
|
aria-pressed={mode === 'pinned'}
|
||||||
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
|
title={mode === 'pinned' ? 'Always visible — switch to show on hover' : 'Show on hover — pin sidebar'}
|
||||||
onclick={toggleFold}
|
onclick={toggleMode}
|
||||||
>
|
>
|
||||||
{#if folded}
|
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
<path d="M9 3H15L14 9L18 13V15H6V13L10 9L9 3ZM12 15V21" />
|
||||||
<path d="m6 17 5-5-5-5" />
|
{#if mode === 'hover'}<path d="M3 3L21 21" />{/if}
|
||||||
<path d="m13 17 5-5-5-5" />
|
</svg>
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
|
|
||||||
<path d="m11 17-5-5 5-5" />
|
|
||||||
<path d="m18 17-5-5 5-5" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</Bevel>
|
</Bevel>
|
||||||
</div>
|
</aside>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
--sidebar-item-hover: oklch(96% 0 0);
|
--sidebar-item-hover: oklch(96% 0 0);
|
||||||
--sidebar-item-active: oklch(92.5% 0 0);
|
--sidebar-item-active: oklch(92.5% 0 0);
|
||||||
--sidebar-width: clamp(220px, 20vw, 280px);
|
--sidebar-width: clamp(220px, 20vw, 280px);
|
||||||
|
--sidebar-rail-width: calc(32px + var(--space-2) + var(--space-2) + 1px);
|
||||||
--sidebar-motion-duration: 220ms;
|
--sidebar-motion-duration: 220ms;
|
||||||
--sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1);
|
--sidebar-motion-easing: cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sidebar-frame.folded {
|
.sidebar-frame.folded {
|
||||||
width: calc(32px + var(--space-2) + var(--space-2) + 1px);
|
width: var(--sidebar-rail-width);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.sidebar-frame-content {
|
.sidebar-frame-content {
|
||||||
@@ -58,6 +59,25 @@
|
|||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
transition-delay: 0s, var(--sidebar-motion-duration);
|
transition-delay: 0s, var(--sidebar-motion-duration);
|
||||||
}
|
}
|
||||||
|
.sidebar-frame.hover-mode {
|
||||||
|
position: relative;
|
||||||
|
z-index: 40;
|
||||||
|
width: var(--sidebar-rail-width);
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.sidebar-frame.hover-mode .bevel.sidebar-frame__bevel {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
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);
|
||||||
|
}
|
||||||
.sidebar-frame-content,
|
.sidebar-frame-content,
|
||||||
.global-sidebar,
|
.global-sidebar,
|
||||||
.workspace-sidebar,
|
.workspace-sidebar,
|
||||||
@@ -647,7 +667,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.sidebar-frame {
|
.sidebar-frame,
|
||||||
|
.sidebar-frame.hover-mode {
|
||||||
--sidebar-width: 100%;
|
--sidebar-width: 100%;
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
@@ -660,7 +681,8 @@
|
|||||||
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
|
transition: transform var(--sidebar-motion-duration) var(--sidebar-motion-easing),
|
||||||
visibility 0s;
|
visibility 0s;
|
||||||
}
|
}
|
||||||
.sidebar-frame.folded {
|
.sidebar-frame.folded,
|
||||||
|
.sidebar-frame.hover-mode.folded {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
@@ -673,8 +695,11 @@
|
|||||||
visibility: inherit;
|
visibility: inherit;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
.sidebar-control-row {
|
.sidebar-frame.hover-mode .bevel.sidebar-frame__bevel,
|
||||||
display: none;
|
.sidebar-frame.hover-mode.folded .bevel.sidebar-frame__bevel {
|
||||||
|
position: static;
|
||||||
|
width: 100%;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,31 +25,42 @@
|
|||||||
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
|
const headerOverrides = createOverrideStack<HeaderSnippet>((activeHeader) => {
|
||||||
header = activeHeader;
|
header = activeHeader;
|
||||||
});
|
});
|
||||||
// Browser-wide layout preference; independent of authentication and Workspace.
|
// Only the display mode persists; hover/touch previews never do.
|
||||||
const sidebarFoldStorageKey = 'yoi.sidebar.folded.v1';
|
const sidebarModeStorageKey = 'yoi.sidebar.mode.v1';
|
||||||
function loadSidebarFolded(): boolean {
|
const legacySidebarStorageKey = 'yoi.sidebar.folded.v1';
|
||||||
if (typeof window === 'undefined') return false;
|
function loadSidebarMode(): 'pinned' | 'hover' {
|
||||||
|
if (typeof window === 'undefined') return 'pinned';
|
||||||
try {
|
try {
|
||||||
return window.localStorage.getItem(sidebarFoldStorageKey) === 'true';
|
const mode = window.localStorage.getItem(sidebarModeStorageKey);
|
||||||
|
if (mode === 'pinned' || mode === 'hover') return mode;
|
||||||
|
return window.localStorage.getItem(legacySidebarStorageKey) === 'true' ? 'hover' : 'pinned';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return 'pinned';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let sidebarFolded = $state(loadSidebarFolded());
|
let sidebarMode = $state(loadSidebarMode());
|
||||||
|
let sidebarTransientOpen = $state(false);
|
||||||
const mobileLayout = new MediaQuery('(max-width: 760px)');
|
const mobileLayout = new MediaQuery('(max-width: 760px)');
|
||||||
|
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const folded = sidebarFolded;
|
const mode = sidebarMode;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(sidebarFoldStorageKey, String(folded));
|
window.localStorage.setItem(sidebarModeStorageKey, mode);
|
||||||
|
window.localStorage.removeItem(legacySidebarStorageKey);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage can be blocked or full; folding must still work for this page.
|
// Blocked/full storage must not prevent mode changes in this page.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
mobileLayout.current;
|
||||||
|
sidebarTransientOpen = false;
|
||||||
|
});
|
||||||
|
|
||||||
function toggleSidebar() {
|
function toggleSidebar() {
|
||||||
sidebarFolded = !sidebarFolded;
|
sidebarTransientOpen = !sidebarOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
provideHeaderController({
|
provideHeaderController({
|
||||||
@@ -62,8 +73,14 @@
|
|||||||
|
|
||||||
<WorkspaceAlerts />
|
<WorkspaceAlerts />
|
||||||
|
|
||||||
<div class="app-shell" class:sidebar-open={!sidebarFolded}>
|
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
|
||||||
<SidebarFrame bind:folded={sidebarFolded}>
|
<SidebarFrame
|
||||||
|
mode={sidebarMode}
|
||||||
|
open={sidebarOpen}
|
||||||
|
mobile={mobileLayout.current}
|
||||||
|
onModeChange={(mode) => { sidebarMode = mode; }}
|
||||||
|
onOpenChange={(open) => { sidebarTransientOpen = open; }}
|
||||||
|
>
|
||||||
<GlobalSidebar
|
<GlobalSidebar
|
||||||
currentPath={page.url.pathname}
|
currentPath={page.url.pathname}
|
||||||
content={sidebar}
|
content={sidebar}
|
||||||
@@ -80,13 +97,13 @@
|
|||||||
<button
|
<button
|
||||||
class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
|
class="app-shell__icon-button app-shell__mobile-sidebar-toggle"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
|
aria-label={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
aria-expanded={!sidebarFolded}
|
aria-expanded={sidebarOpen}
|
||||||
title={sidebarFolded ? 'Show sidebar' : 'Hide sidebar'}
|
title={sidebarOpen ? 'Hide sidebar' : 'Show sidebar'}
|
||||||
onclick={toggleSidebar}
|
onclick={toggleSidebar}
|
||||||
>
|
>
|
||||||
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
|
<svg class="app-shell__icon" aria-hidden="true" viewBox="0 0 24 24">
|
||||||
{#if sidebarFolded}
|
{#if !sidebarOpen}
|
||||||
<path d="m6 17 5-5-5-5" />
|
<path d="m6 17 5-5-5-5" />
|
||||||
<path d="m13 17 5-5-5-5" />
|
<path d="m13 17 5-5-5-5" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -104,7 +121,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
</Bevel>
|
</Bevel>
|
||||||
<main class="app-shell__main" inert={mobileLayout.current && !sidebarFolded}>
|
<main class="app-shell__main" inert={mobileLayout.current && sidebarOpen}>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -203,6 +220,12 @@
|
|||||||
padding: var(--space-4);
|
padding: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.sidebar-hover-mode .app-shell__mobile-sidebar-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
|||||||
@@ -2,88 +2,172 @@
|
|||||||
|
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { createRawSnippet } from "svelte";
|
import { createRawSnippet, tick } from "svelte";
|
||||||
import { logout } from "$lib/workspace/auth/api";
|
import { logout } from "$lib/workspace/auth/api";
|
||||||
import Layout from "./+layout.svelte";
|
import Layout from "./+layout.svelte";
|
||||||
|
|
||||||
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
|
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
|
||||||
|
const storageKey = "yoi.sidebar.mode.v1";
|
||||||
const storageKey = "yoi.sidebar.folded.v1";
|
const legacyKey = "yoi.sidebar.folded.v1";
|
||||||
const props = {
|
const props = {
|
||||||
params: {},
|
params: {},
|
||||||
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
|
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
|
||||||
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
||||||
};
|
};
|
||||||
|
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 delay = () => vi.advanceTimersByTimeAsync(200);
|
||||||
|
|
||||||
beforeEach(() => localStorage.clear());
|
beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sidebar starts unfolded and persists both desktop and mobile toggles", async () => {
|
test("unpinning changes mode, and pointer departure closes the preview", async () => {
|
||||||
const view = render(Layout, { props });
|
render(Layout, { props });
|
||||||
expect(screen.getByRole("button", { name: "Fold sidebar" }).getAttribute("aria-expanded")).toBe("true");
|
expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true");
|
||||||
const content = view.container.querySelector<HTMLElement>(".sidebar-frame-content")!;
|
await enter();
|
||||||
|
screen.getByRole("button", { name: "Unpin sidebar" }).focus();
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 });
|
||||||
|
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
await leave();
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => {
|
||||||
|
localStorage.setItem(storageKey, mode);
|
||||||
|
render(Layout, { props });
|
||||||
|
expect(folded()).toBe(mode === "hover");
|
||||||
|
expect(localStorage.getItem(storageKey)).toBe(mode);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
|
||||||
|
localStorage.setItem(legacyKey, String(value));
|
||||||
|
render(Layout, { props });
|
||||||
|
expect(localStorage.getItem(storageKey)).toBe(value ? "hover" : "pinned");
|
||||||
|
expect(localStorage.getItem(legacyKey)).toBeNull();
|
||||||
|
expect(folded()).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a saved mode takes precedence over the legacy preference", () => {
|
||||||
|
localStorage.setItem(legacyKey, "true");
|
||||||
|
localStorage.setItem(storageKey, "pinned");
|
||||||
|
render(Layout, { props });
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hover keeps the same content node, delays closing, and cancels closing on reentry", async () => {
|
||||||
|
localStorage.setItem(storageKey, "hover");
|
||||||
|
render(Layout, { props });
|
||||||
|
const content = frame().querySelector<HTMLElement>(".sidebar-frame-content")!;
|
||||||
|
expect(content.inert).toBe(true);
|
||||||
|
await enter();
|
||||||
|
expect(folded()).toBe(false);
|
||||||
expect(content.inert).toBe(false);
|
expect(content.inert).toBe(false);
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
|
await leave();
|
||||||
expect(view.container.querySelector(".sidebar-frame-content")).toBe(content);
|
await vi.advanceTimersByTimeAsync(100);
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
await enter();
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
await leave();
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(true);
|
||||||
|
expect(frame().querySelector(".sidebar-frame-content")).toBe(content);
|
||||||
expect(content.inert).toBe(true);
|
expect(content.inert).toBe(true);
|
||||||
expect(content.getAttribute("aria-hidden")).toBe("true");
|
expect(content.getAttribute("aria-hidden")).toBe("true");
|
||||||
expect(localStorage.getItem(storageKey)).toBe("true");
|
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||||
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(false);
|
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
|
|
||||||
expect(content.inert).toBe(false);
|
|
||||||
expect(content.getAttribute("aria-hidden")).toBe("false");
|
|
||||||
expect(localStorage.getItem(storageKey)).toBe("false");
|
|
||||||
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
|
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
|
|
||||||
expect(localStorage.getItem(storageKey)).toBe("true");
|
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
|
|
||||||
expect(localStorage.getItem(storageKey)).toBe("false");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([true, false])("restores stored folded=%s without overwriting it with the default", (folded) => {
|
test("keyboard focus opens the preview and holds it until focus leaves", async () => {
|
||||||
localStorage.setItem(storageKey, String(folded));
|
localStorage.setItem(storageKey, "hover");
|
||||||
const writes = vi.spyOn(Storage.prototype, "setItem");
|
render(Layout, { props });
|
||||||
const view = render(Layout, { props });
|
screen.getByRole("button", { name: "Pin sidebar" }).focus();
|
||||||
expect(screen.getByRole("button", { name: folded ? "Unfold sidebar" : "Fold sidebar" }).getAttribute("aria-expanded")).toBe(String(!folded));
|
await tick();
|
||||||
expect(view.container.querySelector(".app-shell")?.classList.contains("sidebar-open")).toBe(!folded);
|
expect(folded()).toBe(false);
|
||||||
expect(writes.mock.calls.every(([key, value]) => key !== storageKey || value === String(folded))).toBe(true);
|
await enter();
|
||||||
|
await leave();
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
screen.getByRole("link", { name: "Open Account" }).focus();
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fold preference survives logout and a fresh app mount", async () => {
|
test("pinning a preview keeps it open after pointer and focus leave", async () => {
|
||||||
|
localStorage.setItem(storageKey, "hover");
|
||||||
|
render(Layout, { props });
|
||||||
|
await enter();
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 });
|
||||||
|
await leave();
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
expect(localStorage.getItem(storageKey)).toBe("pinned");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mode survives logout and remount, but an open preview does not", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
|
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" })));
|
||||||
|
localStorage.setItem(storageKey, "hover");
|
||||||
const view = render(Layout, { props });
|
const view = render(Layout, { props });
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
|
await enter();
|
||||||
await logout();
|
await logout();
|
||||||
view.unmount();
|
view.unmount();
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
|
expect(folded()).toBe(true);
|
||||||
expect(localStorage.getItem(storageKey)).toBe("true");
|
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("invalid storage falls back to unfolded", () => {
|
test("touch does not hover; mobile header opens temporarily without changing mode", async () => {
|
||||||
localStorage.setItem(storageKey, "not-a-boolean");
|
const matchMedia = window.matchMedia.bind(window);
|
||||||
|
vi.spyOn(window, "matchMedia").mockImplementation((query) => {
|
||||||
|
const result = matchMedia(query);
|
||||||
|
Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" });
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
localStorage.setItem(storageKey, "hover");
|
||||||
|
const view = render(Layout, { props });
|
||||||
|
await fireEvent.pointerEnter(frame(), { pointerType: "touch" });
|
||||||
|
expect(folded()).toBe(true);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
expect(view.container.querySelector<HTMLElement>("main")!.inert).toBe(true);
|
||||||
|
await fireEvent.pointerLeave(frame(), { pointerType: "touch" });
|
||||||
|
await delay();
|
||||||
|
expect(folded()).toBe(false);
|
||||||
|
await fireEvent.click(screen.getByRole("button", { name: "Hide sidebar" }));
|
||||||
|
expect(folded()).toBe(true);
|
||||||
|
expect(view.container.querySelector<HTMLElement>("main")!.inert).toBe(false);
|
||||||
|
expect(localStorage.getItem(storageKey)).toBe("hover");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("invalid storage falls back to pinned", () => {
|
||||||
|
localStorage.setItem(storageKey, "not-a-mode");
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
|
expect(folded()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unavailable storage does not prevent folding", async () => {
|
test("unavailable storage does not prevent mode switching", async () => {
|
||||||
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
|
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); });
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Fold sidebar" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
|
||||||
expect(screen.getByRole("button", { name: "Unfold sidebar" })).not.toBeNull();
|
await delay();
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Show sidebar" }));
|
expect(folded()).toBe(true);
|
||||||
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
|
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
|
||||||
|
expect(folded()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("full storage still restores the saved value and allows toggles", async () => {
|
test("full storage still restores the mode and allows changes", async () => {
|
||||||
localStorage.setItem(storageKey, "true");
|
localStorage.setItem(storageKey, "hover");
|
||||||
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
|
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); });
|
||||||
render(Layout, { props });
|
render(Layout, { props });
|
||||||
await fireEvent.click(screen.getByRole("button", { name: "Unfold sidebar" }));
|
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
|
||||||
expect(screen.getByRole("button", { name: "Fold sidebar" })).not.toBeNull();
|
expect(folded()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,18 +93,18 @@ Deno.test("Shell regions use selected Bevel edges while open separators use Beve
|
|||||||
assert(
|
assert(
|
||||||
rootLayout.includes('class="app-shell__topbar-bevel"') &&
|
rootLayout.includes('class="app-shell__topbar-bevel"') &&
|
||||||
rootLayout.includes("top={false} right={false} left={false}") &&
|
rootLayout.includes("top={false} right={false} left={false}") &&
|
||||||
rootLayout.includes("bind:folded={sidebarFolded}") &&
|
rootLayout.includes("open={sidebarOpen}") &&
|
||||||
rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
|
rootLayout.includes("app-shell__mobile-sidebar-toggle") &&
|
||||||
rootLayout.includes("class:sidebar-open={!sidebarFolded}") &&
|
rootLayout.includes("class:sidebar-open={sidebarOpen}") &&
|
||||||
!rootLayout.includes("depth=") &&
|
!rootLayout.includes("depth=") &&
|
||||||
!rootLayout.includes("BevelLine") &&
|
!rootLayout.includes("BevelLine") &&
|
||||||
sidebarFrame.includes('class="sidebar-frame__bevel"') &&
|
sidebarFrame.includes('class="sidebar-frame__bevel"') &&
|
||||||
sidebarFrame.includes("top={false} bottom={false} left={false}") &&
|
sidebarFrame.includes("top={false} bottom={false} left={false}") &&
|
||||||
sidebarFrame.includes("$bindable(false)") &&
|
sidebarFrame.includes("class:folded={!open}") &&
|
||||||
!sidebarFrame.includes("depth=") &&
|
!sidebarFrame.includes("depth=") &&
|
||||||
!sidebarFrame.includes("BevelLine") &&
|
!sidebarFrame.includes("BevelLine") &&
|
||||||
sidebarCss.includes(".sidebar-frame.folded") &&
|
sidebarCss.includes(".sidebar-frame.folded") &&
|
||||||
sidebarCss.includes("display: none") &&
|
sidebarCss.includes("transform: translateX(-100%)") &&
|
||||||
sidebarCss.includes("display: grid"),
|
sidebarCss.includes("display: grid"),
|
||||||
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
|
"Desktop shell boundaries must use one selected border edge and Mobile must share Header fold state",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user