Files
rmutr/docs/superpowers/plans/2026-07-07-sidebar-icon-rail.md
2026-07-07 00:55:14 +07:00

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.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<boolean>.

  • 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

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 — add isRailMode input

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 as head-menu.component.scss in 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 — add isRailMode input 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:

  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
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:

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.ts to 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:

  • 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 <app-collapsable> 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 class="se_item"> (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 <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:

  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
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).