# Sidebar Collapse-to-Icon-Rail Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Repurpose the existing sidebar hamburger toggle so it collapses the 260px sidebar into a ~72px icon-only rail (instead of hiding it off-screen), with a hover/click flyout for menu groups that have children. **Architecture:** A single `isRailMode: boolean` flows from `MainLayoutComponent` down through `MenuBarComponent` into the three recursive menu-item components (`HeadMenuComponent`, `BasicMenuComponent`, `CollapsableComponent`) as an `@Input()`. Each component conditionally hides its text label in rail mode. `CollapsableComponent` additionally opens a CDK Overlay flyout (anchored to its icon) showing its children as full-text links when hovered/clicked in rail mode. **Tech Stack:** Angular 16, Angular CDK Overlay (`@angular/cdk/overlay`, `@angular/cdk/portal`) — already a project dependency, not yet wired into `app.module.ts`. ## Global Constraints - No new/second toggle button — the existing hamburger in `tool-bar.component.ts` is the only control. - No changes to `src/app/core/data/navigator.ts` (menu data) — this is a rendering-mode change only. - No responsive/mobile breakpoint handling — out of scope. - Rail width: 72px. Full width: 260px (unchanged). - Rail-mode state key in `localStorage`: `sidebar_rail_mode` (string `'true'`/`'false'`). - This codebase has no meaningful automated test coverage for the layout/menu components (the existing `.spec.ts` files under `src/app/layout` are unmodified Angular-CLI boilerplate — a single `'should create'` test with no test module setup for child components). Per the approved spec, this feature ships without new automated tests; each task's "test" step is a manual verification via the running dev server instead. --- ### Task 1: Rail-mode toggle, width collapse, and label-hiding for all menu item types **Files:** - Modify: `src/app/layout/main-layout/main-layout.component.ts` - Modify: `src/app/layout/main-layout/main-layout.component.html` - Modify: `src/app/layout/components/tool-bar/tool-bar.component.ts` - Modify: `src/app/layout/components/tool-bar/tool-bar.component.html` - Modify: `src/app/layout/components/menu-bar/menu-bar.component.ts` - Modify: `src/app/layout/components/menu-bar/menu-bar.component.html` - Modify: `src/app/layout/components/menu-bar/menu-bar.component.scss` - Modify: `src/app/layout/components/menu/head-menu/head-menu.component.ts` - Modify: `src/app/layout/components/menu/head-menu/head-menu.component.html` - Modify: `src/app/layout/components/menu/head-menu/head-menu.component.scss` - Modify: `src/app/layout/components/menu/basic-menu/basic-menu.component.ts` - Modify: `src/app/layout/components/menu/basic-menu/basic-menu.component.html` - Modify: `src/app/layout/components/menu/basic-menu/basic-menu.component.scss` - Modify: `src/app/layout/components/menu/collapsable/collapsable.component.ts` - Modify: `src/app/layout/components/menu/collapsable/collapsable.component.html` - Modify: `src/app/layout/components/menu/collapsable/collapsable.component.scss` **Interfaces:** - Produces: `MainLayoutComponent.isRailMode: boolean`, `MainLayoutComponent.onToggleRailMode(railMode: boolean): void`. - Produces: `ToolBarComponent.@Input() isRailMode: boolean`, `ToolBarComponent.@Output() railModeChange: EventEmitter`. - Produces: `@Input() isRailMode: boolean = false` on `MenuBarComponent`, `HeadMenuComponent`, `BasicMenuComponent`, `CollapsableComponent` — Task 2 consumes this same input on `CollapsableComponent` to drive the flyout. - [ ] **Step 1: Update `main-layout.component.ts`** ```ts import {Component, OnInit, ChangeDetectionStrategy} from '@angular/core'; const SIDEBAR_RAIL_MODE_KEY = 'sidebar_rail_mode'; @Component({ selector: 'app-main-layout', templateUrl: './main-layout.component.html', styleUrls: ['./main-layout.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class MainLayoutComponent implements OnInit { coreColor isRailMode: boolean = localStorage.getItem(SIDEBAR_RAIL_MODE_KEY) === 'true'; constructor() {} ngOnInit(): void { } onToggleRailMode(railMode: boolean) { this.isRailMode = railMode; localStorage.setItem(SIDEBAR_RAIL_MODE_KEY, String(railMode)); } onDeactivate() { } } ``` - [ ] **Step 2: Update `main-layout.component.html`** ```html
``` (This removes the old `
` off-screen-hide wrapper entirely — the sidebar's own width now controls its visible size.) - [ ] **Step 3: Update `tool-bar.component.ts`** ```ts import { OidsAuthService } from './../../../core/service/odic/odic.service'; import { Component, OnInit, ChangeDetectionStrategy, EventEmitter, Output, Input } from '@angular/core'; @Component({ selector: 'app-tool-bar', templateUrl: './tool-bar.component.html', styleUrls: ['./tool-bar.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class ToolBarComponent implements OnInit { @Input() isRailMode: boolean = false @Output() railModeChange = new EventEmitter() constructor( public oidsAuthSV: OidsAuthService ) { } ngOnInit(): void { } toggleMenu(){ this.railModeChange.emit(!this.isRailMode) } logout(){ this.oidsAuthSV.logout() } getUserInitials(): string { const name = this.oidsAuthSV.user?.profile?.display_name || ''; return name.slice(0, 2) || 'สก'; } } ``` - [ ] **Step 4: Update `tool-bar.component.html`** ```html
person
{{ oidsAuthSV.user?.profile?.display_name || 'ผู้ใช้งาน' }}
``` - [ ] **Step 5: Update `menu-bar.component.ts`** — add `isRailMode` input Add `Input` to the existing `@angular/core` import and add the field to the class: ```ts import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, OnDestroy} from '@angular/core'; ``` ```ts export class MenuBarComponent implements OnInit, OnDestroy { @ViewChild('sidebarMenuContainer') sidebarMenuContainer: ElementRef; @Input() isRailMode: boolean = false; menus: any = []; ``` (Leave every other line in the file — the constructor, `ngOnInit`, `updateNavigator`, `updateMenu`, etc. — exactly as-is; this step only adds the `Input` import token and the one new field.) - [ ] **Step 6: Update `menu-bar.component.html`** ```html
school
มทร.รัตนโกสินทร์ ระบบแผนงานและงบประมาณ
``` - [ ] **Step 7: Update `menu-bar.component.scss`** — add rail width + host display ```scss :host { display: flex; height: 100%; } .sidebar-wrapper { background: #ffffff; border-right: 1px solid #e9ecef; box-shadow: 2px 0 8px rgba(0, 0, 0, 0.04); min-width: 260px; max-width: 260px; transition: min-width 0.2s ease, max-width 0.2s ease; &.sidebar-rail { min-width: 72px; max-width: 72px; .sidebar-header { padding: 0; justify-content: center; } } } /* ── Header ── */ .sidebar-header { background: #ffffff; height: 72px; padding: 0 18px; border-bottom: 1px solid #f3f4f6; flex-shrink: 0; } .sidebar-icon-wrap { width: 44px; height: 44px; border-radius: 11px; background: linear-gradient(160deg, #d4432f, #b4331f); display: flex; align-items: center; justify-content: center; flex-shrink: 0; box-shadow: 0 4px 12px rgba(180, 51, 31, 0.3); } .sidebar-icon-inner { font-size: 22px !important; width: 22px !important; height: 22px !important; color: #ffffff; } .sidebar-title { font-size: 14px; font-weight: 700; color: #b4331f; line-height: 1.4; white-space: nowrap; } .sidebar-subtitle { font-size: 11px; color: #7a828e; line-height: 1.5; font-weight: 500; white-space: nowrap; } /* ── Divider ── */ .sidebar-divider { height: 1px; background: #e9e7e2; margin: 8px 16px; } /* ── Menu Container ── */ .sidebar-menu-container { overflow: auto; flex: 1; padding: 10px 0 8px; background: transparent; &::-webkit-scrollbar { width: 4px; } &::-webkit-scrollbar-track { background: transparent; } &::-webkit-scrollbar-thumb { background: #e5e7eb; border-radius: 4px; } &::-webkit-scrollbar-thumb:hover { background: #d1d5db; } } ``` - [ ] **Step 8: Update `head-menu.component.ts`** ```ts import { Component, OnInit, ChangeDetectionStrategy, Input } from '@angular/core'; import { SeItem } from 'src/app/core/data/navigator'; @Component({ selector: 'app-head-menu', templateUrl: './head-menu.component.html', styleUrls: ['./head-menu.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush }) export class HeadMenuComponent implements OnInit { @Input() item: SeItem @Input() isRailMode: boolean = false constructor() { } ngOnInit(): void { } } ``` - [ ] **Step 9: Update `head-menu.component.html`** ```html
{{item?.icon}} {{ item?.title }} {{ item.badge }}
``` - [ ] **Step 10: Update `head-menu.component.scss`** — append rail-item rule ```scss .se_item_warpper { margin: 1px 10px; border-radius: 8px; transition: background-color 0.15s ease; cursor: pointer; &:hover { background-color: #f7ddd7; .se_item { color: #b4331f !important; } .menu-icon { color: #b4331f !important; } } } .menu-icon { color: #9ca3af; font-size: 17px !important; width: 17px !important; height: 17px !important; margin-left: 12px; margin-right: 4px; flex-shrink: 0; transition: color 0.15s ease; } .se_item { position: relative; display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; font-size: 13.5px; font-weight: 400; line-height: 20px; text-decoration: none; color: #374151 !important; border-radius: 8px; width: 100%; transition: color 0.15s ease; } .menu-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; border-radius: 10px; background: #c6392a; color: #ffffff; font-size: 11px; font-weight: 700; padding: 0 6px; line-height: 1; margin-left: auto; flex-shrink: 0; } .se_item_active { background-color: #fcedea !important; border-left: 3px solid #c6392a; border-radius: 8px; .se_item { color: #c6392a !important; font-weight: 600 !important; } .menu-icon { color: #c6392a !important; } } .rail-item { margin: 1px 6px; justify-content: center; .menu-icon { margin: 0; } .se_item { width: auto; padding: 9px 4px; } } ``` - [ ] **Step 11: Update `basic-menu.component.ts`** ```ts import {ChangeDetectorRef, Component, Input, OnDestroy, OnInit} from '@angular/core'; import {SeItem} from 'src/app/core/data/navigator'; import {NavigationEnd, Router} from '@angular/router'; import {filter} from 'rxjs/operators'; import {Subscription} from 'rxjs'; @Component({ selector: 'app-basic-menu', templateUrl: './basic-menu.component.html', styleUrls: ['./basic-menu.component.scss'] }) export class BasicMenuComponent implements OnInit, OnDestroy { @Input() item: SeItem; @Input() permission: any; @Input() isRailMode: boolean = false; isRelatedActive = false; private routerSub: Subscription; constructor(private router: Router, private cdr: ChangeDetectorRef) {} ngOnInit(): void { this.updateActive(this.router.url); this.routerSub = this.router.events.pipe( filter(e => e instanceof NavigationEnd) ).subscribe((e: NavigationEnd) => { this.updateActive(e.url); }); } ngOnDestroy(): void { this.routerSub?.unsubscribe(); } private updateActive(url: string): void { const directMatch = !!(this.item?.link && (url === this.item.link || url.startsWith(this.item.link + '/'))); const relatedMatch = !!(this.item?.relatedLinks?.some(r => url === r || url.startsWith(r + '/'))); this.isRelatedActive = directMatch || relatedMatch; this.cdr.markForCheck(); } } ``` - [ ] **Step 12: Update `basic-menu.component.html`** ```html
{{item?.icon}} {{ item?.title }} {{ item.badge }}
``` - [ ] **Step 13: Update `basic-menu.component.scss`** — same content as `head-menu.component.scss` in Step 10 (this file already duplicates that stylesheet in the existing codebase; keep the duplication pattern) ```scss .se_item_warpper { margin: 1px 10px; border-radius: 8px; transition: background-color 0.15s ease; cursor: pointer; &:hover { background-color: #f7ddd7; .se_item { color: #b4331f !important; } .menu-icon { color: #b4331f !important; } } } .menu-icon { color: #9ca3af; font-size: 17px !important; width: 17px !important; height: 17px !important; margin-left: 12px; margin-right: 4px; flex-shrink: 0; transition: color 0.15s ease; } .se_item { position: relative; display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; font-size: 13.5px; font-weight: 400; line-height: 20px; text-decoration: none; color: #374151 !important; border-radius: 8px; width: 100%; transition: color 0.15s ease; } .menu-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; border-radius: 10px; background: #c6392a; color: #ffffff; font-size: 11px; font-weight: 700; padding: 0 6px; line-height: 1; margin-left: auto; flex-shrink: 0; } .se_item_active { background-color: #fcedea !important; border-left: 3px solid #c6392a; border-radius: 8px; .se_item { color: #c6392a !important; font-weight: 600 !important; } .menu-icon { color: #c6392a !important; } } .rail-item { margin: 1px 6px; justify-content: center; .menu-icon { margin: 0; } .se_item { width: auto; padding: 9px 4px; } } ``` - [ ] **Step 14: Update `collapsable.component.ts`** — add `isRailMode` input only (flyout logic lands in Task 2) ```ts import {ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, OnChanges, OnDestroy, OnInit, SimpleChanges} from '@angular/core'; import {SeItem} from 'src/app/core/data/navigator'; import {SEAnimations} from 'src/app/shared/animations/animations'; import {NavigationEnd, Router} from '@angular/router'; import {filter} from 'rxjs/operators'; import {Subscription} from 'rxjs'; @Component({ selector: 'app-collapsable', templateUrl: './collapsable.component.html', styleUrls: ['./collapsable.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, animations: SEAnimations }) export class CollapsableComponent implements OnInit, OnChanges, OnDestroy { isCollapsed: boolean = true; isExpanded: boolean = false; isCollapsedItem: any = {}; isChildrenActive: boolean = false; currentUrl: string = ''; private routerSub: Subscription; @HostBinding('class') get classList(): any { return { 'se_item_collapsed': this.isCollapsed, 'se_item_expanded': this.isExpanded }; } @Input() item: SeItem; @Input() permission: any; @Input() isRailMode: boolean = false; constructor(private cdr: ChangeDetectorRef, private router: Router) { } ngOnInit(): void { this.isCollapsedItem[this.item?.id] = false; this.isExpand(); this.checkActiveState(this.router.url); this.routerSub = this.router.events.pipe( filter(e => e instanceof NavigationEnd) ).subscribe((e: NavigationEnd) => { this.checkActiveState(e.url); }); } ngOnDestroy(): void { this.routerSub?.unsubscribe(); } checkActiveState(url: string): void { this.currentUrl = url; const selfMatch = this.item?.link && (url === this.item.link || url.startsWith(this.item.link + '/')); const childMatch = this.hasActiveChild(this.item?.children, url); this.isChildrenActive = !!(selfMatch || childMatch); if (this.isChildrenActive) { this.expand(); } this.cdr.markForCheck(); } isChildRelatedActive(child: SeItem): boolean { const url = this.currentUrl; const directMatch = child?.link && (url === child.link || url.startsWith(child.link + '/')); return !directMatch && !!(child?.relatedLinks?.some(r => url === r || url.startsWith(r + '/'))); } private hasActiveChild(children: SeItem[], url: string): boolean { return children?.some(c => { if (c?.link && (url === c.link || url.startsWith(c.link + '/'))) return true; if (c?.relatedLinks?.some(r => url === r || url.startsWith(r + '/'))) return true; return this.hasActiveChild(c?.children, url); }) ?? false; } ngOnChanges(changes: SimpleChanges): void { if (changes.item && this.item?.ischildActive) { this.expand(); } this.cdr.markForCheck(); } isExpand() { const authCallback = localStorage.getItem('authCallback'); if (!authCallback) return; const isMenu = authCallback.split('/'); if (isMenu.includes(this.item?.id)) { this.isCollapsedItem[this.item?.id] = true; this.expand(); } const fChildren = this.item?.children?.filter((f) => f.link === authCallback); if (fChildren?.length > 0) { this.isCollapsedItem[this.item?.id] = true; this.expand(); } const fChildrenId = this.item?.children?.filter((f) => { if (f) return false; const ids = f.link.split('/').pop(); return isMenu.includes(ids); }); if (fChildrenId?.length > 0) { this.isCollapsedItem[this.item?.id] = true; this.expand(); } } toggleCollapsable() { if (this.isCollapsed) { this.expand(); } else { this.collapse(); } } expand(): void { if (this.item?.disabled) { return; } if (!this.isCollapsed) { return; } this.isCollapsed = false; this.isExpanded = !this.isCollapsed; } collapse(): void { if (this.item?.disabled) { return; } if (this.isCollapsed) { return; } this.isCollapsed = true; this.isExpanded = !this.isCollapsed; } identify(index, item) { return index; } } ``` - [ ] **Step 15: Update `collapsable.component.html`** — hide label/chevron/children in rail mode (flyout arrives in Task 2, so in rail mode a collapsable's icon is inert for now — that's expected and fixed by Task 2) ```html
{{item?.icon}} {{ item?.title }} {{ item?.title }}
expand_more
{{menuChilden.title}}
``` - [ ] **Step 16: Update `collapsable.component.scss`** — append rail-item rule Append this block to the end of the existing file (keep every existing rule already in the file untouched): ```scss .se_item_warpper.rail-item { margin: 1px 6px; .bgMenu { justify-content: center; padding: 8px 0; } .menu-icon { margin: 0; } } ``` - [ ] **Step 17: Build and manually verify** Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npx tsc -p tsconfig.app.json --noEmit` Expected: no output (no type errors). Then, with the dev server running at `http://localhost:4200`: 1. Load any `/app/...` page. Confirm the sidebar looks unchanged (260px, full labels). 2. Click the hamburger icon in the toolbar. Confirm: sidebar animates down to ~72px, header collapses to just the school-icon logo, dividers disappear, leaf items (e.g. "เมนูนี้เพื่อบอส") show only their icon. 3. Hover a leaf item's icon. Confirm a native browser tooltip shows its Thai title. 4. Click a leaf item's icon. Confirm normal navigation still happens. 5. Confirm a `collapsable` item (e.g. "แผนงานและโครงการ") shows only its icon in rail mode, with no visible chevron or children (clicking it does nothing yet — expected; Task 2 adds the flyout). 6. Click the hamburger again. Confirm the sidebar expands back to 260px with full labels. 7. Toggle to rail mode, then reload the page. Confirm it comes back up already in rail mode (state persisted via `localStorage`). - [ ] **Step 18: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/layout git commit -m "feat: collapse sidebar to icon-only rail via existing toggle" ``` Note: `rmutr-web` is its own git repository (nested inside `/Users/nut.looknut/Project/rmutr`, which is a separate outer repo). Run all git commands from inside `rmutr-web`, not from the outer repo. --- ### Task 2: Flyout popup for menu groups + active-group indicator in rail mode **Files:** - Modify: `src/app/app.module.ts` - Modify: `src/app/layout/components/menu/collapsable/collapsable.component.ts` - Modify: `src/app/layout/components/menu/collapsable/collapsable.component.html` - Modify: `src/app/layout/components/menu/collapsable/collapsable.component.scss` **Interfaces:** - Consumes: `@Input() isRailMode` on `CollapsableComponent` (from Task 1). - Produces: `CollapsableComponent.openFlyout()`, `.closeFlyout()`, `.onTriggerClick()`, `.onTriggerEnter()`, `.onTriggerLeave()`, `.onFlyoutAreaEnter()`, `.onFlyoutAreaLeave()`, `.onFlyoutClick(event: MouseEvent)`, `.isFlyoutOpen: boolean` — all self-contained to this component; nothing outside this file depends on them. - [ ] **Step 1: Register Angular CDK Overlay in `app.module.ts`** Add the import near the top of the file, alongside the other Angular imports: ```ts import { OverlayModule } from '@angular/cdk/overlay'; ``` Add `OverlayModule` to the `imports: [...]` array (anywhere in the list, e.g. right after `BrowserAnimationsModule`): ```ts imports: [ BrowserModule, AppRoutingModule, CoreModule, SharedModule, BrowserAnimationsModule, OverlayModule, StoreModule.forRoot(appReducers,{ metaReducers, runtimeChecks:{ strictStateImmutability: false, strictActionImmutability: false, } }), EffectsModule.forRoot([]), StoreDevtoolsModule.instrument({ maxAge: 25, logOnly: environment.production , connectInZone: true}), StoreRouterConnectingModule.forRoot(), ], ``` - [ ] **Step 2: Rewrite `collapsable.component.ts`** to add the flyout ```ts import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, HostBinding, HostListener, Input, OnChanges, OnDestroy, OnInit, SimpleChanges, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; import {SeItem} from 'src/app/core/data/navigator'; import {SEAnimations} from 'src/app/shared/animations/animations'; import {NavigationEnd, Router} from '@angular/router'; import {filter} from 'rxjs/operators'; import {Subscription} from 'rxjs'; import {Overlay, OverlayRef} from '@angular/cdk/overlay'; import {TemplatePortal} from '@angular/cdk/portal'; const FLYOUT_CLOSE_DELAY_MS = 200; @Component({ selector: 'app-collapsable', templateUrl: './collapsable.component.html', styleUrls: ['./collapsable.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, animations: SEAnimations }) export class CollapsableComponent implements OnInit, OnChanges, OnDestroy { isCollapsed: boolean = true; isExpanded: boolean = false; isCollapsedItem: any = {}; isChildrenActive: boolean = false; currentUrl: string = ''; isFlyoutOpen: boolean = false; private routerSub: Subscription; private closeTimeout: any; private overlayRef: OverlayRef | null = null; @HostBinding('class') get classList(): any { return { 'se_item_collapsed': this.isCollapsed, 'se_item_expanded': this.isExpanded }; } @Input() item: SeItem; @Input() permission: any; @Input() isRailMode: boolean = false; @ViewChild('railTrigger') railTrigger: ElementRef; @ViewChild('childrenTemplate') childrenTemplate: TemplateRef; constructor( private cdr: ChangeDetectorRef, private router: Router, private overlay: Overlay, private viewContainerRef: ViewContainerRef ) { } ngOnInit(): void { this.isCollapsedItem[this.item?.id] = false; this.isExpand(); this.checkActiveState(this.router.url); this.routerSub = this.router.events.pipe( filter(e => e instanceof NavigationEnd) ).subscribe((e: NavigationEnd) => { this.checkActiveState(e.url); this.closeFlyout(); }); } ngOnDestroy(): void { this.routerSub?.unsubscribe(); clearTimeout(this.closeTimeout); this.overlayRef?.dispose(); } @HostListener('document:keydown.escape') onEscape(): void { this.closeFlyout(); } checkActiveState(url: string): void { this.currentUrl = url; const selfMatch = this.item?.link && (url === this.item.link || url.startsWith(this.item.link + '/')); const childMatch = this.hasActiveChild(this.item?.children, url); this.isChildrenActive = !!(selfMatch || childMatch); if (this.isChildrenActive) { this.expand(); } this.cdr.markForCheck(); } isChildRelatedActive(child: SeItem): boolean { const url = this.currentUrl; const directMatch = child?.link && (url === child.link || url.startsWith(child.link + '/')); return !directMatch && !!(child?.relatedLinks?.some(r => url === r || url.startsWith(r + '/'))); } private hasActiveChild(children: SeItem[], url: string): boolean { return children?.some(c => { if (c?.link && (url === c.link || url.startsWith(c.link + '/'))) return true; if (c?.relatedLinks?.some(r => url === r || url.startsWith(r + '/'))) return true; return this.hasActiveChild(c?.children, url); }) ?? false; } ngOnChanges(changes: SimpleChanges): void { if (changes.item && this.item?.ischildActive) { this.expand(); } if (changes.isRailMode && !this.isRailMode) { this.closeFlyout(); } this.cdr.markForCheck(); } isExpand() { const authCallback = localStorage.getItem('authCallback'); if (!authCallback) return; const isMenu = authCallback.split('/'); if (isMenu.includes(this.item?.id)) { this.isCollapsedItem[this.item?.id] = true; this.expand(); } const fChildren = this.item?.children?.filter((f) => f.link === authCallback); if (fChildren?.length > 0) { this.isCollapsedItem[this.item?.id] = true; this.expand(); } const fChildrenId = this.item?.children?.filter((f) => { if (f) return false; const ids = f.link.split('/').pop(); return isMenu.includes(ids); }); if (fChildrenId?.length > 0) { this.isCollapsedItem[this.item?.id] = true; this.expand(); } } onTriggerClick(): void { if (this.isRailMode) { this.isFlyoutOpen ? this.closeFlyout() : this.openFlyout(); return; } this.toggleCollapsable(); } onTriggerEnter(): void { if (!this.isRailMode) return; clearTimeout(this.closeTimeout); this.openFlyout(); } onTriggerLeave(): void { if (!this.isRailMode) return; this.scheduleClose(); } onFlyoutAreaEnter(): void { clearTimeout(this.closeTimeout); } onFlyoutAreaLeave(): void { this.scheduleClose(); } onFlyoutClick(event: MouseEvent): void { const target = event.target as HTMLElement; if (target.closest('a.se_item')) { this.closeFlyout(); } } private scheduleClose(): void { clearTimeout(this.closeTimeout); this.closeTimeout = setTimeout(() => this.closeFlyout(), FLYOUT_CLOSE_DELAY_MS); } openFlyout(): void { if (this.overlayRef || !this.item?.children?.length) return; const positionStrategy = this.overlay.position() .flexibleConnectedTo(this.railTrigger) .withPositions([ {originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: 4} ]) .withFlexibleDimensions(false) .withPush(true); this.overlayRef = this.overlay.create({ positionStrategy, scrollStrategy: this.overlay.scrollStrategies.close() }); const portal = new TemplatePortal(this.childrenTemplate, this.viewContainerRef); this.overlayRef.attach(portal); this.isFlyoutOpen = true; this.cdr.markForCheck(); } closeFlyout(): void { clearTimeout(this.closeTimeout); this.overlayRef?.dispose(); this.overlayRef = null; if (this.isFlyoutOpen) { this.isFlyoutOpen = false; this.cdr.markForCheck(); } } toggleCollapsable() { if (this.isCollapsed) { this.expand(); } else { this.collapse(); } } expand(): void { if (this.item?.disabled) { return; } if (!this.isCollapsed) { return; } this.isCollapsed = false; this.isExpanded = !this.isCollapsed; } collapse(): void { if (this.item?.disabled) { return; } if (this.isCollapsed) { return; } this.isCollapsed = true; this.isExpanded = !this.isCollapsed; } identify(index, item) { return index; } } ``` - [ ] **Step 3: Rewrite `collapsable.component.html`** — wrap the children list in a template usable both inline (normal mode) and inside the overlay (rail mode) ```html
{{item?.icon}} {{ item?.title }} {{ item?.title }}
expand_more
{{ item?.title }}
{{menuChilden.title}}
``` Notes on this template: - `childrenTemplate` is now used two ways: inline via `*ngTemplateOutlet` when the group is expanded in normal (non-rail) mode, and as a CDK `TemplatePortal` attached to the overlay when `openFlyout()` runs in rail mode. Both reuse the exact same child-rendering markup — no duplication. - Nested `` inside the flyout does **not** receive `[isRailMode]` (it's omitted, so it defaults to `false`), so a sub-group inside the flyout renders as a normal inline accordion, matching the spec's decision to only special-case the top-level rail icon, not everything nested inside the popup. - `onFlyoutClick` only closes the flyout when the click landed on an actual `` (a real navigation link rendered by `head-menu`/`basic-menu`), so clicking a nested group's toggle row inside the flyout (which is a plain `
`, not an ``) does not prematurely close the flyout. - [ ] **Step 4: Append flyout/active-dot styles to `collapsable.component.scss`** Append this block to the end of the existing file (every existing rule stays untouched): ```scss .se_item_warpper.rail-item { margin: 1px 6px; .bgMenu { position: relative; justify-content: center; padding: 8px 0; } .menu-icon { margin: 0; } } .rail-active-dot { position: absolute; top: 4px; right: 4px; width: 6px; height: 6px; border-radius: 50%; background: #c6392a; } .rail-flyout-panel { background: #ffffff; border-radius: 10px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); padding: 6px 4px; min-width: 220px; max-width: 300px; } .rail-flyout-title { font-size: 12px; font-weight: 700; color: #b4331f; padding: 6px 12px 4px; } ``` - [ ] **Step 5: Build and manually verify** Run: `cd /Users/nut.looknut/Project/rmutr/rmutr-web && npx tsc -p tsconfig.app.json --noEmit` Expected: no output (no type errors). Then, with the dev server running at `http://localhost:4200`: 1. Toggle the sidebar into rail mode. 2. Hover the icon for "แผนงานและโครงการ". Confirm a floating panel appears to the right of the icon, titled "แผนงานและโครงการ", listing every child ("ต้นฉบับเสนอโครงการ ง.5", "คำขอระหว่างปี", ...) as full-text clickable rows. 3. Move the mouse from the icon into the flyout panel without the cursor ever leaving both elements. Confirm the flyout does **not** close while doing this. 4. Click "ต้นฉบับเสนอโครงการ ง.5" inside the flyout. Confirm: the app navigates to `/app/original-project-proposal` and the flyout closes. 5. Confirm the "แผนงานและโครงการ" icon now shows a small red dot in its top-right corner (since the active route is one of its children). 6. Move the mouse away from the icon (without opening a flyout) and confirm no flyout appears for icons with no active descendant, and no dot renders on those. 7. Open the flyout again, then press `Escape`. Confirm it closes. 8. Open the flyout again, then click anywhere outside the sidebar. Confirm it closes (verifies the `scrollStrategies.close()` / natural blur-driven behavior — if it does not close on outside click, note this as a follow-up, since CDK's default overlay does not auto-dismiss on outside click without `hasBackdrop: true` + a backdrop click handler; see Task 2 Step 2 for where to add that if this check fails). 9. Reload the page while rail mode + an active child route are both in effect. Confirm the rail mode and the active-group dot both reappear correctly. - [ ] **Step 6: Commit** ```bash cd /Users/nut.looknut/Project/rmutr/rmutr-web git add src/app/app.module.ts src/app/layout/components/menu/collapsable git commit -m "feat: add hover/click flyout for menu groups in sidebar rail mode" ``` --- ## Self-Review Notes - **Spec coverage:** toggle repurposed (Task 1 Steps 1-4) ✓; width 260→72px with transition (Task 1 Step 7) ✓; header collapse (Task 1 Step 6/7) ✓; leaf items icon+tooltip (Task 1 Steps 8-13) ✓; flyout on hover/click for groups (Task 2 Steps 2-3) ✓; active-group dot indicator (Task 2 Steps 3-4) ✓; localStorage persistence (Task 1 Step 1) ✓; no new toggle button, no navigator.ts changes, no responsive work — none added, per constraints ✓. - **Known follow-up flagged, not silently dropped:** Task 2 Step 5's check #8 calls out that outside-click dismissal may need an explicit backdrop handler if the manual check fails — this is surfaced as a verification step rather than assumed to work, since CDK Overlay's outside-click behavior depends on configuration this plan doesn't lock in with a backdrop by default (kept minimal per YAGNI; add only if the manual check shows it's actually needed).