Move grouped navigation to top bar and remove help button
This commit is contained in:
@@ -1,81 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import type { UserRole } from '$lib/auth.js';
|
||||
import { navigationItemActive, navigationLocation, visibleNavigation } from '$lib/navigation.js';
|
||||
|
||||
let { role, open = $bindable(false) }: { role: UserRole; open?: boolean } = $props();
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
let drawer = $state<HTMLDialogElement>();
|
||||
let { role, variant = 'primary' }: { role: UserRole; variant?: 'primary' | 'secondary' } = $props();
|
||||
let navigation = $state<HTMLElement>();
|
||||
const groups = $derived(visibleNavigation(role));
|
||||
const location = $derived(navigationLocation($page.url.pathname, role));
|
||||
const storageKey = 'electric-cherry:navigation';
|
||||
|
||||
function save() {
|
||||
try { localStorage.setItem(storageKey, JSON.stringify(collapsed)); } catch { /* Navigation works without storage. */ }
|
||||
}
|
||||
function toggle(id: string) { collapsed = { ...collapsed, [id]: !collapsed[id] }; save(); }
|
||||
// Keep the current section or page visible in horizontally scrolling rows.
|
||||
$effect(() => {
|
||||
const path = $page.url.pathname;
|
||||
const currentRole = role;
|
||||
untrack(() => {
|
||||
const current = navigationLocation(path, currentRole);
|
||||
if (current) collapsed = { ...collapsed, [current.group.id]: false };
|
||||
const current = location;
|
||||
const node = navigation;
|
||||
if (!current || !node) return;
|
||||
let cancelled = false;
|
||||
let observer: ResizeObserver | undefined;
|
||||
void tick().then(() => {
|
||||
if (cancelled) return;
|
||||
const selected = node.querySelector<HTMLElement>('[aria-current]');
|
||||
if (!selected) return;
|
||||
const reveal = () => {
|
||||
const bounds = node.getBoundingClientRect();
|
||||
const target = selected.getBoundingClientRect();
|
||||
if (target.left < bounds.left) node.scrollLeft -= bounds.left - target.left;
|
||||
else if (target.right > bounds.right) node.scrollLeft += target.right - bounds.right;
|
||||
};
|
||||
reveal();
|
||||
observer = new ResizeObserver(reveal);
|
||||
observer.observe(node);
|
||||
});
|
||||
});
|
||||
$effect(() => {
|
||||
if (open && drawer && !drawer.open) drawer.showModal();
|
||||
else if (!open && drawer?.open) drawer.close();
|
||||
});
|
||||
onMount(() => {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) ?? '{}');
|
||||
if (saved && typeof saved === 'object') collapsed = Object.fromEntries(groups.map(group => [group.id, saved[group.id] === true]));
|
||||
} catch { /* Ignore invalid or unavailable preferences. */ }
|
||||
if (location) collapsed = { ...collapsed, [location.group.id]: false };
|
||||
const desktop = matchMedia('(min-width: 1000px)');
|
||||
const resized = () => { if (desktop.matches) open = false; };
|
||||
desktop.addEventListener('change', resized);
|
||||
return () => desktop.removeEventListener('change', resized);
|
||||
return () => { cancelled = true; observer?.disconnect(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet links(prefix: string)}
|
||||
<nav aria-label="Primary">
|
||||
{#if variant === 'primary'}
|
||||
<nav class="primary" aria-label="Primary" bind:this={navigation}>
|
||||
{#each groups as group (group.id)}
|
||||
<section class:current={location?.group.id === group.id}>
|
||||
<button class="group" aria-expanded={!collapsed[group.id]} aria-controls={`${prefix}-${group.id}`} onclick={() => toggle(group.id)}>
|
||||
{group.label}<span aria-hidden="true">{collapsed[group.id] ? '+' : '−'}</span>
|
||||
</button>
|
||||
<div id={`${prefix}-${group.id}`} hidden={collapsed[group.id]}>
|
||||
{#each group.items as item (item.href)}
|
||||
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<a href={group.items[0].href} aria-current={location?.group.id === group.id ? 'location' : undefined}>{group.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/snippet}
|
||||
|
||||
<aside class="sidebar">{@render links('desktop-navigation')}</aside>
|
||||
<dialog class="drawer" id="navigation-drawer" bind:this={drawer} aria-label="Navigation" onclose={() => open = false}
|
||||
onclick={event => { if (event.target === drawer) { const box = drawer!.getBoundingClientRect(); if (event.clientX > box.right || event.clientX < box.left || event.clientY < box.top || event.clientY > box.bottom) open = false; } }}>
|
||||
<div class="drawer-heading"><strong>Navigation</strong><button aria-label="Close navigation" onclick={() => open = false}>×</button></div>
|
||||
{@render links('mobile-navigation')}
|
||||
</dialog>
|
||||
{:else if location}
|
||||
<nav class="secondary" aria-label={`${location.group.label} pages`} bind:this={navigation}>
|
||||
{#each location.group.items as item (item.href)}
|
||||
<a href={item.href} aria-current={navigationItemActive(item, $page.url.pathname) ? 'page' : undefined}>{item.label}</a>
|
||||
{/each}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.sidebar { position: fixed; inset: 60px auto 0 0; width: 232px; overflow-y: auto; overscroll-behavior: contain; background: var(--surface); border-right: 1px solid var(--border-subtle); padding: 16px 12px 32px; z-index: 15; }
|
||||
nav { display: grid; gap: 12px; }
|
||||
.group { display: flex; justify-content: space-between; align-items: center; width: 100%; padding: 8px 12px; color: var(--text-secondary); font: inherit; font-size: var(--text-xs); font-weight: 600; background: none; border: 0; border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.current .group { color: var(--text-primary); }
|
||||
a { display: block; padding: 8px 12px; border-radius: var(--radius-sm); color: var(--text-secondary); text-decoration: none; font-size: var(--text-sm); line-height: 1.5; }
|
||||
a:hover, .group:hover { background: var(--surface-raised); color: var(--text-primary); }
|
||||
a[aria-current] { color: var(--accent-primary-hover); background: color-mix(in oklab, var(--accent-primary) 12%, var(--surface)); box-shadow: inset 3px 0 var(--accent-primary); }
|
||||
.drawer { position: fixed; inset: 0 auto 0 0; margin: 0; width: min(320px, 90vw); height: 100dvh; max-height: 100dvh; border: 0; border-right: 1px solid var(--border-strong); border-radius: 0; padding: max(16px, env(safe-area-inset-top)) 16px max(24px, env(safe-area-inset-bottom)); color: var(--text-primary); background: var(--surface); }
|
||||
.drawer::backdrop { background: #0008; }
|
||||
.drawer-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.drawer-heading button { width: 44px; height: 44px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); background: var(--surface-raised); color: inherit; font: inherit; font-size: 24px; cursor: pointer; }
|
||||
.drawer a, .drawer .group { min-height: 44px; display: flex; align-items: center; }
|
||||
@media (max-width: 999px) { .sidebar { display: none; } }
|
||||
nav { display:flex; align-items:stretch; gap:4px; min-width:0; max-width:100%; overflow-x:auto; overscroll-behavior-x:contain; scrollbar-width:thin; }
|
||||
a { display:flex; align-items:center; flex-shrink:0; min-height:44px; padding:10px 16px; border-bottom:2px solid transparent; color:var(--text-secondary); text-decoration:none; white-space:nowrap; font-size:var(--text-sm); font-weight:500; }
|
||||
a:hover { color:var(--text-primary); background:var(--surface-raised); }
|
||||
a:focus-visible { outline-offset:-3px; }
|
||||
a[aria-current] { color:var(--accent-primary-hover); border-bottom-color:var(--accent-primary); }
|
||||
.primary a { min-height:59px; }
|
||||
.secondary { margin-bottom:var(--space-6); border-bottom:1px solid var(--border-subtle); }
|
||||
@media (max-width:999px) { .primary a { min-height:48px; } }
|
||||
@media (max-width:720px) { a { padding-inline:12px; } }
|
||||
</style>
|
||||
|
||||
+12
-32
@@ -7,7 +7,7 @@
|
||||
import { startFleetMonitor, hydrateFleet } from '$ui/stores/fleet.js';
|
||||
import { page } from '$app/stores';
|
||||
import Navigation from '$ui/components/Navigation.svelte';
|
||||
import { navigationLocation, visibleNavigation } from '$lib/navigation.js';
|
||||
import { visibleNavigation } from '$lib/navigation.js';
|
||||
import BrandMark from '$ui/components/BrandMark.svelte';
|
||||
import Confirmation from '$ui/components/Confirmation.svelte';
|
||||
import Dialog from '$ui/components/primitives/Dialog.svelte';
|
||||
@@ -23,12 +23,10 @@
|
||||
let { children, data }: Props = $props();
|
||||
const publicPage = $derived(['/login', '/setup'].includes($page.url.pathname));
|
||||
const sessionReady = $derived(!!data.user && !data.user.mustChangePassword && !publicPage);
|
||||
let menuOpen = $state(false);
|
||||
let accountOpen = $state(false);
|
||||
let signingOut = $state(false);
|
||||
let signOutError = $state('');
|
||||
let helpOpen = $state(false);
|
||||
let menuButton = $state<HTMLButtonElement>();
|
||||
let accountButton = $state<HTMLButtonElement>();
|
||||
const accountName = $derived(data.user?.displayName || data.user?.username || 'Account');
|
||||
const accountInitial = $derived(Array.from(accountName.trim())[0]?.toLocaleUpperCase() || '?');
|
||||
@@ -38,20 +36,15 @@
|
||||
}
|
||||
|
||||
function openHelp() {
|
||||
if (menuOpen) { menuButton?.focus(); menuOpen = false; }
|
||||
helpOpen = true;
|
||||
}
|
||||
|
||||
beforeNavigate(() => performance.mark('ec:navigation-start'));
|
||||
afterNavigate(() => { if (performance.getEntriesByName('ec:navigation-start').length) { performance.measure('ec:navigation', 'ec:navigation-start'); performance.clearMarks('ec:navigation-start'); } });
|
||||
afterNavigate(() => { menuOpen = false; accountOpen = false; resolveConfirmation(false); });
|
||||
afterNavigate(() => { accountOpen = false; resolveConfirmation(false); });
|
||||
|
||||
function closeMenu(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape' && accountOpen) { accountOpen = false; accountButton?.focus(); }
|
||||
if (event.key === 'Escape' && menuOpen) {
|
||||
menuOpen = false;
|
||||
menuButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
@@ -122,13 +115,12 @@
|
||||
// Non-Chromium browsers fall through to plain navigation.
|
||||
onNavigate(enableViewTransitions);
|
||||
|
||||
const location = $derived(navigationLocation($page.url.pathname, data.user?.role ?? 'viewer'));
|
||||
const shortcuts = $derived(visibleNavigation(data.user?.role ?? 'viewer').flatMap(group => group.items).filter(item => item.shortcut));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={closeMenu} onclick={dismissAccount} />
|
||||
|
||||
<div class="shell" class:with-navigation={sessionReady}>
|
||||
<div class="shell">
|
||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||
{#if publicPage}
|
||||
<header class="auth-brand"><BrandMark size="md" /></header>
|
||||
@@ -137,21 +129,14 @@
|
||||
<a class="brand-link" href={sessionReady ? '/' : '/account'} aria-label="Electric Cherry home">
|
||||
<BrandMark size="sm" />
|
||||
</a>
|
||||
{#if sessionReady && location}<div class="location"><span>{location.group.label}</span><span aria-hidden="true"> / </span><strong>{location.item.label}</strong></div>{/if}
|
||||
{#if sessionReady && data.user}<div class="primary-navigation"><Navigation role={data.user.role} /></div>{/if}
|
||||
<div class="header-actions">
|
||||
{#if sessionReady}<button class="help-button" onclick={openHelp} aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)">?</button>{/if}
|
||||
{#if sessionReady}<button class="menu-toggle" bind:this={menuButton} type="button"
|
||||
aria-label={menuOpen ? 'Close menu' : 'Menu'} aria-expanded={menuOpen} aria-controls="navigation-drawer"
|
||||
onclick={() => { menuOpen = !menuOpen; accountOpen = false; }}>
|
||||
<span class="menu-label">{menuOpen ? 'Close' : 'Menu'}</span>
|
||||
<span aria-hidden="true">{menuOpen ? '×' : '☰'}</span>
|
||||
</button>{/if}
|
||||
{#if data.user}
|
||||
<div class="account-menu">
|
||||
<button bind:this={accountButton} class="account-button" type="button"
|
||||
aria-label={`Account: ${accountName}`} title={accountName}
|
||||
aria-expanded={accountOpen} aria-controls="account-links"
|
||||
onclick={() => { accountOpen = !accountOpen; menuOpen = false; }}>
|
||||
onclick={() => accountOpen = !accountOpen}>
|
||||
<span aria-hidden="true">{accountInitial}</span>
|
||||
</button>
|
||||
{#if accountOpen}
|
||||
@@ -167,9 +152,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
{#if sessionReady && data.user}<Navigation role={data.user.role} bind:open={menuOpen} />{/if}
|
||||
{/if}
|
||||
<main id="main-content" tabindex="-1" class:auth-main={publicPage}>
|
||||
{#if sessionReady && data.user}<Navigation role={data.user.role} variant="secondary" />{/if}
|
||||
{#if sessionReady && data.user?.role === 'viewer'}<aside class="viewer-note"><strong>Viewer access</strong><span>You can read fleet status and summaries. Operating servers or viewing raw configs, file changes, logs, and telemetry requires an operator account.</span></aside>{/if}
|
||||
{@render children()}
|
||||
</main>
|
||||
@@ -221,24 +206,21 @@
|
||||
.shell { min-height: 100vh; }
|
||||
.auth-brand { display:flex; justify-content:center; padding:48px 20px 20px; }
|
||||
main { min-width:0; overflow-wrap:anywhere; padding:var(--space-6) var(--space-8) var(--space-16); max-width:1600px; margin:0 auto; view-transition-name:main; }
|
||||
.with-navigation main { margin-left:232px; }
|
||||
main.auth-main { max-width:520px; padding-top:16px; }
|
||||
.viewer-note { display:flex; gap:8px 16px; flex-wrap:wrap; padding:14px 18px; margin-bottom:24px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); font-size:var(--text-sm); color:var(--text-secondary); }
|
||||
.viewer-note strong { color:var(--text-primary); }
|
||||
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:24px; height:60px; padding:0 20px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
|
||||
.app-header { position:sticky; top:0; z-index:20; display:flex; align-items:center; gap:24px; min-height:60px; padding:0 20px; border-bottom:1px solid var(--border-subtle); background:var(--surface); }
|
||||
.brand-link { flex-shrink:0; display:flex; align-items:center; text-decoration:none; }
|
||||
.location { font-size:var(--text-sm); color:var(--text-secondary); min-width:0; }
|
||||
.location strong { color:var(--text-primary); font-weight:500; }
|
||||
.primary-navigation { flex:1; min-width:0; }
|
||||
.header-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
|
||||
.account-menu { position:relative; }
|
||||
.account-button,.help-button,.menu-toggle { display:inline-flex; align-items:center; justify-content:center; min-width:44px; height:44px; padding:8px 12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); font:inherit; cursor:pointer; }
|
||||
.account-button { display:inline-flex; align-items:center; justify-content:center; min-width:44px; height:44px; padding:8px 12px; border:1px solid var(--border-strong); border-radius:var(--radius-md); background:var(--surface); color:var(--text-primary); font:inherit; cursor:pointer; }
|
||||
.account-button { border-radius:50%; color:var(--accent-primary-hover); background:color-mix(in oklab,var(--accent-primary) 12%,var(--surface)); font-weight:600; }
|
||||
.account-links { position:absolute; right:0; top:calc(100% + 8px); width:220px; max-width:calc(100vw - 32px); max-height:calc(100dvh - 84px); overflow-y:auto; z-index:30; display:grid; padding:8px; background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-md); box-shadow:0 12px 32px #0005; }
|
||||
.account-name { padding:8px 12px 12px; margin-bottom:4px; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); font-size:var(--text-sm); overflow-wrap:anywhere; }
|
||||
.account-links a,.account-links button { text-align:left; padding:10px 12px; color:var(--text-primary); background:none; border:0; border-radius:var(--radius-sm); font:inherit; font-size:var(--text-sm); cursor:pointer; text-decoration:none; }
|
||||
.account-links a:hover,.account-links button:hover,.help-button:hover,.menu-toggle:hover { background:var(--surface-raised); }
|
||||
.account-links a:hover,.account-links button:hover { background:var(--surface-raised); }
|
||||
.account-links p { padding:0 12px; font-size:var(--text-xs); color:var(--state-danger); }
|
||||
.menu-toggle { display:none; gap:8px; }
|
||||
.shortcuts { display:grid; gap:12px; margin:0; }
|
||||
.shortcuts > div { display:flex; gap:20px; align-items:center; }
|
||||
.shortcuts dt { min-width:55px; }
|
||||
@@ -250,14 +232,12 @@
|
||||
:global(input),:global(select),:global(textarea),:global(button),:global(a) { scroll-margin-block:90px 100px; }
|
||||
:global(dialog) { max-height:calc(100dvh - 32px); overflow-y:auto; overscroll-behavior:contain; }
|
||||
@media (max-width:999px) {
|
||||
.with-navigation main { margin-inline:auto; }
|
||||
.menu-toggle { display:inline-flex; }
|
||||
.location { display:none; }
|
||||
.app-header { flex-wrap:wrap; gap:0 16px; padding-top:8px; }
|
||||
.primary-navigation { order:3; flex-basis:100%; }
|
||||
}
|
||||
@media (max-width:720px) {
|
||||
.app-header { padding-inline:12px; gap:8px; }
|
||||
.brand-link :global(.mark-sm .word) { font-size:12px; letter-spacing:1px; }
|
||||
.menu-label { display:none; }
|
||||
main { padding:var(--space-5) max(var(--space-4),env(safe-area-inset-right)) calc(var(--space-16) + env(safe-area-inset-bottom)) max(var(--space-4),env(safe-area-inset-left)); }
|
||||
:global(input:not([type='checkbox']):not([type='radio'])),:global(select),:global(textarea) { font-size:16px !important; min-height:44px; }
|
||||
:global(button),:global(.btn) { min-height:44px; }
|
||||
|
||||
Reference in New Issue
Block a user