web: add global sidebar fallback

This commit is contained in:
Keisuke Hirata 2026-07-23 19:23:02 +09:00
parent 2bd69f9403
commit 308e1ffdbc
No known key found for this signature in database
5 changed files with 99 additions and 14 deletions

View File

@ -133,6 +133,15 @@
min-width: 0; min-width: 0;
} }
.app-shell { .app-shell {
display: grid;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.app-shell.global-sidebar-layout {
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
}
.app-content {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
@ -214,14 +223,31 @@
gap: var(--space-4); gap: var(--space-4);
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.workspace-layout { .app-layout {
grid-template-columns: 1fr;
grid-template-rows: auto auto 1fr;
width: 100vw;
height: auto; height: auto;
min-height: 100dvh; min-height: 100dvh;
overflow: visible; overflow: visible;
} }
.app-shell,
.app-shell.global-sidebar-layout {
grid-template-columns: 1fr;
overflow: visible;
}
.app-content {
overflow: visible;
}
.global-sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.workspace-layout {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
width: 100%;
height: auto;
min-height: 0;
overflow: visible;
}
.workspace-layout.sidebar-collapsed { .workspace-layout.sidebar-collapsed {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -232,19 +258,28 @@
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.workspace-topbar { .workspace-topbar {
grid-column: 1;
grid-row: 2;
padding: 0 var(--space-4); padding: 0 var(--space-4);
} }
.shell { .shell {
grid-column: 1; grid-column: 1;
grid-row: 3; grid-row: 2;
overflow: visible; overflow: visible;
padding: var(--space-5) var(--space-4); padding: var(--space-5) var(--space-4);
} }
} }
} }
@layer components { @layer components {
.global-sidebar {
min-width: 0;
min-height: 0;
overflow-y: auto;
padding: var(--space-4) var(--space-3);
border-right: 1px solid var(--line);
}
.global-sidebar-section {
display: grid;
gap: var(--space-2);
}
.workspace-sidebar { .workspace-sidebar {
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;

View File

@ -390,6 +390,9 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
const rootLayoutLoad = await Deno.readTextFile( const rootLayoutLoad = await Deno.readTextFile(
new URL("./../../../routes/+layout.ts", import.meta.url), new URL("./../../../routes/+layout.ts", import.meta.url),
); );
const globalSidebar = await Deno.readTextFile(
new URL("../sidebar/GlobalSidebar.svelte", 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),
); );
@ -424,6 +427,8 @@ 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("global-sidebar-layout") &&
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"') &&
@ -433,6 +438,14 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
!sidebar.includes("Open Account"), !sidebar.includes("Open Account"),
"Account navigation should live in the global layout header, not the workspace sidebar", "Account navigation should live in the global layout header, not the workspace sidebar",
); );
assert(
globalSidebar.includes("Global") &&
globalSidebar.includes("/account") &&
globalSidebar.includes("/login/device") &&
!globalSidebar.includes("Tickets") &&
!globalSidebar.includes("Repositories"),
"Root default sidebar should contain only global navigation, not workspace-scoped sections",
);
assert( assert(
workspaceLayout.includes("WorkspaceSidebar") && workspaceLayout.includes("WorkspaceSidebar") &&
workspaceLayout.includes("workspace={data.workspace}") && workspaceLayout.includes("workspace={data.workspace}") &&

View File

@ -0,0 +1,30 @@
<script lang="ts">
type Props = {
currentPath: string;
};
const { currentPath }: Props = $props();
const items = [
{ href: '/account', label: 'Account' },
{ href: '/login/device', label: 'Device Login' },
];
</script>
<aside 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">
{#each items as item}
<a
class="sidebar-link"
class:active={currentPath === item.href}
href={item.href}
aria-current={currentPath === item.href ? 'page' : undefined}
>
<span>{item.label}</span>
</a>
{/each}
</nav>
</div>
</aside>

View File

@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
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 '../app.css'; import '../app.css';
import type { LayoutProps } from './$types'; import type { LayoutProps } from './$types';
let { children }: LayoutProps = $props(); let { data, children }: LayoutProps = $props();
</script> </script>
<WorkspaceAlerts /> <WorkspaceAlerts />
@ -19,7 +21,12 @@
</a> </a>
</nav> </nav>
</header> </header>
<div class="app-shell"> <div class:global-sidebar-layout={!data.workspaceScoped} class="app-shell">
{#if !data.workspaceScoped}
<GlobalSidebar currentPath={page.url.pathname} />
{/if}
<div class="app-content">
{@render children()} {@render children()}
</div> </div>
</div>
</div> </div>

View File

@ -8,12 +8,12 @@ export const prerender = false;
export const load: LayoutLoad = async ({ fetch, params, url }) => { export const load: LayoutLoad = async ({ fetch, params, url }) => {
if (params.workspaceId) { if (params.workspaceId) {
return {}; return { workspaceScoped: true };
} }
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 {}; return { workspaceScoped: false };
} }
const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace"); const workspace = await loadJson<WorkspaceResponse>(fetch, "/api/workspace");
@ -21,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 {}; return { workspaceScoped: false };
}; };