web: register sidebar snippets from layouts

This commit is contained in:
Keisuke Hirata 2026-07-23 20:01:54 +09:00
parent b3854004a1
commit fb4d3c3f1f
No known key found for this signature in database
8 changed files with 137 additions and 40 deletions

View File

@ -214,7 +214,7 @@
grid-template-rows: auto auto 1fr; grid-template-rows: auto auto 1fr;
width: 100%; width: 100%;
height: auto; height: auto;
min-height: 0; min-height: 100dvh;
overflow: visible; overflow: visible;
} }
.workspace-layout.sidebar-collapsed { .workspace-layout.sidebar-collapsed {

View File

@ -393,6 +393,15 @@ 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 workspaceLayout = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url),
);
const workspaceLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/w/[workspaceId]/+layout.ts", import.meta.url),
);
const sidebarOverride = await Deno.readTextFile(
new URL("../sidebar/SidebarOverride.svelte", import.meta.url),
);
const sidebar = await Deno.readTextFile( const sidebar = await Deno.readTextFile(
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url), new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
); );
@ -421,16 +430,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Auth model should stay on Backend auth APIs rather than workspace authorization APIs", "Auth model should stay on Backend auth APIs rather than workspace authorization APIs",
); );
assert( assert(
rootLayout.includes("GlobalSidebar") && rootLayout.includes("SIDEBAR_CONTEXT") &&
rootLayout.includes("WorkspaceSidebar") && rootLayout.includes("GlobalSidebar") &&
rootLayout.includes("data.workspaceScoped") && rootLayout.includes("{@render sidebar()}") &&
!rootLayout.includes("WorkspaceSidebar") &&
rootLayout.includes("workspace-topbar") && rootLayout.includes("workspace-topbar") &&
rootLayout.includes("topbar-icon-button") && rootLayout.includes("topbar-icon-button") &&
rootLayout.includes('href="/account"') && rootLayout.includes('href="/account"') &&
rootLayout.includes("Open Account") && rootLayout.includes("Open Account") &&
!sidebar.includes("accountHref") && !sidebar.includes("accountHref") &&
!sidebar.includes("Open Account"), !sidebar.includes("Open Account"),
"Root layout chrome should choose GlobalSidebar or WorkspaceSidebar while account navigation stays in the header", "Root layout chrome should render a registered sidebar snippet or default global sidebar while account navigation stays in the header",
); );
assert( assert(
globalSidebar.includes("Global") && globalSidebar.includes("Global") &&
@ -441,10 +451,17 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
"Root default sidebar should contain only global navigation, not workspace-scoped sections", "Root default sidebar should contain only global navigation, not workspace-scoped sections",
); );
assert( assert(
rootLayoutLoad.includes("params.workspaceId") && workspaceLayout.includes("{#snippet workspaceSidebar()}") &&
rootLayoutLoad.includes("workspaceApiPath(workspaceId") && workspaceLayout.includes("WorkspaceSidebar") &&
rootLayoutLoad.includes("workspaceScoped: true"), workspaceLayout.includes("<SidebarOverride sidebar={workspaceSidebar} />") &&
"Root layout load should provide workspace-scoped sidebar data when a workspace route is active", workspaceLayoutLoad.includes("params.workspaceId") &&
workspaceLayoutLoad.includes("workspaceApiPath(workspaceId"),
"Workspace layout should load workspace data and register a WorkspaceSidebar snippet",
);
assert(
sidebarOverride.includes("controller.setSidebar(sidebar)") &&
sidebarOverride.includes("controller.clearSidebar(sidebar)"),
"SidebarOverride should register and clean up the child-provided sidebar snippet",
); );
assert( assert(
rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'), rootLayoutLoad.includes('"/account"') && rootLayoutLoad.includes('"/login/device"'),

View File

@ -0,0 +1,15 @@
<script lang="ts">
import { getSidebarController, type SidebarSnippet } from './context';
type Props = {
sidebar: SidebarSnippet;
};
const { sidebar }: Props = $props();
const controller = getSidebarController();
$effect(() => {
controller.setSidebar(sidebar);
return () => controller.clearSidebar(sidebar);
});
</script>

View File

@ -0,0 +1,16 @@
import { getContext } from "svelte";
import type { Snippet } from "svelte";
export type SidebarSnippet = Snippet<[]>;
export type SidebarController = {
setSidebar(snippet: SidebarSnippet): void;
clearSidebar(snippet: SidebarSnippet): void;
setCollapsed(collapsed: boolean): void;
};
export const SIDEBAR_CONTEXT = Symbol("yoi-sidebar-context");
export function getSidebarController(): SidebarController {
return getContext<SidebarController>(SIDEBAR_CONTEXT);
}

View File

@ -1,28 +1,35 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
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 WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte'; 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';
let { data, children }: LayoutProps = $props(); let { children }: LayoutProps = $props();
let sidebar = $state<SidebarSnippet | null>(null);
let sidebarCollapsed = $state(false); let sidebarCollapsed = $state(false);
setContext(SIDEBAR_CONTEXT, {
setSidebar(snippet: SidebarSnippet) {
sidebar = snippet;
},
clearSidebar(snippet: SidebarSnippet) {
if (sidebar === snippet) sidebar = null;
sidebarCollapsed = false;
},
setCollapsed(collapsed: boolean) {
sidebarCollapsed = collapsed;
},
});
</script> </script>
<WorkspaceAlerts /> <WorkspaceAlerts />
<div class:sidebar-collapsed={data.workspaceScoped && sidebarCollapsed} class="workspace-layout"> <div class:sidebar-collapsed={sidebarCollapsed} class="workspace-layout">
{#if data.workspaceScoped} {#if sidebar}
<WorkspaceSidebar {@render sidebar()}
workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null}
repositories={data.repositories ?? null}
repositoriesError={data.repositoriesError ?? null}
currentPath={page.url.pathname}
collapsed={sidebarCollapsed}
onToggleCollapsed={() => (sidebarCollapsed = !sidebarCollapsed)}
/>
{:else} {:else}
<GlobalSidebar currentPath={page.url.pathname} /> <GlobalSidebar currentPath={page.url.pathname} />
{/if} {/if}

View File

@ -1,31 +1,19 @@
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath, workspaceRoute } from "$lib/workspace/api/http"; import { loadJson, workspaceRoute } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types"; import type { WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const ssr = false; export const ssr = false;
export const prerender = false; export const prerender = false;
export const load: LayoutLoad = async ({ fetch, params, url }) => { export const load: LayoutLoad = async ({ fetch, params, url }) => {
const workspaceId = params.workspaceId; if (params.workspaceId) {
if (workspaceId) { return {};
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {
workspaceScoped: true,
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
} }
const publicRoutes = new Set(["/account", "/login/device"]); const publicRoutes = new Set(["/account", "/login/device"]);
if (publicRoutes.has(url.pathname)) { if (publicRoutes.has(url.pathname)) {
return { workspaceScoped: false }; return {};
} }
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace"); const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
@ -33,5 +21,5 @@ export const load: LayoutLoad = async ({ fetch, params, url }) => {
const scopedPath = workspaceRoute(workspace.data.workspace_id); const scopedPath = workspaceRoute(workspace.data.workspace_id);
throw redirect(307, `${scopedPath}${url.search}`); throw redirect(307, `${scopedPath}${url.search}`);
} }
return { workspaceScoped: false }; return {};
}; };

View File

@ -0,0 +1,35 @@
<script lang="ts">
import { page } from '$app/state';
import SidebarOverride from '$lib/workspace/sidebar/SidebarOverride.svelte';
import WorkspaceSidebar from '$lib/workspace/sidebar/WorkspaceSidebar.svelte';
import { getSidebarController } from '$lib/workspace/sidebar/context';
import type { LayoutProps } from './$types';
let { data, children }: LayoutProps = $props();
let sidebarCollapsed = $state(false);
const sidebarController = getSidebarController();
function toggleSidebar() {
sidebarCollapsed = !sidebarCollapsed;
}
$effect(() => {
sidebarController.setCollapsed(sidebarCollapsed);
});
</script>
{#snippet workspaceSidebar()}
<WorkspaceSidebar
workspace={data.workspace ?? null}
workspaceError={data.workspaceError ?? null}
repositories={data.repositories ?? null}
repositoriesError={data.repositoriesError ?? null}
currentPath={page.url.pathname}
collapsed={sidebarCollapsed}
onToggleCollapsed={toggleSidebar}
/>
{/snippet}
<SidebarOverride sidebar={workspaceSidebar} />
{@render children()}

View File

@ -0,0 +1,19 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import type { RepositoryListResponse, WorkspaceResponse } from "$lib/workspace/sidebar/types";
import type { LayoutLoad } from "./$types";
export const load: LayoutLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const apiPath = (path: string) => workspaceApiPath(workspaceId, path);
const [workspace, repositories] = await Promise.all([
loadJson<WorkspaceResponse>(fetch, apiPath("/workspace")),
loadJson<RepositoryListResponse>(fetch, apiPath("/repositories")),
]);
return {
workspace: workspace.data,
workspaceError: workspace.error,
repositories: repositories.data,
repositoriesError: repositories.error,
};
};