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( const globalSidebar = await Deno.readTextFile(
new URL("../sidebar/GlobalSidebar.svelte", import.meta.url), 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( const workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), 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( assert(
rootLayout.includes("SIDEBAR_CONTEXT") && rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("GlobalSidebar") && rootLayout.includes("GlobalSidebar") &&
rootLayout.includes("SidebarFrame") &&
rootLayout.includes("{@render sidebar()}") && rootLayout.includes("{@render sidebar()}") &&
!rootLayout.includes("WorkspaceSidebar") && !rootLayout.includes("WorkspaceSidebar") &&
rootLayout.includes("workspace-topbar") && 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", "Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
); );
assert( assert(
workspaceLayout.includes("sidebarFolded") && sidebarFrame.includes("let folded = $state(false)") &&
workspaceLayout.includes("onToggleFold={toggleSidebarFold}") && sidebarFrame.includes("sidebar-fold-button") &&
sidebar.includes("folded?: boolean") && sidebarFrame.includes("Fold sidebar") &&
sidebar.includes("onToggleFold?: () => void") && sidebarFrame.includes("Unfold sidebar") &&
sidebar.includes("sidebar-fold-button") && !workspaceLayout.includes("sidebarFolded") &&
sidebar.includes("Fold sidebar") && !workspaceLayout.includes("onToggleFold") &&
sidebar.includes("Unfold sidebar"), !sidebar.includes("folded?: boolean") &&
"Workspace sidebar should keep a visible fold button with fold/folded naming", !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( assert(
sidebarOverride.includes("controller.setSidebar(sidebar)") && sidebarOverride.includes("controller.setSidebar(sidebar)") &&

View File

@ -13,7 +13,7 @@
]; ];
</script> </script>
<aside class="global-sidebar" aria-label="Global navigation"> <div class="global-sidebar" aria-label="Global navigation">
<div class="global-sidebar-section"> <div class="global-sidebar-section">
<p class="sidebar-section-label">Global</p> <p class="sidebar-section-label">Global</p>
<nav class="sidebar-list" aria-label="Global pages"> <nav class="sidebar-list" aria-label="Global pages">
@ -29,4 +29,4 @@
{/each} {/each}
</nav> </nav>
</div> </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; repositories?: RepositoryListResponse | null;
repositoriesError?: string | null; repositoriesError?: string | null;
currentPath?: string; currentPath?: string;
folded?: boolean;
onToggleFold?: () => void;
}; };
let { let {
@ -22,9 +20,7 @@
workspaceError = null, workspaceError = null,
repositories = null, repositories = null,
repositoriesError = null, repositoriesError = null,
currentPath = '/', currentPath = '/'
folded = false,
onToggleFold
}: Props = $props(); }: Props = $props();
let workspaceId = $derived(workspace?.workspace_id ?? ''); let workspaceId = $derived(workspace?.workspace_id ?? '');
@ -32,35 +28,8 @@
let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings'); let settingsHref = $derived(workspaceId ? workspaceRoute(workspaceId, '/settings') : '/settings');
</script> </script>
<aside <div class="workspace-sidebar">
class:folded
class="workspace-sidebar"
aria-label="Workspace navigation"
>
<header class="sidebar-header"> <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="sidebar-title-row">
<div class="workspace-label"> <div class="workspace-label">
{#if workspace} {#if workspace}
@ -106,12 +75,10 @@
</div> </div>
</header> </header>
{#if !folded} <nav class="sidebar-sections" aria-label="Workspace sections">
<nav class="sidebar-sections" aria-label="Workspace sections"> <RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} />
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} {workspaceId} /> <ObjectivesNavSection {currentPath} {workspaceId} />
<ObjectivesNavSection {currentPath} {workspaceId} /> <TicketsNavSection {currentPath} {workspaceId} />
<TicketsNavSection {currentPath} {workspaceId} /> <WorkersNavSection {currentPath} {workspaceId} />
<WorkersNavSection {currentPath} {workspaceId} /> </nav>
</nav> </div>
{/if}
</aside>

View File

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

View File

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

View File

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