CUB-122: Scaffold Control Center React frontend
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m57s
All checks were successful
Dev Build / build-test (pull_request) Successful in 1m57s
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
<nav class="bottom-nav" aria-label="Bottom navigation">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
class="bottom-nav__item"
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="bottom-nav__item--active"
|
||||
#rla="routerLinkActive"
|
||||
[attr.aria-label]="dest.label"
|
||||
[attr.aria-current]="rla.isActive ? 'page' : null"
|
||||
>
|
||||
<span class="bottom-nav__icon-wrapper">
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ dest.label }}</span>
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
@@ -0,0 +1,94 @@
|
||||
// ============================================================================
|
||||
// Bottom Navigation Bar — Mobile Navigation
|
||||
// Per CUB-27 spec breakpoints:
|
||||
// Compact (0–599px): Visible — M3 NavigationBar pattern
|
||||
// Medium+ (≥600px): Hidden — nav rail takes over
|
||||
// ============================================================================
|
||||
|
||||
.bottom-nav {
|
||||
display: none; // Hidden on desktop, shown on mobile via media query
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--cc-bottom-nav-height);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-top: 1px solid var(--cc-outline);
|
||||
z-index: 50;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
padding: 0 8px;
|
||||
// Safe area inset for notched devices
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.bottom-nav__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
color: var(--cc-on-surface-variant);
|
||||
border-radius: 16px;
|
||||
transition: color 150ms ease, background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface);
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
&--active {
|
||||
color: var(--status-active);
|
||||
background-color: var(--status-active-bg);
|
||||
|
||||
.bottom-nav__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
|
||||
.bottom-nav__item--active & {
|
||||
background-color: var(--status-active-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-nav__label {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact (0–599px): Show bottom nav
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (max-width: 599px) {
|
||||
.bottom-nav {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medium+ (≥600px): Hidden — nav rail takes over
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bottom-nav__item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
/**
|
||||
* Bottom Navigation Bar for mobile (compact breakpoint).
|
||||
* Per spec Section 3.2: M3 NavigationBar, 3–5 destinations,
|
||||
* active destination uses M3 indicator pill.
|
||||
* Visible only on compact (< 600px) breakpoint.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-bottom-nav',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './bottom-nav.component.html',
|
||||
styleUrl: './bottom-nav.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BottomNavComponent {
|
||||
/** Show only first 5 destinations on bottom nav */
|
||||
protected readonly destinations = NAV_DESTINATIONS.slice(0, 5);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<header class="header-bar" role="banner">
|
||||
<h1 class="header-bar__title">Command Hub</h1>
|
||||
|
||||
<div class="header-bar__actions">
|
||||
<!-- Quick-Jump trigger -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Jump to agent"
|
||||
(click)="openQuickJump.emit()"
|
||||
>
|
||||
<mat-icon>keyboard_command_key</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Live indicator -->
|
||||
<button
|
||||
class="header-bar__action-btn header-bar__live-btn"
|
||||
mat-icon-button
|
||||
[attr.aria-label]="isConnected() ? 'Connected — live' : 'Disconnected'"
|
||||
>
|
||||
<span
|
||||
class="header-bar__live-dot"
|
||||
[class.header-bar__live-dot--connected]="isConnected()"
|
||||
></span>
|
||||
<span class="header-bar__live-label">
|
||||
{{ isConnected() ? 'Live' : 'Offline' }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Notification bell -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="notificationCount()"
|
||||
[matBadgeHidden]="notificationCount() === 0"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
notifications
|
||||
</mat-icon>
|
||||
</button>
|
||||
|
||||
<!-- Settings -->
|
||||
<button
|
||||
class="header-bar__action-btn"
|
||||
mat-icon-button
|
||||
aria-label="Settings"
|
||||
>
|
||||
<mat-icon>settings</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,126 @@
|
||||
// ============================================================================
|
||||
// Header Bar — Top App Bar
|
||||
// Per CUB-27 spec breakpoints:
|
||||
// Compact (0–599px): SmallTopAppBar — 56px height, compact title, hidden labels
|
||||
// Medium (600–1023px): Medium top bar — 64px height
|
||||
// Expanded (≥1024px): MediumTopAppBar — 64px height, full actions
|
||||
// ============================================================================
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: var(--cc-header-height-compact); // Compact by default (mobile-first)
|
||||
padding: 0 var(--cc-section-padding-compact);
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface);
|
||||
margin: 0;
|
||||
letter-spacing: -0.01em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header-bar__action-btn {
|
||||
color: var(--cc-on-surface-variant) !important;
|
||||
min-width: 48px;
|
||||
min-height: 48px;
|
||||
|
||||
&:hover {
|
||||
color: var(--cc-on-surface) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
background-color: var(--status-error);
|
||||
vertical-align: middle;
|
||||
|
||||
&--connected {
|
||||
background-color: var(--status-active);
|
||||
animation: pulse-active 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.header-bar__live-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--cc-on-surface-variant);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact (0–599px): SmallTopAppBar — hide live label, tighter spacing
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (max-width: 599px) {
|
||||
.header-bar__live-label {
|
||||
display: none; // Space saving on compact — dot alone is enough
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medium (600–1023px): Medium top bar
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.header-bar {
|
||||
height: var(--cc-header-height);
|
||||
padding: 0 var(--cc-section-padding);
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expanded (≥1024px): Full top bar
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (min-width: 1024px) {
|
||||
.header-bar {
|
||||
height: var(--cc-header-height);
|
||||
padding: 0 var(--cc-section-padding);
|
||||
}
|
||||
|
||||
.header-bar__title {
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.header-bar__actions {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.header-bar__live-dot--connected {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, Output, signal } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
|
||||
/**
|
||||
* Header Bar component for the Command Hub.
|
||||
* Per spec Section 3.1: 64px tall, app title + live indicator + notification bell + settings.
|
||||
* Uses M3 top app bar pattern.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-header-bar',
|
||||
standalone: true,
|
||||
imports: [MatIconModule, MatButtonModule, MatBadgeModule],
|
||||
templateUrl: './header-bar.component.html',
|
||||
styleUrl: './header-bar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class HeaderBarComponent {
|
||||
/** Emits when the user requests the Quick-Jump drawer. */
|
||||
@Output() readonly openQuickJump = new EventEmitter<void>();
|
||||
|
||||
protected readonly notificationCount = signal(3);
|
||||
protected readonly isConnected = signal(true);
|
||||
|
||||
// TODO: Wire up notification panel (spec Section 2: Notifications Panel)
|
||||
// TODO: Wire up settings navigation
|
||||
}
|
||||
4
frontend-legacy/src/app/layout/index.ts
Normal file
4
frontend-legacy/src/app/layout/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './nav-rail/nav-rail.component';
|
||||
export * from './bottom-nav/bottom-nav.component';
|
||||
export * from './header-bar/header-bar.component';
|
||||
export * from './layout-shell/layout-shell.component';
|
||||
@@ -0,0 +1,27 @@
|
||||
<div class="layout-shell">
|
||||
<!-- Desktop/Kiosk: Nav Rail on the left -->
|
||||
<app-nav-rail class="layout-shell__nav-rail" />
|
||||
|
||||
<div class="layout-shell__main">
|
||||
<!-- Header bar at top of content area -->
|
||||
<app-header-bar class="layout-shell__header" (openQuickJump)="openQuickJump()" />
|
||||
|
||||
<!-- Scrollable content area -->
|
||||
<main class="layout-shell__content">
|
||||
<router-outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: Bottom Navigation Bar -->
|
||||
<app-bottom-nav class="layout-shell__bottom-nav" />
|
||||
</div>
|
||||
|
||||
<!-- Quick-Jump Drawer (global overlay) -->
|
||||
<app-quick-jump-drawer />
|
||||
|
||||
<!-- Agent Session Drawer (CUB-26) — desktop: side drawer, mobile: bottom sheet -->
|
||||
<app-agent-session-drawer
|
||||
[isMobile]="isMobile()"
|
||||
(openSession)="onOpenSession($event)"
|
||||
(pinToDashboard)="onPinToDashboard($event)"
|
||||
/>
|
||||
@@ -0,0 +1,73 @@
|
||||
// ============================================================================
|
||||
// Layout Shell — Adaptive layout container
|
||||
// Per CUB-27 spec breakpoints:
|
||||
// Compact (0–599px): Header + Content + Bottom Nav (stacked)
|
||||
// Medium (600–1023px): Collapsed Nav Rail + Header + Content
|
||||
// Expanded (≥1024px): Expandable Nav Rail + Header + Content
|
||||
// ============================================================================
|
||||
|
||||
.layout-shell {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-background);
|
||||
}
|
||||
|
||||
.layout-shell__nav-rail {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0; // Prevent flex overflow
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-shell__header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: var(--cc-section-padding);
|
||||
}
|
||||
|
||||
.layout-shell__bottom-nav {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compact (0–599px): Stack layout vertically, bottom nav visible
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (max-width: 599px) {
|
||||
.layout-shell {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout-shell__content {
|
||||
padding: var(--cc-section-padding-compact);
|
||||
// Account for bottom nav bar height
|
||||
padding-bottom: calc(var(--cc-bottom-nav-height) + 16px);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medium (600–1023px): Sidebar + content, collapsed nav rail
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.layout-shell__content {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expanded (≥1024px): Full nav rail with expandable behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (min-width: 1024px) {
|
||||
.layout-shell__content {
|
||||
padding: var(--cc-section-padding);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ChangeDetectionStrategy, Component, HostListener, OnDestroy, signal, ViewChild } from '@angular/core';
|
||||
import { Router, RouterOutlet } from '@angular/router';
|
||||
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { NavRailComponent } from '../nav-rail/nav-rail.component';
|
||||
import { BottomNavComponent } from '../bottom-nav/bottom-nav.component';
|
||||
import { HeaderBarComponent } from '../header-bar/header-bar.component';
|
||||
import { QuickJumpDrawerComponent } from '../../components/quick-jump-drawer/index';
|
||||
import { AgentSessionDrawerComponent } from '../../components/agent-session-drawer/index';
|
||||
import { AgentCardData } from '../../models/agent.model';
|
||||
|
||||
/**
|
||||
* Layout Shell — wraps the main content area with adaptive navigation.
|
||||
* Desktop/Kiosk: Nav Rail (left) + Header + Content
|
||||
* Mobile: Header + Content + Bottom Nav
|
||||
* Per spec Section 3.1 (kiosk) and 3.2 (mobile).
|
||||
* CUB-26: Hosts the Agent Session Drawer for quick-jump navigation.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-layout-shell',
|
||||
standalone: true,
|
||||
imports: [RouterOutlet, NavRailComponent, BottomNavComponent, HeaderBarComponent, QuickJumpDrawerComponent, AgentSessionDrawerComponent],
|
||||
templateUrl: './layout-shell.component.html',
|
||||
styleUrl: './layout-shell.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class LayoutShellComponent implements OnDestroy {
|
||||
@ViewChild(QuickJumpDrawerComponent) quickJumpDrawer!: QuickJumpDrawerComponent;
|
||||
@ViewChild(AgentSessionDrawerComponent) sessionDrawer!: AgentSessionDrawerComponent;
|
||||
|
||||
/** Whether the viewport is mobile-sized. */
|
||||
readonly isMobile = signal(false);
|
||||
|
||||
private readonly breakpointSub: Subscription;
|
||||
|
||||
constructor(
|
||||
private readonly breakpointObserver: BreakpointObserver,
|
||||
private readonly router: Router,
|
||||
) {
|
||||
this.breakpointSub = this.breakpointObserver
|
||||
.observe([Breakpoints.Handset, Breakpoints.Small])
|
||||
.subscribe((result) => {
|
||||
this.isMobile.set(result.matches);
|
||||
});
|
||||
}
|
||||
|
||||
/** Open the quick-jump drawer from anywhere in the layout. */
|
||||
openQuickJump(): void {
|
||||
this.quickJumpDrawer?.open();
|
||||
}
|
||||
|
||||
/** Open the session drawer for a specific agent. */
|
||||
openSessionDrawer(agent: AgentCardData): void {
|
||||
this.sessionDrawer?.open(agent);
|
||||
}
|
||||
|
||||
/** Open the session log page directly (long-press bypass). */
|
||||
openSessionLog(sessionKey: string): void {
|
||||
this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } });
|
||||
}
|
||||
|
||||
/** Handle "Open Full Session" action from session drawer. */
|
||||
onOpenSession(sessionKey: string): void {
|
||||
this.router.navigate(['/sessions'], { queryParams: { key: sessionKey } });
|
||||
}
|
||||
|
||||
/** Handle "Pin to Dashboard" action from session drawer. */
|
||||
onPinToDashboard(sessionKey: string): void {
|
||||
// TODO: Implement pin-to-dashboard logic
|
||||
console.log('[LayoutShell] Pin to dashboard:', sessionKey);
|
||||
}
|
||||
|
||||
/** Global keyboard shortcut: Ctrl+K or Cmd+K opens the quick-jump drawer. */
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onGlobalKeydown(event: KeyboardEvent): void {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
this.quickJumpDrawer?.toggle();
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.breakpointSub.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<aside
|
||||
class="nav-rail"
|
||||
[class.nav-rail--expanded]="expanded()"
|
||||
[attr.aria-label]="'Navigation'"
|
||||
>
|
||||
<!-- Header with OpenClaw brand -->
|
||||
<div class="nav-rail__header">
|
||||
<button
|
||||
class="nav-rail__toggle"
|
||||
(click)="toggleExpand()"
|
||||
[attr.aria-label]="expanded() ? 'Collapse navigation' : 'Expand navigation'"
|
||||
[attr.aria-expanded]="expanded()"
|
||||
>
|
||||
<mat-icon>menu</mat-icon>
|
||||
</button>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__brand">OpenClaw</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation destinations -->
|
||||
<nav class="nav-rail__nav">
|
||||
@for (dest of destinations; track dest.route) {
|
||||
<a
|
||||
[routerLink]="dest.route"
|
||||
routerLinkActive="nav-rail__item--active"
|
||||
[attr.aria-label]="dest.label"
|
||||
class="nav-rail__item"
|
||||
>
|
||||
<mat-icon
|
||||
[matBadge]="dest.badge ?? 0"
|
||||
[matBadgeHidden]="!dest.badge"
|
||||
matBadgePosition="above after"
|
||||
matBadgeSize="small"
|
||||
>
|
||||
{{ dest.icon }}
|
||||
</mat-icon>
|
||||
@if (expanded()) {
|
||||
<span class="nav-rail__label">{{ dest.label }}</span>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
</nav>
|
||||
</aside>
|
||||
158
frontend-legacy/src/app/layout/nav-rail/nav-rail.component.scss
Normal file
158
frontend-legacy/src/app/layout/nav-rail/nav-rail.component.scss
Normal file
@@ -0,0 +1,158 @@
|
||||
// ============================================================================
|
||||
// Nav Rail — Desktop/Kiosk Navigation
|
||||
// Per CUB-27 spec breakpoints:
|
||||
// Compact (0–599px): Hidden — bottom nav takes over
|
||||
// Medium (600–1023px): Collapsed (72px), icon-only
|
||||
// Expanded (≥1024px): Expandable (72px collapsed / 256px expanded on hover)
|
||||
// Section 5.4: Spacing & Grid
|
||||
// ============================================================================
|
||||
|
||||
.nav-rail {
|
||||
display: none; // Hidden by default (mobile-first)
|
||||
flex-direction: column;
|
||||
width: var(--cc-nav-rail-collapsed-width);
|
||||
min-height: 100vh;
|
||||
background-color: var(--cc-surface-container-high);
|
||||
border-right: 1px solid var(--cc-outline);
|
||||
transition: width 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
|
||||
&--expanded {
|
||||
width: var(--cc-nav-rail-expanded-width);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 12px;
|
||||
min-height: 64px;
|
||||
border-bottom: 1px solid var(--cc-outline);
|
||||
}
|
||||
|
||||
.nav-rail__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--cc-on-surface);
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid var(--status-active);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__brand {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--status-active);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.nav-rail__nav {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
|
||||
// Override Angular Material list item styles for compact nav rail items
|
||||
--mat-list-list-item-one-line-vertical-gap: 4px;
|
||||
}
|
||||
|
||||
.nav-rail__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
min-height: 56px;
|
||||
padding: 0 12px;
|
||||
border-radius: 28px;
|
||||
margin: 2px 12px;
|
||||
color: var(--cc-on-surface-variant);
|
||||
text-decoration: none;
|
||||
transition: background-color 150ms ease, color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
color: var(--cc-on-surface);
|
||||
}
|
||||
|
||||
&--active {
|
||||
background-color: var(--status-active-bg);
|
||||
color: var(--status-active);
|
||||
|
||||
.nav-rail__label {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-rail__label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Medium (600–1023px): Show collapsed nav rail (icon-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (min-width: 600px) and (max-width: 1023px) {
|
||||
.nav-rail {
|
||||
display: flex;
|
||||
width: var(--cc-nav-rail-collapsed-width);
|
||||
}
|
||||
|
||||
// Always collapsed on medium — hide labels
|
||||
.nav-rail__brand,
|
||||
.nav-rail__label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-rail__header {
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.nav-rail__item {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
margin: 2px 8px;
|
||||
}
|
||||
|
||||
// Disable expand on medium
|
||||
.nav-rail--expanded {
|
||||
width: var(--cc-nav-rail-collapsed-width);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expanded (≥1024px): Full expandable nav rail
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (min-width: 1024px) {
|
||||
.nav-rail {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accessibility: Reduced Motion
|
||||
// ---------------------------------------------------------------------------
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.nav-rail {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChangeDetectionStrategy, Component, HostListener, signal, OnDestroy, OnInit } from '@angular/core';
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatBadgeModule } from '@angular/material/badge';
|
||||
import { NAV_DESTINATIONS } from '../../models/nav.model';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-rail',
|
||||
standalone: true,
|
||||
imports: [RouterLink, RouterLinkActive, MatIconModule, MatBadgeModule],
|
||||
templateUrl: './nav-rail.component.html',
|
||||
styleUrl: './nav-rail.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavRailComponent implements OnInit, OnDestroy {
|
||||
protected readonly destinations = NAV_DESTINATIONS;
|
||||
protected readonly expanded = signal(false);
|
||||
protected readonly isExpandedBreakpoint = signal(false);
|
||||
|
||||
private readonly EXPANDED_BP = 1024;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.updateBreakpoint();
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
onResize(): void {
|
||||
this.updateBreakpoint();
|
||||
}
|
||||
|
||||
@HostListener('mouseenter')
|
||||
onHoverIn(): void {
|
||||
if (this.isExpandedBreakpoint()) {
|
||||
this.expanded.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('mouseleave')
|
||||
onHoverOut(): void {
|
||||
if (this.isExpandedBreakpoint()) {
|
||||
this.expanded.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpand(): void {
|
||||
if (this.isExpandedBreakpoint()) {
|
||||
this.expanded.update(v => !v);
|
||||
}
|
||||
}
|
||||
|
||||
private updateBreakpoint(): void {
|
||||
const isExpanded = window.innerWidth >= this.EXPANDED_BP;
|
||||
this.isExpandedBreakpoint.set(isExpanded);
|
||||
// Collapse when leaving expanded breakpoint
|
||||
if (!isExpanded) {
|
||||
this.expanded.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Cleanup is handled by HostListener auto-unsubscribe
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user