web: move sidebar fold into frame

This commit is contained in:
Keisuke Hirata 2026-07-23 21:59:47 +09:00
parent 60460b23dc
commit e975c648c2
No known key found for this signature in database
7 changed files with 121 additions and 108 deletions

View File

@ -393,6 +393,12 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const globalSidebar = await Deno.readTextFile(
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url),
);
const sidebarFrame = await Deno.readTextFile(
new URL("../sidebar/SidebarFrame.svelte", import.meta.url),
);
const sidebarCss = await Deno.readTextFile(
new URL("../sidebar/sidebar.css", import.meta.url),
);
const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
);
@ -432,6 +438,7 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
assert(
rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") &&
rootLayout.includes("SidebarFrame") &&
rootLayout.includes("{@render sidebar()}") &&
!rootLayout.includes("WorkspaceSidebar") &&
rootLayout.includes("workspace-topbar") &&
@ -459,14 +466,23 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
);
assert(
workspaceLayout.includes("sidebarFolded") &&
workspaceLayout.includes("onToggleFold={toggleSidebarFold}") &&
sidebar.includes("folded?: boolean") &&
sidebar.includes("onToggleFold?: () => void") &&
sidebar.includes("sidebar-fold-button") &&
sidebar.includes("Fold sidebar") &&
sidebar.includes("Unfold sidebar"),
"Workspace sidebar should keep a visible fold button with fold/folded naming",
sidebarFrame.includes("let folded = $state(false)") &&
sidebarFrame.includes("sidebar-fold-button") &&
sidebarFrame.includes("Fold sidebar") &&
sidebarFrame.includes("Unfold sidebar") &&
!workspaceLayout.includes("sidebarFolded") &&
!workspaceLayout.includes("onToggleFold") &&
!sidebar.includes("folded?: boolean") &&
!sidebar.includes("onToggleFold?: () => void") &&
!sidebar.includes("sidebar-fold-button"),
"Sidebar fold control should belong to SidebarFrame, not WorkspaceSidebar",
);
assert(
sidebarCss.startsWith("@layer reset, tokens, base, layout, components;") &&
sidebarCss.includes(".sidebar-frame") &&
sidebarCss.includes(".sidebar-link") &&
sidebarCss.includes("text-decoration: none"),
"Sidebar styles should define their layer order before component rules so base link styles do not win by import order",
);
assert(
sidebarOverride.includes("controller.setSidebar(sidebar)") &&

View File

@ -13,7 +13,7 @@
];
</script>
<aside class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section">
<p class="sidebar-section-label">Global</p>
<nav class="sidebar-list" aria-label="Global pages">
@ -29,4 +29,4 @@
{/each}
</nav>
</div>
</aside>
</div>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import './sidebar.css';
type Props = {
children: Snippet<[]>;
};
let { children }: Props = $props();
let folded = $state(false);
function toggleFold() {
folded = !folded;
}
</script>
<aside class="sidebar-frame" class:folded aria-label="Sidebar">
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={toggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</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>
</div>
{#if !folded}
<div class="sidebar-frame-content">
{@render children()}
</div>
{/if}
</aside>

View File

@ -13,8 +13,6 @@
repositories?: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
folded?: boolean;
onToggleFold?: () => void;
};
let {
@ -22,9 +20,7 @@
workspaceError = null,
repositories = null,
repositoriesError = null,
currentPath = '/',
folded = false,
onToggleFold
currentPath = '/'
}: Props = $props();
let workspaceId = $derived(workspace?.workspace_id ?? '');
@ -32,35 +28,8 @@
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script>
<aside
class:folded
class="workspace-sidebar"
aria-label="Workspace navigation"
>
<div class="workspace-sidebar">
<header class="sidebar-header">
<div class="sidebar-control-row">
<button
class="sidebar-fold-button"
type="button"
aria-label={folded ? 'Unfold sidebar' : 'Fold sidebar'}
aria-expanded={!folded}
title={folded ? 'Unfold sidebar' : 'Fold sidebar'}
onclick={onToggleFold}
>
{#if folded}
<svg class="sidebar-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="m6 17 5-5-5-5" />
<path d="m13 17 5-5-5-5" />
</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>
</div>
<div class="sidebar-title-row">
<div class="workspace-label">
{#if workspace}
@ -106,12 +75,10 @@
</div>
</header>
{#if !folded}
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
{/if}
</aside>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} />
</nav>
</div>

View File

@ -1,5 +1,7 @@
@layer reset, tokens, base, layout, components;
@layer components {
.global-sidebar {
.sidebar-frame {
grid-column: 1;
grid-row: 1 / 3;
width: clamp(220px, 20vw, 280px);
@ -9,24 +11,22 @@
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--line);
}
.global-sidebar-section {
.sidebar-frame.folded {
width: max-content;
overflow: hidden;
padding-inline: var(--space-2);
}
.sidebar-frame-content,
.global-sidebar,
.workspace-sidebar {
min-width: 0;
}
.global-sidebar,
.global-sidebar-section,
.workspace-sidebar {
display: grid;
gap: var(--space-2);
}
.workspace-sidebar {
grid-column: 1;
grid-row: 1 / 3;
width: clamp(220px, 20vw, 280px);
align-self: stretch;
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--line);
}
.workspace-sidebar.folded {
width: max-content;
}
.sidebar-header {
display: grid;
gap: var(--space-2);
@ -39,6 +39,13 @@
justify-content: flex-end;
gap: var(--space-1);
}
.sidebar-control-row {
margin-bottom: var(--space-2);
}
.sidebar-frame.folded .sidebar-control-row {
justify-content: center;
margin-bottom: 0;
}
.sidebar-title-row {
display: flex;
align-items: center;
@ -84,13 +91,13 @@
color: var(--text-muted);
text-decoration: none;
}
.sidebar-fold-button {
border: 1px solid var(--line);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
color: var(--text-strong);
cursor: pointer;
}
.sidebar-fold-button {
border: 1px solid var(--line);
background: var(--bg-raised);
box-shadow: var(--shadow-soft);
color: var(--text-strong);
cursor: pointer;
}
.sidebar-icon {
width: 18px;
height: 18px;
@ -107,25 +114,6 @@
background: var(--interactive-hover);
color: var(--text-muted);
}
.workspace-sidebar.folded {
overflow: hidden;
padding-inline: var(--space-2);
}
.workspace-sidebar.folded .sidebar-header {
margin-bottom: 0;
}
.workspace-sidebar.folded .sidebar-control-row,
.workspace-sidebar.folded .sidebar-title-row,
.workspace-sidebar.folded .sidebar-actions-row {
justify-content: center;
}
.workspace-sidebar.folded .sidebar-actions-row {
align-items: center;
flex-direction: column;
}
.workspace-sidebar.folded .workspace-label {
display: none;
}
.sidebar-sections {
display: grid;
gap: var(--space-5);
@ -249,8 +237,8 @@
}
@media (max-width: 760px) {
.global-sidebar,
.workspace-sidebar {
.sidebar-frame,
.sidebar-frame.folded {
grid-column: 1;
grid-row: 1;
width: auto;

View File

@ -3,6 +3,7 @@
import { setContext } from 'svelte';
import WorkspaceAlerts from '$lib/workspace/alerts/WorkspaceAlerts.svelte';
import GlobalSidebar from '$lib/workspace/sidebar/GlobalSidebar.svelte';
import SidebarFrame from '$lib/workspace/sidebar/SidebarFrame.svelte';
import { SIDEBAR_CONTEXT, type SidebarSnippet } from '$lib/workspace/sidebar/context';
import '../app.css';
import type { LayoutProps } from './$types';
@ -23,11 +24,13 @@
<WorkspaceAlerts />
<div class="workspace-layout">
{#if sidebar}
{@render sidebar()}
{:else}
<GlobalSidebar currentPath={page.url.pathname} />
{/if}
<SidebarFrame>
{#if sidebar}
{@render sidebar()}
{:else}
<GlobalSidebar currentPath={page.url.pathname} />
{/if}
</SidebarFrame>
<header class="workspace-topbar">
<nav class="workspace-topbar-actions" aria-label="Global navigation">
<a class="topbar-icon-button" href="/account" aria-label="Open Account" title="Account">

View File

@ -5,11 +5,6 @@
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let sidebarFolded = $state(false);
function toggleSidebarFold() {
sidebarFolded = !sidebarFolded;
}
</script>
{#snippet workspaceSidebar()}
@ -19,8 +14,6 @@
repositories={data.repositories ?? null}
repositoriesError={data.repositoriesError ?? null}
currentPath={page.url.pathname}
folded={sidebarFolded}
onToggleFold={toggleSidebarFold}
/>
{/snippet}