42 KiB
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.tsis 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.tsfiles undersrc/app/layoutare 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<boolean>. -
Produces:
@Input() isRailMode: boolean = falseonMenuBarComponent,HeadMenuComponent,BasicMenuComponent,CollapsableComponent— Task 2 consumes this same input onCollapsableComponentto drive the flyout. -
Step 1: Update
main-layout.component.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
<div class="flex min-h-full" style="height: 100vh;">
<div class="flex flex-row w-full">
<app-menu-bar [isRailMode]="isRailMode"></app-menu-bar>
<div class="main-content flex flex-col w-full h-full" style="">
<div class="app-tool-bar"></div>
<app-tool-bar [isRailMode]="isRailMode" (railModeChange)="onToggleRailMode($event)"></app-tool-bar>
<app-sub-toolbar></app-sub-toolbar>
<div class="main-content-container">
<router-outlet (deactivate)="onDeactivate()" *ngIf="true"></router-outlet>
</div>
</div>
</div>
</div>
(This removes the old <div [ngStyle]="{'margin-left': isExpanedMenu ? '-280px':'0px'}"> off-screen-hide wrapper entirely — the sidebar's own width now controls its visible size.)
- Step 3: Update
tool-bar.component.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<boolean>()
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
<div class="flex items-center justify-between w-full toolbar-wrapper">
<div class="relative flex items-center w-full h-14 z-49">
<div class="flex items-center justify-between w-full h-full px-4">
<!-- Left: hamburger -->
<div class="flex items-center">
<button class="menu-toggle-btn" (click)="toggleMenu()" [title]="isRailMode ? 'ขยายเมนู' : 'ยุบเมนูเป็นไอคอน'">
<mat-icon>{{ isRailMode ? 'menu_open' : 'menu' }}</mat-icon>
</button>
</div>
<!-- Right: year pill + notification + user name + logout -->
<div class="flex items-center gap-3">
<div class="user-info">
<div class="user-avatar-circle">
<mat-icon>person</mat-icon>
</div>
<span class="user-display-name">
{{ oidsAuthSV.user?.profile?.display_name || 'ผู้ใช้งาน' }}
</span>
</div>
<button class="icon-btn logout-btn" (click)="logout()" title="ออกจากระบบ">
<mat-icon>logout</mat-icon>
</button>
</div>
</div>
</div>
</div>
- Step 5: Update
menu-bar.component.ts— addisRailModeinput
Add Input to the existing @angular/core import and add the field to the class:
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, OnDestroy} from '@angular/core';
export class MenuBarComponent implements OnInit, OnDestroy {
@ViewChild('sidebarMenuContainer') sidebarMenuContainer: ElementRef<HTMLElement>;
@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
<div class="sidebar-wrapper flex flex-col" [ngClass]="{'sidebar-rail': isRailMode}" style="height: 100%">
<!-- Header -->
<div class="sidebar-header flex items-center gap-3" [routerLink]="['/app/pending']" style="cursor: pointer;">
<div class="sidebar-icon-wrap">
<mat-icon class="sidebar-icon-inner">school</mat-icon>
</div>
<div class="flex flex-col" *ngIf="!isRailMode">
<span class="sidebar-title">มทร.รัตนโกสินทร์</span>
<span class="sidebar-subtitle">ระบบแผนงานและงบประมาณ</span>
</div>
</div>
<!-- Menu -->
<div class="sidebar-menu-container" #sidebarMenuContainer>
<ng-container *ngFor="let menu of menus; trackBy: trackByMenu">
<ng-container *ngIf="menu.type == 'divider' && !isRailMode">
<div class="sidebar-divider"></div>
</ng-container>
<ng-container *ngIf="menu.type == 'head' && permission.includes(menu.code) && !menu.assignableOnly">
<app-head-menu [item]="menu" [isRailMode]="isRailMode"></app-head-menu>
</ng-container>
<ng-container *ngIf="menu.type == 'basic' && permission.includes(menu.code) && !menu.assignableOnly">
<app-basic-menu [item]="menu" [permission]="permission" [isRailMode]="isRailMode"></app-basic-menu>
</ng-container>
<ng-container *ngIf="menu.type == 'collapsable' && permission.includes(menu.code) && !menu.assignableOnly">
<app-collapsable [item]="menu" [permission]="permission" [isRailMode]="isRailMode"></app-collapsable>
</ng-container>
</ng-container>
</div>
</div>
- Step 7: Update
menu-bar.component.scss— add rail width + host display
: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
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
<div class="se_item_warpper flex items-center" [routerLinkActive]="'se_item_active'" [ngClass]="{'rail-item': isRailMode}" [title]="isRailMode ? item?.title : null">
<span class="material-icons menu-icon" *ngIf="item?.icon">{{item?.icon}}</span>
<a class="se_item hover:cursor-pointer"
*ngIf="item.link && !item.externalLink && !item.function && !item.disabled"
[ngClass]="{'fuse-vertical-navigation-item-active-forced': item.active}"
[routerLink]="[item.link]">
<span *ngIf="!isRailMode">{{ item?.title }}</span>
<span class="menu-badge" *ngIf="item?.badge && !isRailMode">{{ item.badge }}</span>
</a>
</div>
- Step 10: Update
head-menu.component.scss— append rail-item rule
.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
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
<div class="se_item_warpper flex items-center" [ngClass]="{'se_item_active': isRelatedActive, 'rail-item': isRailMode}" [title]="isRailMode ? item?.title : null">
<span class="material-icons menu-icon" *ngIf="item?.icon">{{item?.icon}}</span>
<a class="se_item hover:cursor-pointer"
*ngIf="item.link && !item.externalLink && !item.function && !item.disabled"
[ngClass]="{'fuse-vertical-navigation-item-active-forced': item.active}"
[routerLink]="[item.link]">
<span *ngIf="!isRailMode">{{ item?.title }}</span>
<span class="menu-badge" *ngIf="item?.badge && !isRailMode">{{ item.badge }}</span>
</a>
</div>
- Step 13: Update
basic-menu.component.scss— same content ashead-menu.component.scssin Step 10 (this file already duplicates that stylesheet in the existing codebase; keep the duplication pattern)
.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— addisRailModeinput only (flyout logic lands in Task 2)
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)
<div class="se_item_warpper" [ngClass]="{'rail-item': isRailMode}" [title]="isRailMode ? item?.title : null">
<div (click)="toggleCollapsable()">
<div class="flex justify-between items-center bgMenu" [ngClass]="{'bgActive': item.ischildActive || isChildrenActive}">
<div class="flex items-center">
<span class="material-icons menu-icon" [ngClass]="item?.colorText" *ngIf="item?.icon">{{item?.icon}}</span>
<a class="se_item hover:cursor-pointer" [ngClass]="item?.colorText"
*ngIf="item?.link && !isRailMode"
[routerLink]="[item.link]">
{{ item?.title }}
</a>
<a class="se_item hover:cursor-pointer" [ngClass]="item?.colorText"
*ngIf="!item?.link && !isRailMode">
{{ item?.title }}
</a>
</div>
<div class="flex items-center" style="padding-right: 12px;" *ngIf="!isRailMode">
<mat-icon class="se_icon" [ngStyle]="{'transform': isCollapsed ? '': 'rotate(360deg)'}">expand_more</mat-icon>
</div>
</div>
</div>
<div class="se_item_children" @expandCollapse *ngIf="!isCollapsed && !isRailMode">
<ng-container *ngFor="let menuChilden of item.children;trackBy:identify">
<ng-container *ngIf="menuChilden.type == 'divider'">
<div class="menu-group-separator">
<span *ngIf="menuChilden.title" class="menu-group-label">{{menuChilden.title}}</span>
</div>
</ng-container>
<ng-container *ngIf="menuChilden.type == 'head' && permission.includes(menuChilden.code) && !menuChilden.assignableOnly" >
<app-head-menu [item]="menuChilden"></app-head-menu>
</ng-container>
<ng-container *ngIf="menuChilden.type == 'basic' && permission.includes(menuChilden.code) && !menuChilden.assignableOnly">
<app-basic-menu [item]="menuChilden" [permission]="permission"></app-basic-menu>
</ng-container>
<ng-container *ngIf="menuChilden.type == 'collapsable' && permission.includes(menuChilden.code) && !menuChilden.assignableOnly">
<app-collapsable [item]="menuChilden" [permission]="permission"></app-collapsable>
</ng-container>
</ng-container>
</div>
</div>
- 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):
.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:
- Load any
/app/...page. Confirm the sidebar looks unchanged (260px, full labels). - 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.
- Hover a leaf item's icon. Confirm a native browser tooltip shows its Thai title.
- Click a leaf item's icon. Confirm normal navigation still happens.
- Confirm a
collapsableitem (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). - Click the hamburger again. Confirm the sidebar expands back to 260px with full labels.
- Toggle to rail mode, then reload the page. Confirm it comes back up already in rail mode (state persisted via
localStorage).
- Step 18: Commit
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() isRailModeonCollapsableComponent(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:
import { OverlayModule } from '@angular/cdk/overlay';
Add OverlayModule to the imports: [...] array (anywhere in the list, e.g. right after BrowserAnimationsModule):
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.tsto add the flyout
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<HTMLElement>;
@ViewChild('childrenTemplate') childrenTemplate: TemplateRef<any>;
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)
<div class="se_item_warpper" [ngClass]="{'rail-item': isRailMode}">
<div #railTrigger
(click)="onTriggerClick()"
(mouseenter)="onTriggerEnter()"
(mouseleave)="onTriggerLeave()"
[title]="isRailMode ? item?.title : null">
<div class="flex justify-between items-center bgMenu" [ngClass]="{'bgActive': item.ischildActive || isChildrenActive}">
<div class="flex items-center">
<span class="material-icons menu-icon" [ngClass]="item?.colorText" *ngIf="item?.icon">{{item?.icon}}</span>
<span class="rail-active-dot" *ngIf="isRailMode && (item.ischildActive || isChildrenActive)"></span>
<a class="se_item hover:cursor-pointer" [ngClass]="item?.colorText"
*ngIf="item?.link && !isRailMode"
[routerLink]="[item.link]">
{{ item?.title }}
</a>
<a class="se_item hover:cursor-pointer" [ngClass]="item?.colorText"
*ngIf="!item?.link && !isRailMode">
{{ item?.title }}
</a>
</div>
<div class="flex items-center" style="padding-right: 12px;" *ngIf="!isRailMode">
<mat-icon class="se_icon" [ngStyle]="{'transform': isCollapsed ? '': 'rotate(360deg)'}">expand_more</mat-icon>
</div>
</div>
</div>
<div class="se_item_children" @expandCollapse *ngIf="!isCollapsed && !isRailMode">
<ng-container *ngTemplateOutlet="childrenTemplate"></ng-container>
</div>
</div>
<ng-template #childrenTemplate>
<div [ngClass]="{'rail-flyout-panel': isRailMode}"
(mouseenter)="onFlyoutAreaEnter()"
(mouseleave)="onFlyoutAreaLeave()"
(click)="onFlyoutClick($event)">
<div class="rail-flyout-title" *ngIf="isRailMode">{{ item?.title }}</div>
<ng-container *ngFor="let menuChilden of item.children;trackBy:identify">
<ng-container *ngIf="menuChilden.type == 'divider'">
<div class="menu-group-separator">
<span *ngIf="menuChilden.title" class="menu-group-label">{{menuChilden.title}}</span>
</div>
</ng-container>
<ng-container *ngIf="menuChilden.type == 'head' && permission.includes(menuChilden.code) && !menuChilden.assignableOnly" >
<app-head-menu [item]="menuChilden"></app-head-menu>
</ng-container>
<ng-container *ngIf="menuChilden.type == 'basic' && permission.includes(menuChilden.code) && !menuChilden.assignableOnly">
<app-basic-menu [item]="menuChilden" [permission]="permission"></app-basic-menu>
</ng-container>
<ng-container *ngIf="menuChilden.type == 'collapsable' && permission.includes(menuChilden.code) && !menuChilden.assignableOnly">
<app-collapsable [item]="menuChilden" [permission]="permission"></app-collapsable>
</ng-container>
</ng-container>
</div>
</ng-template>
Notes on this template:
-
childrenTemplateis now used two ways: inline via*ngTemplateOutletwhen the group is expanded in normal (non-rail) mode, and as a CDKTemplatePortalattached to the overlay whenopenFlyout()runs in rail mode. Both reuse the exact same child-rendering markup — no duplication. -
Nested
<app-collapsable>inside the flyout does not receive[isRailMode](it's omitted, so it defaults tofalse), 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. -
onFlyoutClickonly closes the flyout when the click landed on an actual<a class="se_item">(a real navigation link rendered byhead-menu/basic-menu), so clicking a nested group's toggle row inside the flyout (which is a plain<div>, not an<a>) 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):
.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:
- Toggle the sidebar into rail mode.
- 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.
- 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.
- Click "ต้นฉบับเสนอโครงการ ง.5" inside the flyout. Confirm: the app navigates to
/app/original-project-proposaland the flyout closes. - Confirm the "แผนงานและโครงการ" icon now shows a small red dot in its top-right corner (since the active route is one of its children).
- 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.
- Open the flyout again, then press
Escape. Confirm it closes. - 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 withouthasBackdrop: true+ a backdrop click handler; see Task 2 Step 2 for where to add that if this check fails). - 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
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).