1303 lines
42 KiB
Markdown
1303 lines
42 KiB
Markdown
# 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`**
|
|
|
|
```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
|
|
<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`**
|
|
|
|
```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`**
|
|
|
|
```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:
|
|
|
|
```ts
|
|
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, Input, OnInit, ViewChild, OnDestroy} from '@angular/core';
|
|
```
|
|
|
|
```ts
|
|
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`**
|
|
|
|
```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
|
|
|
|
```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
|
|
<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
|
|
|
|
```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
|
|
<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)
|
|
|
|
```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
|
|
<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):
|
|
|
|
```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<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)
|
|
|
|
```html
|
|
<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):
|
|
|
|
```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).
|