feat(ui): add global feedback and agent menu
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { user, isacAccess, notificationSummary, children }: {
|
||||
user: { username: string; displayName: string; role: string } | null;
|
||||
user: { username: string; displayName: string; role: string; avatarUrl: string } | null;
|
||||
isacAccess: boolean;
|
||||
notificationSummary: { unread: number };
|
||||
children: Snippet;
|
||||
@@ -22,6 +22,9 @@
|
||||
let showCookieNotice = $state(false);
|
||||
let mobileOpen = $state(false);
|
||||
let mobilePanel = $state<HTMLElement | null>(null);
|
||||
let accountOpen = $state(false);
|
||||
let accountButton = $state<HTMLButtonElement | null>(null);
|
||||
let accountMenu = $state<HTMLElement | null>(null);
|
||||
let unreadNotifications = $state(0);
|
||||
$effect(() => { unreadNotifications = notificationSummary.unread; });
|
||||
|
||||
@@ -70,15 +73,33 @@
|
||||
event.preventDefault();
|
||||
closeMobileNavigation();
|
||||
}
|
||||
if (event.key === 'Escape' && accountOpen) {
|
||||
event.preventDefault();
|
||||
accountOpen = false;
|
||||
accountButton?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAccountMenu() {
|
||||
accountOpen = !accountOpen;
|
||||
if (accountOpen) {
|
||||
await tick();
|
||||
accountMenu?.querySelector<HTMLElement>('a')?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function closeAccountFromOutside(event: MouseEvent) {
|
||||
if (accountOpen && !(event.target as Element).closest('.desktop-agent-menu')) accountOpen = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
page.url.pathname;
|
||||
mobileOpen = false;
|
||||
accountOpen = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleNavigationKey}/>
|
||||
<svelte:window onkeydown={handleNavigationKey} onclick={closeAccountFromOutside}/>
|
||||
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/" aria-label="SHD.build home">
|
||||
@@ -88,18 +109,36 @@
|
||||
{#each links as link}
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}>{link.label}</a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname === '/account'} href="/account">My Profile</a>
|
||||
{/if}
|
||||
{#if isacAccess}
|
||||
<a class:active={page.url.pathname.startsWith('/isac')} href="/isac">ISAC</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{#if user}
|
||||
<div class="account">
|
||||
<a class="notification-link" href="/account/notifications" aria-label={`${unreadNotifications} unread notifications`}><span>◆</span>{#if unreadNotifications}<b>{unreadNotifications > 99 ? '99+' : unreadNotifications}</b>{/if}</a>
|
||||
<a href="/account"><small><i></i> AGENT ONLINE</small>{user.displayName}</a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button aria-busy={pending}>SIGN OUT →</button>{/snippet}</PendingForm>
|
||||
<div class="desktop-agent-menu">
|
||||
<button
|
||||
class="desktop-agent-trigger"
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={accountOpen}
|
||||
aria-controls="desktop-agent-menu"
|
||||
bind:this={accountButton}
|
||||
onclick={toggleAccountMenu}
|
||||
>
|
||||
<img src={user.avatarUrl} alt=""/>
|
||||
<span><small><i></i> AGENT ONLINE</small><b>{user.displayName}</b></span>
|
||||
{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{/if}
|
||||
<strong aria-hidden="true">{accountOpen ? '−' : '+'}</strong>
|
||||
</button>
|
||||
{#if accountOpen}
|
||||
<div id="desktop-agent-menu" class="desktop-agent-panel" role="menu" bind:this={accountMenu}>
|
||||
<header><img src={user.avatarUrl} alt=""/><div><small>ACTIVE AGENT</small><b>{user.displayName}</b><span>{user.role.toUpperCase()} ACCESS</span></div></header>
|
||||
<a role="menuitem" class:active={page.url.pathname === '/account'} href="/account"><span>MY PROFILE</span><b>AGENT DOSSIER →</b></a>
|
||||
<a role="menuitem" class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><span>NOTIFICATIONS</span><b>{unreadNotifications ? `${unreadNotifications > 99 ? '99+' : unreadNotifications} UNREAD` : 'INBOX CLEAR'} →</b></a>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button role="menuitem" class="desktop-agent-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<a class="sign-in" href="/sign-in">SIGN IN →</a>
|
||||
@@ -117,14 +156,14 @@
|
||||
<a class:active={page.url.pathname.startsWith(link.href)} href={link.href}><small>{String(index + 1).padStart(2, '0')}</small><span>{link.label}</span><b>›</b></a>
|
||||
{/each}
|
||||
{#if user}
|
||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Inbox</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||
<a class:active={page.url.pathname.startsWith('/account/notifications')} href="/account/notifications"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Notifications</span>{#if unreadNotifications}<em>{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>{:else}<b>›</b>{/if}</a>
|
||||
<a class:active={page.url.pathname === '/account'} href="/account"><small>{String(links.length + 2).padStart(2, '0')}</small><span>My Profile</span><b>›</b></a>
|
||||
{:else}
|
||||
<a class:active={page.url.pathname.startsWith('/sign-in')} href="/sign-in"><small>{String(links.length + 1).padStart(2, '0')}</small><span>Sign In</span><b>›</b></a>
|
||||
{/if}
|
||||
{#if isacAccess}<a class:active={page.url.pathname.startsWith('/isac')} href="/isac"><small>{String(links.length + 3).padStart(2, '0')}</small><span>ISAC</span><b>›</b></a>{/if}
|
||||
{#if user}
|
||||
<div class="mobile-agent-state"><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||
<div class="mobile-agent-state"><img src={user.avatarUrl} alt=""/><span><i></i> AGENT ONLINE</span><strong>{user.displayName}</strong></div>
|
||||
<PendingForm action="/sign-in?/logout">{#snippet children(pending)}<button class="mobile-sign-out" aria-busy={pending} disabled={pending}>{pending ? 'SIGNING OUT…' : 'SIGN OUT →'}</button>{/snippet}</PendingForm>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { actionFeedback } from '$lib/toast';
|
||||
import type { Snippet } from 'svelte';
|
||||
let { action, method = 'POST', enctype, class: className = '', children }: { action: string; method?: 'POST'; enctype?: 'multipart/form-data'; class?: string; children: Snippet<[boolean]> } = $props();
|
||||
let {
|
||||
action,
|
||||
method = 'POST',
|
||||
enctype,
|
||||
class: className = '',
|
||||
toast = true,
|
||||
successMessage,
|
||||
children
|
||||
}: {
|
||||
action: string;
|
||||
method?: 'POST';
|
||||
enctype?: 'multipart/form-data';
|
||||
class?: string;
|
||||
toast?: boolean;
|
||||
successMessage?: string;
|
||||
children: Snippet<[boolean]>;
|
||||
} = $props();
|
||||
let pending = $state(false);
|
||||
</script>
|
||||
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ update }) => { try { await update(); } finally { pending = false; } }; }}>
|
||||
<form {action} {method} {enctype} class={className} use:enhance={() => { if (pending) return; pending = true; return async ({ result, update }) => { try { if (toast) actionFeedback(result, action, successMessage); await update(); } finally { pending = false; } }; }}>
|
||||
<fieldset disabled={pending} aria-busy={pending}>{@render children(pending)}</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import { dismissToast, toasts, type Toast } from '$lib/toast';
|
||||
|
||||
const toneLabel = (toast: Toast) => ({
|
||||
success: 'OPERATION COMPLETE',
|
||||
error: 'ACTION FAILED',
|
||||
warning: 'ATTENTION REQUIRED',
|
||||
info: 'SYSTEM UPDATE'
|
||||
})[toast.tone];
|
||||
|
||||
function startTimer(_node: HTMLElement, toast: Toast) {
|
||||
const timer = window.setTimeout(() => dismissToast(toast.id), toast.duration);
|
||||
return {
|
||||
destroy() {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<aside class="toast-viewport" aria-label="Application feedback">
|
||||
{#each $toasts as item (item.id)}
|
||||
<article
|
||||
class={`toast-message ${item.tone}`}
|
||||
role={item.tone === 'error' || item.tone === 'warning' ? 'alert' : 'status'}
|
||||
aria-atomic="true"
|
||||
use:startTimer={item}
|
||||
>
|
||||
<i aria-hidden="true"></i>
|
||||
<div>
|
||||
<span>{toneLabel(item)}</span>
|
||||
<strong>{item.title}</strong>
|
||||
{#if item.message}<p>{item.message}</p>{/if}
|
||||
</div>
|
||||
<button type="button" aria-label={`Dismiss ${item.title}`} onclick={() => dismissToast(item.id)}>×</button>
|
||||
</article>
|
||||
{/each}
|
||||
</aside>
|
||||
Reference in New Issue
Block a user