Skip to content

บทที่ 14 — Angular Material + CDK

🟡 ก่อนอ่าน: ควรผ่านบท 1 (Components + Templates), บท 5 (Forms) และบท 12 (Accessibility) มาก่อน
ต้องรู้จัก: standalone component (บท 1), reactive forms + FormControl (บท 5), ARIA basics (บท 12) — บทนี้ใช้ทั้งสามอย่างนี้หนัก

← บทที่ 13: Security | สารบัญ | บทที่ 15: i18n →

หลังจบบท คุณจะ:

  • เข้าใจ Angular Material — component library + Material 3 design system
  • ใช้ CDK (Component Dev Kit) primitives — สร้าง custom component
  • ทำ theming + dark mode ที่ scalable
  • ใช้ Overlay + Portal + Drag-drop + Scrolling
  • เลือกระหว่าง Material / PrimeNG / Tailwind UI / custom
  • Test Material component ด้วย Component Harness

ใช้เวลา 3-4 ชั่วโมง


1. Angular Material คืออะไร

Angular Material คือคลัง UI component อย่างเป็นทางการของทีม Angular ที่ทำตามดีไซน์ Material Design ของ Google — Material Design คือแนวทางการออกแบบ UI ของ Google ที่กำหนดรูปลักษณ์, สี, การเคลื่อนไหว และขนาดของ component ให้มีมาตรฐาน มี component สำเร็จรูปกว่า 30 ตัวที่ผ่านการทดสอบและรองรับ accessibility มาให้พร้อม ช่วยให้เราไม่ต้องสร้างปุ่ม/ฟอร์ม/ตารางเองตั้งแต่ศูนย์ มาดูข้อดีข้อเสียและทางเลือกอื่น:

text
Angular Material:
- Component library ทาง official ของ Angular team
- Implement Material Design 3 (Google's design language)
- มี ~30 components ที่ accessible + tested
- Built on top of Angular CDK

ข้อดี:
✅ Accessibility ดี (ARIA + keyboard ครบ)
✅ Tested across browser
✅ Theming powerful (CSS custom properties, Sass mixins)
✅ Maintained by Google
✅ Stable API

ข้อเสีย:
❌ "Material" look — opinionated design (= ดีไซน์ที่มีแนวทางตายตัวตาม Google — ยืดหยุ่นน้อย)
❌ Bundle size ขึ้นอยู่กับ components ที่ import — ด้วย standalone + tree-shaking แต่ละ component เพิ่มประมาณ 10-30 KB (gzip) สำหรับแอป enterprise ทั่วไปถือว่ายอมรับได้ แต่ถ้าทำ landing page ที่เน้น performance สูงอาจหนักไป (ตรวจสอบด้วย bundlephobia.com ก่อน import)
❌ Customization บางอย่างยาก (override deep styles)

→ เหมาะสำหรับ: แอปองค์กร (enterprise), เครื่องมือภายในทีม (internal tool), แอปที่ไม่ต้องการดีไซน์แปลกใหม่

Alternative UI Libraries (2026)

LibraryStyleLicenseUse case
Angular MaterialMaterial 3Freeแอปองค์กร (Enterprise), Google ecosystem
PrimeNGMultiple themesFreeFeature-rich (มี component ครบกว่า)
NG-ZORROAnt DesignFreeEnterprise, ตลาดจีน
NG-BootstrapBootstrap 5Freeผู้ใช้ Bootstrap
Taiga UICustomFreeModern, ปรับแต่งได้มาก
Tailwind UI + headlessUtility-first (= ใช้ class CSS เล็กๆ ประกอบแทน CSS)Paid (Tailwind UI)ปรับแต่งได้ทุกอย่าง
shadcn-style (Spartan UI)Headless (= ให้แค่ behavior ไม่มีสไตล์, ออกแบบหน้าตาเอง)Freeแนว shadcn สำหรับ Angular

2. Setup Angular Material

การติดตั้งทำได้ด้วยคำสั่งเดียว ng add @angular/material ซึ่งจะถามให้เลือกธีมสี ติดตั้งฟอนต์/ไอคอน และตั้งค่า animation ให้อัตโนมัติ (หรือจะติดตั้งเองทีละขั้นก็ได้)

📖 ng คือ Angular CLI — เครื่องมือสั่งงาน Angular ผ่าน command line ถ้ายังไม่ได้ติดตั้ง ให้ดูวิธีใน [บท 1 — Getting Started] ก่อน

bash
ng add @angular/material

→ Prompts (ข้อความอาจต่างไปเล็กน้อยตามเวอร์ชัน CLI ที่ใช้จริง — ให้ยึดตามที่ terminal แสดงจริงเป็นหลัก):

text
? Choose a prebuilt theme name (or "custom"): 
  Azure/Blue (recommended)
  Rose/Red
  Magenta/Violet
  Cyan/Orange
  Custom
  
? Set up global Angular Material typography styles? Yes
? Include the Angular animations module? Include

→ Updates:

text
package.json:         @angular/material + @angular/cdk
src/styles.scss:       import theme + typography
src/index.html:        Material icons + Roboto font
angular.json:          icon font path

Manual Install

bash
npm install @angular/material @angular/cdk
scss
// styles.scss
@use '@angular/material' as mat;

html {
    @include mat.theme((
        color: (
            theme-type: light,
            primary: mat.$azure-palette,
            tertiary: mat.$blue-palette
        ),
        typography: (
            plain-family: 'Roboto, sans-serif'
        ),
        density: 0
    ));
}

📖 Sass (.scss) คือ CSS ที่มีความสามารถเพิ่มเติม เช่น mixin (เหมือน function), variable — ng add @angular/material จัดการ Sass setup ให้อัตโนมัติ ถ้าทำ manual install ต้องตรวจสอบว่า project ใช้ Sass อยู่ด้วย


3. First Components

มาลองใช้งานจริงกัน — แต่ละ component ของ Material อยู่ในโมดูลแยก เรา import เฉพาะตัวที่ใช้ (เช่น MatButtonModule, MatCardModule) แล้วใช้ผ่าน element/attribute ที่ขึ้นต้นด้วย mat- ในเทมเพลต ตัวอย่างนี้ทำการ์ดที่มีปุ่มและไอคอน

📖 standalone component = component ที่ไม่ต้องอยู่ใน NgModule — เราใส่ imports: [...] ที่ตัว component เองเลย ดูรายละเอียดในบท 1

typescript
// Import each component module
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';

@Component({
    standalone: true,
    imports: [MatButtonModule, MatIconModule, MatCardModule],
    template: `
        <mat-card>
            <mat-card-header>
                <mat-card-title>Hello Material</mat-card-title>
                <mat-card-subtitle>A simple card</mat-card-subtitle>
            </mat-card-header>
            <mat-card-content>
                <p>This is a Material card.</p>
            </mat-card-content>
            <mat-card-actions>
                <button mat-button>Cancel</button>
                <button matButton="filled">
                    <mat-icon>check</mat-icon>
                    OK
                </button>
            </mat-card-actions>
        </mat-card>
    `
})
export class HelloMaterialComponent {}

Button Variants

html
<button mat-button>Text</button>                <!-- ghost (ไม่มีพื้นหลัง) -->
<button mat-flat-button>Flat</button>           <!-- filled (มีพื้นหลังทึบ) -->
<button mat-stroked-button>Stroked</button>     <!-- outlined (มีกรอบ) -->
<button mat-raised-button>Raised</button>       <!-- elevated (มีเงา) -->
<button mat-icon-button><mat-icon>menu</mat-icon></button>
<button mat-fab><mat-icon>add</mat-icon></button>           <!-- FAB = Floating Action Button (ปุ่มลอยสำหรับ action หลัก) -->
<button mat-mini-fab><mat-icon>add</mat-icon></button>      <!-- small FAB -->

<!-- Color: primary | accent | warn -->

⚠️ ของเก่า vs ใหม่ — สำคัญ: color="primary|accent|warn" เป็น API ของ Material 2 — ถูก deprecate (เลิกใช้) ใน M3 (Material Design 3) (Angular 17.2+) เพราะ M3 ใช้ระบบ token แทน color attribute

โค้ดใหม่ทั้งบทควรใช้ variant API (matButton="filled") + แก้สีผ่าน CSS token (--mat-sys-primary) ดังตัวอย่างด้านล่าง — ถ้ายังเห็น color="primary" ในตัวอย่างอื่น ๆ ของบทนี้ ให้แทนที่ด้วย pattern นี้เมื่อนำไปใช้จริง

html
<!-- ✅ Material 3 variant API (Angular 17.2+) — แนะนำ -->
<button matButton="filled">Primary</button>
<button matButton="filled" class="danger-button">Danger</button>

<style>
    /* แก้สีผ่าน token — ไม่ใช่ color attribute */
    .danger-button {
        --mat-sys-primary: var(--mat-sys-error);
    }
</style>

<!-- ❌ Material 2 (legacy) — ยังทำงานได้แต่ deprecated -->
<button mat-flat-button color="primary">Primary</button>
<button mat-flat-button color="warn">Danger</button>

4. Common Components — Cheatsheet

ส่วนนี้เป็น "สูตรลัด" รวม component ที่ใช้บ่อยที่สุดพร้อมโค้ดตัวอย่าง — form field, select, datepicker, dialog, snackbar, table ฯลฯ เปิดอ้างอิงเวลาต้องการหยิบไปใช้ได้ทันที โดยไม่ต้องเปิดเอกสารทางการทีละหน้า:

Form Field + Input

วาง imports เหล่านี้ใน imports: [...] ของ standalone component ที่ใช้งาน:

typescript
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatRadioModule } from '@angular/material/radio';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatNativeDateModule } from '@angular/material/core';
import { ReactiveFormsModule } from '@angular/forms'; // จำเป็นสำหรับ formControlName

📖 ตัวอย่างด้านล่างใช้ formControlName ซึ่งเป็น Reactive Forms (ดูบท 5) — ต้อง import ReactiveFormsModule และมี FormGroup ครอบใน template ด้วย

html
<mat-form-field>
    <mat-label>Email</mat-label>
    <input matInput type="email" formControlName="email" required>
    <mat-icon matPrefix>email</mat-icon>
    <mat-hint>Use work email</mat-hint>
    @if (form.controls.email.invalid && form.controls.email.touched) {
        <mat-error>Invalid email</mat-error>
    }
</mat-form-field>

<mat-form-field>
    <mat-label>Country</mat-label>
    <mat-select formControlName="country">
        <mat-option value="th">Thailand</mat-option>
        <mat-option value="us">USA</mat-option>
        <mat-option value="jp">Japan</mat-option>
    </mat-select>
</mat-form-field>

<mat-checkbox formControlName="agree">I agree</mat-checkbox>

<mat-radio-group formControlName="gender">
    <mat-radio-button value="m">Male</mat-radio-button>
    <mat-radio-button value="f">Female</mat-radio-button>
</mat-radio-group>

<mat-form-field>
    <mat-label>Birth date</mat-label>
    <input matInput [matDatepicker]="picker" formControlName="birthday">
    <mat-datepicker-toggle matIconSuffix [for]="picker" />
    <mat-datepicker #picker />
</mat-form-field>

List + Table

html
<!-- ⚠️ directive names (matListItemTitle/matListItemLine/matListItemIcon)
     ใช้ Material 15+ ขึ้นไป (MDC migration = การเปลี่ยนมาใช้ Material Design Components ของ Google ตั้งแต่ v15 — เวอร์ชันเก่ากว่านี้ syntax ต่างออกไป)
     บทนี้เขียนสำหรับ Angular เวอร์ชันปัจจุบัน จึงใช้ syntax นี้ได้เลย — ตรวจสอบเวอร์ชัน Material ที่ติดตั้งจริงถ้าเจอ deprecation warning -->
<mat-list>
    @for (item of items(); track item.id) {
        <mat-list-item>
            <mat-icon matListItemIcon>{{ item.icon }}</mat-icon>
            <span matListItemTitle>{{ item.name }}</span>
            <span matListItemLine>{{ item.description }}</span>
        </mat-list-item>
    }
</mat-list>

<table mat-table [dataSource]="users()">
    <ng-container matColumnDef="name">
        <th mat-header-cell *matHeaderCellDef>Name</th>
        <td mat-cell *matCellDef="let user">{{ user.name }}</td>
    </ng-container>
    
    <ng-container matColumnDef="email">
        <th mat-header-cell *matHeaderCellDef>Email</th>
        <td mat-cell *matCellDef="let user">{{ user.email }}</td>
    </ng-container>
    
    <tr mat-header-row *matHeaderRowDef="['name', 'email']"></tr>
    <tr mat-row *matRowDef="let row; columns: ['name', 'email']"></tr>
</table>

Tabs + Stepper + Expansion

html
<mat-tab-group>
    <mat-tab label="Profile">
        Profile content
    </mat-tab>
    <mat-tab label="Account">
        Account content
    </mat-tab>
</mat-tab-group>

<mat-stepper #stepper>
    <mat-step label="Step 1">
        Content 1
        <button mat-button matStepperNext>Next</button>
    </mat-step>
    <mat-step label="Step 2">
        Content 2
        <button mat-button matStepperPrevious>Back</button>
        <button mat-button matStepperNext>Next</button>
    </mat-step>
    <mat-step label="Finish">
        Done!
    </mat-step>
</mat-stepper>

<mat-expansion-panel>
    <mat-expansion-panel-header>
        <mat-panel-title>Details</mat-panel-title>
    </mat-expansion-panel-header>
    Expanded content
</mat-expansion-panel>

Snackbar (Toast)

typescript
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({...})
export class MyComponent {
    private snack = inject(MatSnackBar);
    
    onSave() {
        this.snack.open('Saved!', 'Dismiss', {
            duration: 3000,
            horizontalPosition: 'end',
            verticalPosition: 'top'
        });
    }
}

Dialog (Modal)

typescript
import { MatDialog } from '@angular/material/dialog';

@Component({
    template: `<button (click)="confirm()">Delete</button>`
})
export class MyComponent {
    private dialog = inject(MatDialog);
    
    confirm() {
        const ref = this.dialog.open(ConfirmDialogComponent, {
            data: { title: 'Delete?', message: 'Cannot be undone' }
        });
        
        ref.afterClosed().subscribe(result => {
            if (result) {
                this.delete();
            }
        });
    }
}
typescript
// dialog component
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';

interface ConfirmData { title: string; message: string; }

@Component({
    standalone: true,
    imports: [MatDialogModule, MatButtonModule],
    template: `
        <h2 mat-dialog-title>{{ data.title }}</h2>
        <mat-dialog-content>{{ data.message }}</mat-dialog-content>
        <mat-dialog-actions>
            <button mat-button mat-dialog-close>Cancel</button>
            <button matButton="filled" [mat-dialog-close]="true">Delete</button>
        </mat-dialog-actions>
    `
})
export class ConfirmDialogComponent {
    // ⭐ ระบุ generic เพื่อ type safety — ไม่งั้น data จะเป็น any
    data = inject<ConfirmData>(MAT_DIALOG_DATA);
}
html
<button mat-button [matMenuTriggerFor]="menu">Menu</button>
<mat-menu #menu>
    <button mat-menu-item (click)="edit()">
        <mat-icon>edit</mat-icon> Edit
    </button>
    <button mat-menu-item (click)="delete()">
        <mat-icon>delete</mat-icon> Delete
    </button>
</mat-menu>

<button mat-icon-button matTooltip="Open settings">
    <mat-icon matBadge="3" matBadgeColor="warn">notifications</mat-icon>
</button>

5. Theming — Material 3

ธีมคือการกำหนด "ชุดสีและรูปลักษณ์" ของทั้งแอป Material 3 ใช้แนวคิด color token ที่สร้างจาก palette หลักไม่กี่สี แล้วแปลงเป็นตัวแปร CSS ให้ทุก component ใช้ร่วมกัน — ลองนึกว่าเป็น CSS variable ชุดใหญ่ที่ Angular gen ให้จากสีหลักที่เราเลือก ส่วนนี้สอนตั้งแต่ใช้ palette สำเร็จรูป, สร้างเอง, ทำ dark mode ไปจนถึงปรับความหนาแน่น (density):

Concept

text
Material 3 theming = color tokens generated from palette
- Primary (main brand color)
- Secondary
- Tertiary
- Error
- Neutral

Each token has tones: 0, 10, 20, ..., 100
→ Maps to CSS variables (--mat-primary-50, etc.)

Predefined Palette

scss
@use '@angular/material' as mat;

html {
    @include mat.theme((
        color: (
            theme-type: light,
            primary: mat.$azure-palette,
            tertiary: mat.$blue-palette
        ),
        typography: (
            plain-family: 'Roboto, sans-serif'
        ),
        density: 0
    ));
}

Built-in palettes: red, green, blue, yellow, cyan, magenta (= สีชมพูม่วง), orange, chartreuse (= สีเขียวเหลือง), azure (= สีฟ้าอ่อน), violet, rose

ลอง preview สีก่อนเลือกได้ที่ Material Theme Builder

Custom Palette (Material 3 / M3)

🎯 Material 3 (Angular 17.2+) ใช้ key tones 0-100 ไม่ใช่ 50-900 + A100/A700 (M2) — สร้าง palette ผ่าน Material Theme Builder แล้ว export SCSS, หรือสร้างจาก seed color เดียว:

📖 ศัพท์: key tones = ค่าความสว่างของสี 0-100 (0=ดำสุด, 100=ขาวสุด) / HCT (Hue=เฉดสี, Chroma=ความอิ่มตัวของสี, Tone=ความสว่าง) = ระบบสีใหม่ของ M3 ที่ออกแบบมาให้ contrast เพียงพออัตโนมัติ / seed color = สีเริ่มต้น 1 สีที่ Angular เอาไปคำนวณ palette ทั้งชุดให้

scss
@use '@angular/material' as mat;

// M3 palette จาก seed color เดียว — Angular gen tones ให้ครบ 13 level (0,10,20...95,99,100)
html {
    @include mat.theme((
        color: (
            theme-type: light,
            primary: mat.$azure-palette,        // built-in palette
            // หรือ custom seed — สร้าง palette ผ่าน Material Theme Builder แล้ว export SCSS มาแทน
        ),
        typography: (
            plain-family: 'Roboto, sans-serif'
        ),
        density: 0,
    ));
}

API เก่า (M2) — อย่าใช้ในโค้ดใหม่: mat.define-palette((50: #..., A100: #...), 500, 100, 700) — นี่คือ Material 2 ของก่อน v17.2 ปัจจุบัน deprecated

ทำไม M3 ดีกว่า:

  • Token-based — แก้สีผ่าน CSS variable runtime ได้ ไม่ต้อง recompile
  • Auto-derive tones จาก seed (HCT color space) — accessible by design
  • รองรับ multi-brand runtime switch ผ่าน CSS class

Dark Mode

scss
// Option 1: separate selector
html {
    @include mat.theme((
        color: (theme-type: light, primary: mat.$azure-palette)
    ));
}

html.dark {
    @include mat.theme((
        color: (theme-type: dark, primary: mat.$azure-palette)
    ));
}
typescript
// Toggle dark mode
@Injectable({ providedIn: 'root' })
export class ThemeService {
    isDark = signal(false);
    
    constructor() {
        // อ่านค่า theme จาก localStorage หรือ system preference ของ browser
        const saved = localStorage.getItem('theme');
        const preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        this.isDark.set(saved ? saved === 'dark' : preferDark);
        
        // effect() = ฟังก์ชันของ Angular Signals ที่รันทุกครั้งที่ค่า signal เปลี่ยน — ดูรายละเอียดในบท Signals
        effect(() => {
            document.documentElement.classList.toggle('dark', this.isDark());
            localStorage.setItem('theme', this.isDark() ? 'dark' : 'light');
        });
    }
    
    toggle() {
        this.isDark.update(v => !v);
    }
}
html
<button mat-icon-button (click)="theme.toggle()">
    <mat-icon>{{ theme.isDark() ? 'light_mode' : 'dark_mode' }}</mat-icon>
</button>

System Preference (No Toggle)

scss
@media (prefers-color-scheme: dark) {
    html {
        @include mat.theme((color: (theme-type: dark, primary: mat.$azure-palette)));
    }
}

Use Tokens ใน Component CSS

scss
.my-card {
    background: var(--mat-sys-surface);
    color: var(--mat-sys-on-surface);
    border: 1px solid var(--mat-sys-outline);
    border-radius: var(--mat-sys-corner-medium);
    padding: 16px;
}

.my-button {
    background: var(--mat-sys-primary);
    color: var(--mat-sys-on-primary);
}

→ Auto-adjust กับ theme (light/dark)

Density (= ความหนาแน่น/กระชับของ UI เช่น ขนาดปุ่ม ระยะห่าง)

scss
@include mat.theme((
    density: -2     // -1, -2, -3 — compact เหมาะกับแอปธนาคารหรือหน้าจอข้อมูลแน่น
));

→ Default (0) ~48px button height. Density -3 ~32px


6. Typography

นอกจากสี Material ยังคุมรูปแบบตัวอักษร (ฟอนต์, น้ำหนัก, ขนาด) เป็นชุดมาตรฐานเรียกว่า "type scale" เราตั้งฟอนต์รวมที่ธีม แล้วใช้ class สำเร็จ (เช่น mat-headline-medium, mat-body-large) กับข้อความเพื่อให้ typography ทั้งแอปสม่ำเสมอ:

scss
@use '@angular/material' as mat;

html {
    @include mat.theme((
        typography: (
            plain-family: 'Roboto, sans-serif',
            brand-family: 'Inter, sans-serif',
            bold-weight: 700,
            medium-weight: 500,
            regular-weight: 400
        )
    ));
}

Typography Classes

html
<h1 class="mat-display-large">Display Large</h1>
<h2 class="mat-headline-medium">Headline Medium</h2>
<p class="mat-title-large">Title Large</p>
<p class="mat-body-large">Body Large</p>
<p class="mat-label-small">Label Small</p>

→ Material 3 type scale = ระบบตัวอักษรที่สม่ำเสมอตลอดทั้งแอป


7. Layout + Responsive

โครงหน้าแอปทั่วไปมักมีแถบเมนูด้านข้าง (sidenav) กับแถบบน (toolbar) Material มี component พวกนี้ให้พร้อม และเมื่อรวมกับ BreakpointObserver ของ CDK เราทำให้เมนูปรับตัวตามขนาดจอได้ — บนจอใหญ่กางค้างไว้ บนมือถือซ่อนแล้วสไลด์ออกมา:

Sidenav (Side Drawer) — แถบเมนูด้านข้างที่เปิด/ปิดได้

html
<mat-sidenav-container>
    <mat-sidenav #sidenav mode="side" opened>
        <mat-list>
            <mat-list-item routerLink="/home">Home</mat-list-item>
            <mat-list-item routerLink="/users">Users</mat-list-item>
        </mat-list>
    </mat-sidenav>
    
    <mat-sidenav-content>
        <mat-toolbar color="primary"><!-- หมายเหตุ: color="primary" บน toolbar ก็ deprecated ใน M3 เช่นกัน — ให้ใช้ CSS token เช่น style="background: var(--mat-sys-primary)" แทนในโค้ดจริง -->
            <button mat-icon-button (click)="sidenav.toggle()">
                <mat-icon>menu</mat-icon>
            </button>
            <span>My App</span>
        </mat-toolbar>
        
        <main>
            <router-outlet />
        </main>
    </mat-sidenav-content>
</mat-sidenav-container>

Responsive Mode (CDK BreakpointObserver)

typescript
import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
import { toSignal } from '@angular/core/rxjs-interop'; // toSignal = แปลง Observable เป็น Signal
import { map } from 'rxjs/operators'; // map = แปลงค่าที่ observable ส่งออกมาแต่ละครั้ง เช่นแปลงจาก object เต็มให้เหลือแค่ boolean

@Component({...})
export class LayoutComponent {
    private bp = inject(BreakpointObserver);
    
    isMobile = toSignal(
        // Breakpoints.Handset = ขนาดจอมือถือ (phone)
        this.bp.observe([Breakpoints.Handset]).pipe(map(r => r.matches)),
        { initialValue: false }
    );
    
    sidenavMode = computed(() => this.isMobile() ? 'over' : 'side');
    sidenavOpened = computed(() => !this.isMobile());
}
html
<mat-sidenav 
    [mode]="sidenavMode()" 
    [opened]="sidenavOpened()"
>
    ...
</mat-sidenav>

8. CDK — Component Dev Kit

CDK (Component Dev Kit) คือ "ไส้ใน" ที่ Material สร้างทับอีกที มันให้ behavior (พฤติกรรม) และ accessibility primitives โดยไม่มีสไตล์ตายตัว — นี่คือความหมายของ "headless" คือ library ที่มีแค่พฤติกรรม ไม่มีสไตล์มาให้ เราออกแบบหน้าตาเองได้ทั้งหมด ข้อดีคือได้สร้าง UI แบบของเราเองแต่ได้พฤติกรรมที่ทดสอบมาแล้ว (เช่น overlay, drag-drop, virtual scroll) นี่คือรายชื่อโมดูลที่มี:

text
CDK = Angular Material's "headless" library (มีแค่พฤติกรรม ไม่มีสไตล์ตายตัว)
- No styling — just behavior + a11y primitives
- Build your own UI using CDK components
- Used internally by Material

Modules:
- @angular/cdk/a11y          — จัดการ focus และประกาศข้อความให้ screen reader
- @angular/cdk/overlay        — popup, dropdown, tooltip (ลอยเหนือหน้าจอ)
- @angular/cdk/portal         — render content ใน component อื่น
- @angular/cdk/drag-drop      — ลากวาง (drag and drop)
- @angular/cdk/scrolling      — virtual scroll (แสดงเฉพาะ item ที่มองเห็น)
- @angular/cdk/layout         — ตรวจขนาดจอ (breakpoint observer)
- @angular/cdk/clipboard      — คัดลอกข้อความ
- @angular/cdk/dialog         — modal primitives
- @angular/cdk/menu           — menu primitives
- @angular/cdk/table          — table primitives
- @angular/cdk/tree           — tree primitives

9. CDK Overlay — Popover/Dropdown

CDK Overlay คือเครื่องมือสำหรับแสดงเนื้อหา "ลอย" อยู่เหนือหน้าจอ — popover (= กล่องข้อมูลที่ลอยขึ้นมาเมื่อกดปุ่มหรือ hover), dropdown, tooltip, dialog — พร้อมจัดตำแหน่งให้สัมพันธ์กับ element ที่กดและเลื่อนตามได้ เป็นพื้นฐานของ component ลอยทั้งหมด

ภาพรวมการทำงาน: สร้าง overlayRef ก่อน แล้วบอกว่าจะ attach component อะไร วางไว้ที่ไหน (position strategy) และปิดยังไง (backdrop click):

typescript
import { Component, ElementRef, inject, viewChild } from '@angular/core';
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';

@Component({
    template: `
        <button (click)="open()" #trigger>Open</button>
    `
})
export class MenuButton {
    private overlay = inject(Overlay);
    private trigger = viewChild('trigger', { read: ElementRef });
    
    private overlayRef?: OverlayRef;
    
    open() {
        this.overlayRef?.dispose();
        
        const positionStrategy = this.overlay.position()
            .flexibleConnectedTo(this.trigger()!)
            .withPositions([{
                originX: 'start',
                originY: 'bottom',
                overlayX: 'start',
                overlayY: 'top',
                offsetY: 4
            }]);
        
        this.overlayRef = this.overlay.create({
            positionStrategy,
            hasBackdrop: true,
            backdropClass: 'cdk-overlay-transparent-backdrop',
            scrollStrategy: this.overlay.scrollStrategies.reposition()
        });
        
        this.overlayRef.attach(new ComponentPortal(MenuPanelComponent));
        this.overlayRef.backdropClick().subscribe(() => this.close());
    }
    
    close() {
        this.overlayRef?.dispose();
    }
}

→ ใช้สร้าง popover ได้หลายรูปแบบ (autocomplete=พิมพ์แล้วแนะนำ, tooltip=คำใบ้เมื่อชี้, dropdown=รายการเลือก, dialog แบบกำหนดเอง)


10. CDK Portal — Render Anywhere

Portal คือกลไกที่ให้เรา "นิยาม" template ไว้ที่หนึ่ง แต่ไปแสดงผลจริงในอีกที่หนึ่ง (outlet = จุดที่กำหนดว่าจะแสดงผลที่ไหน) — Portal เหมือนประกาศเนื้อหาไว้ที่ Component A แต่ให้ไปแสดงในกล่อง Component B มีประโยชน์ตอนทำ modal ที่ต้องแสดงอยู่บนสุดหน้าจอ โค้ดยังอยู่ใน component เดิม

📖 ตัวอย่างด้านล่างใช้ outlet ใน component เดียวกันเพื่อความง่าย — use case จริงคือ render ใน parent component หรือ document.body (ถ้า outlet อยู่ component เดียวกันแบบตัวอย่างนี้ ผลลัพธ์ที่เห็นจะไม่ต่างจากใช้ @if ธรรมดา — จุดที่ Portal เก่งจริงคือตอนต้องส่ง content ข้าม component หรือไปแปะไว้นอก DOM tree ปกติ เช่น modal ที่ต้องลอยอยู่บนสุดเหนือทุกอย่างใน document.body)

typescript
import { CdkPortal, PortalModule } from '@angular/cdk/portal';

@Component({
    standalone: true,
    imports: [PortalModule],
    template: `
        <ng-template cdkPortal>
            <div class="modal-content">Hi from portal!</div>
        </ng-template>
        
        <div class="modal-host">
            <ng-template [cdkPortalOutlet]="myPortal()"></ng-template>
        </div>
    `
})
export class PortalDemo {
    // ✅ modern signal-based query (ดูบท 1)
    myPortal = viewChild.required(CdkPortal);
}

→ Render template ใน outlet ที่อื่น (เช่น modal layer, tooltip layer)


11. CDK Drag-Drop

CDK Drag-Drop ทำให้สร้างฟีเจอร์ลากวาง (จัดเรียงรายการ, ลากข้ามคอลัมน์แบบ Kanban) ได้ง่าย ๆ แค่ใส่ directive cdkDropList กับ cdkDrag แล้วจัดการการสลับตำแหน่งใน array ผ่าน helper:

  • moveItemInArray = ย้าย item ภายใน list เดิม
  • transferArrayItem = ย้าย item ข้ามไป list อื่น
typescript
import { CdkDragDrop, CdkDropList, CdkDrag, moveItemInArray } from '@angular/cdk/drag-drop';

@Component({
    standalone: true,
    imports: [CdkDropList, CdkDrag],
    template: `
        <div cdkDropList (cdkDropListDropped)="drop($event)">
            @for (item of items(); track item.id) {
                <div cdkDrag>{{ item.name }}</div>
            }
        </div>
    `
})
export class SortableList {
    items = signal([
        { id: 1, name: 'A' },
        { id: 2, name: 'B' },
        { id: 3, name: 'C' }
    ]);
    
    drop(event: CdkDragDrop<{ id: number; name: string }[]>) {
        this.items.update(items => {
            const copy = [...items]; // spread copy ก่อน เพราะ signal ต้องการ new reference (ไม่ mutate array เดิม)
            moveItemInArray(copy, event.previousIndex, event.currentIndex);
            return copy;
        });
    }
}

Cross-List Drag

📖 UserService, UserFormDialogComponent, ConfirmDialogComponent ใน section ต่าง ๆ ของบทนี้เป็น class ที่ต้องสร้างเอง — ดูตัวอย่างได้ในส่วน section 21

typescript
import { CdkDragDrop, CdkDropList, CdkDrag, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';

interface Task { id: number; text: string; }

@Component({
    standalone: true,
    imports: [CdkDropList, CdkDrag],
    template: `
        <div 
            cdkDropList 
            #todo="cdkDropList" 
            [cdkDropListData]="todos()"
            [cdkDropListConnectedTo]="[done]"
            (cdkDropListDropped)="drop($event)"
        >
            @for (t of todos(); track t.id) {
                <div cdkDrag>{{ t.text }}</div>
            }
        </div>
        
        <div 
            cdkDropList 
            #done="cdkDropList" 
            [cdkDropListData]="dones()"
            [cdkDropListConnectedTo]="[todo]"
            (cdkDropListDropped)="drop($event)"
        >
            @for (d of dones(); track d.id) {
                <div cdkDrag>{{ d.text }}</div>
            }
        </div>
    `
})
export class KanbanBoard {
    todos = signal<Task[]>([
        { id: 1, text: 'Task 1' },
        { id: 2, text: 'Task 2' }
    ]);
    dones = signal<Task[]>([
        { id: 3, text: 'Task 3' }
    ]);
    
    drop(event: CdkDragDrop<Task[]>) {
        if (event.previousContainer === event.container) {
            // Same list — reorder (ย้ายใน list เดิม)
            this.todos.update(arr => {
                const copy = [...arr];
                moveItemInArray(copy, event.previousIndex, event.currentIndex);
                return copy;
            });
        } else {
            // Different list — transfer (ย้ายข้าม list)
            // ต้องอัปเดต signal ทั้ง source และ destination — ไม่ mutate array โดยตรง
            const prevData = [...event.previousContainer.data];
            const currData = [...event.container.data];
            transferArrayItem(prevData, currData, event.previousIndex, event.currentIndex);
            // กำหนดว่า list ไหนคือ todo และ done แล้ว update signal ที่ถูกต้อง
            if (event.previousContainer.id === 'todo') {
                this.todos.set(prevData);
                this.dones.set(currData);
            } else {
                this.dones.set(prevData);
                this.todos.set(currData);
            }
        }
    }
}

→ ใช้ทำ Kanban board, รายการเรียงลำดับได้ (sortable list), ลากเข้าโฟลเดอร์ (drag-to-folder)


12. CDK Scrolling — Virtual Scroll

ถ้าต้องแสดงรายการยาวมาก (เป็นพัน ๆ แถว) การ render ทั้งหมดลง DOM จะทำให้แอปอืด Virtual Scroll แก้ปัญหานี้ด้วยเทคนิค windowing (= แสดงเฉพาะ item ที่อยู่ในกรอบมองเห็น ไม่ render ทั้งหมด) — render เฉพาะแถวที่มองเห็นบนจอ ส่วนที่เลื่อนพ้นไปแล้วจะถูกถอดออก ทำให้ลื่นแม้มีหมื่นรายการ:

typescript
import { ScrollingModule } from '@angular/cdk/scrolling'; // ScrollingModule ครอบ CdkVirtualScrollViewport ไว้แล้ว

@Component({
    standalone: true,
    imports: [ScrollingModule],
    template: `
        <!-- ⚠️ ต้องใช้ *cdkVirtualFor (structural directive ของ CDK) — แทนด้วย @for ไม่ได้
             เพราะ @for render ทุก item ใน DOM (ไม่มี windowing) → ทำลายจุดประสงค์ virtual scroll -->
        <cdk-virtual-scroll-viewport itemSize="50" style="height: 500px;">
            <div *cdkVirtualFor="let item of items(); trackBy: trackById" class="item">
                {{ item.name }}
            </div>
        </cdk-virtual-scroll-viewport>
    `,
    styles: `.item { height: 50px; border-bottom: 1px solid #ccc; }`
})
export class BigList {
    items = signal(
        Array.from({ length: 10000 }, (_, i) => ({ id: i, name: `Item ${i}` }))
    );
    
    trackById(_: number, item: { id: number }) {
        return item.id;
    }
}

→ แสดง 10,000 รายการอย่างลื่นไหล (render เฉพาะ item ที่มองเห็นใน DOM ณ ขณะนั้น)

Variable Size

⚠️ autosize directive เป็น experimental และถูก deprecate ใน CDK v17+ เพราะ performance ไม่ดีและอาจเกิด scroll jumping ใน production — แนะนำให้ใช้ fixed itemSize เมื่อทำได้ หรือ implement custom VirtualScrollStrategy สำหรับ item ที่ความสูงต่างกันจริง ๆ

html
<cdk-virtual-scroll-viewport autosize>
    <div *cdkVirtualFor="let row of rows()" class="row">{{ row.content }}</div>
</cdk-virtual-scroll-viewport>

13. CDK Clipboard

งานเล็ก ๆ ที่พบบ่อยอย่าง "ปุ่มคัดลอกข้อความ" CDK มี Clipboard service ให้เรียก .copy() ได้ทันที โดยจัดการความเข้ากันได้ของเบราว์เซอร์ให้แล้ว ไม่ต้องไปยุ่งกับ Clipboard API ดิบ ๆ เอง:

typescript
import { Clipboard } from '@angular/cdk/clipboard';

@Component({
    template: `<button (click)="copy()">Copy</button>`
})
export class ShareButton {
    private clipboard = inject(Clipboard);
    
    copy() {
        const success = this.clipboard.copy('Hello, World!');
        if (success) {
            this.snack.open('Copied!', 'OK', { duration: 2000 });
        }
    }
}
html
<!-- Directive -->
<input #input value="copy this">
<button [cdkCopyToClipboard]="input.value">Copy</button>

14. Component Harness — Testing

เวลาเทสต์ component ที่ใช้ Material อย่าไปขุด HTML ข้างในของมัน เพราะโครงสร้าง HTML ภายในเปลี่ยนได้ทุกเวอร์ชัน ให้ใช้ Harness แทน — Harness คือชุด API ทางการที่ Angular Material ให้มาสำหรับสั่งงาน component ใน test โดยไม่ต้องแตะ HTML โดยตรง เช่น .setValue(), .click() ข้อดีคือเทสต์จึงไม่พังเมื่ออัปเกรด Material เวอร์ชันใหม่ (หลักการเดียวกับที่เคยเห็นในบท 8 — Testing):

typescript
import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatInputHarness } from '@angular/material/input/testing';

describe('LoginComponent', () => {
    let fixture: ComponentFixture<LoginComponent>;
    let loader: HarnessLoader;
    
    beforeEach(async () => {
        await TestBed.configureTestingModule({
            imports: [LoginComponent]
        }).compileComponents();
        
        fixture = TestBed.createComponent(LoginComponent);
        loader = TestbedHarnessEnvironment.loader(fixture);
    });
    
    it('should submit form', async () => {
        const emailInput = await loader.getHarness(
            MatInputHarness.with({ selector: '[formControlName="email"]' })
        );
        await emailInput.setValue('test@example.com');
        
        const passwordInput = await loader.getHarness(
            MatInputHarness.with({ selector: '[formControlName="password"]' })
        );
        await passwordInput.setValue('password123');
        
        const submitBtn = await loader.getHarness(
            MatButtonHarness.with({ text: /sign in/i })
        );
        await submitBtn.click();
        
        // assertions
    });
});

→ Harness = ชั้น abstraction บน Material — ไม่ต้องใช้ querySelector
→ Test ไม่ขึ้นกับ DOM structure ของ Material internals


15. Custom Form Field

บางครั้งเราต้องการ input แบบพิเศษ (เช่นช่องใส่ tag) ที่ยังอยากใช้กรอบสวย ๆ และ label ลอยของ <mat-form-field> ได้ ทำได้โดยให้ component ของเรา implement interface (= สัญญา/contract ที่ระบุว่า class ต้อง implement method/property อะไรบ้าง) MatFormFieldControl เพื่อบอก Material ว่า "ฉันก็เป็น form control นะ"

📖 ControlValueAccessor = interface ที่ทำให้ Angular Forms คุยกับ component ของเราได้เหมือนเป็น input ปกติ (เช่นรู้ค่าปัจจุบัน, รับรู้เมื่อค่าเปลี่ยน) — สอนละเอียดในบท 5 (Forms)

⚠️ ขั้นสูง: section นี้แสดงเฉพาะโครงสร้างเท่านั้น — Custom Form Field เป็น concept ขั้นสูงที่ต้องใช้ความรู้เรื่อง ControlValueAccessor และ Angular forms อย่างลึกซึ้ง ดู full example ได้ที่ Angular Material docs

typescript
import { MatFormFieldControl } from '@angular/material/form-field';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
    selector: 'app-tags-input',
    standalone: true,
    template: `<input ...>`,
    providers: [
        { provide: MatFormFieldControl, useExisting: TagsInputComponent },
        { provide: NG_VALUE_ACCESSOR, useExisting: TagsInputComponent, multi: true }
    ]
})
export class TagsInputComponent implements MatFormFieldControl<string[]>, ControlValueAccessor {
    // Implement MatFormFieldControl interface
    value: string[] | null = null;
    placeholder = '';
    focused = false;
    empty = false;
    shouldLabelFloat = true;
    required = false;
    disabled = false;
    errorState = false;
    stateChanges = new Subject<void>();
    controlType = 'tags-input';
    
    // ... full implementation
}
html
<mat-form-field>
    <mat-label>Tags</mat-label>
    <app-tags-input formControlName="tags"></app-tags-input>
</mat-form-field>

16. Performance Tips

Material มี component เยอะ ถ้า import มาทั้งหมดหรือ import ผ่าน shared module เดียว bundle จะใหญ่เกินจำเป็น เคล็ดลับด้าน performance คือ import เฉพาะตัวที่ใช้ (standalone ช่วยให้ tree-shake ได้), โหลด animation แบบ lazy และใช้ virtual scroll กับรายการยาว:

Lazy Load Material Modules

typescript
// การ import ทีละ sub-path แบบนี้ถูกต้องอยู่แล้ว (ไม่มี barrel '@angular/material' รวมทุกอย่าง)
// ❌ ปัญหาจริงคือขั้นตอนถัดไป — เอาไปรวมไว้ใน SharedModule แล้ว export ทั้งหมด — bundle ใหญ่โดยไม่จำเป็น
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
// ... แล้วรวมใน SharedModule และ export ทั้งหมด — tree-shake ไม่ได้ (defeats tree-shaking)

// ✅ Import ต่อ component (standalone) — tree-shakable (= bundler ตัดโค้ดที่ไม่ได้ import ออกได้ — ลดขนาด bundle)
@Component({
    standalone: true,
    imports: [MatButtonModule]    // import เฉพาะตัวที่ใช้
})

Use Async Animation

typescript
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

providers: [
    provideAnimationsAsync()    // lazy load animations
]

→ ประหยัด ~30 KB (โหลด animations module ทีหลัง ไม่ต้องรวมไว้ตั้งแต่ต้น)

Disable Material Ripples (if not needed)

typescript
import { MAT_RIPPLE_GLOBAL_OPTIONS } from '@angular/material/core';

providers: [
    { provide: MAT_RIPPLE_GLOBAL_OPTIONS, useValue: { disabled: true } }
]

17. Common Patterns

ส่วนนี้รวม "แพตเทิร์น" ที่เกือบทุกแอปต้องใช้พร้อมโค้ดสำเร็จรูป — ปุ่มที่แสดง spinner ตอนกำลังโหลด, service สำหรับ dialog ยืนยัน, service แจ้งเตือน (snackbar) และตารางที่มี pagination + sort + filter ครบ ก๊อปไปปรับใช้ได้เลย:

Loading Button

html
<button 
    matButton="filled"
    [disabled]="loading()"
    (click)="save()"
>
    @if (loading()) {
        <mat-spinner diameter="20"></mat-spinner>
    } @else {
        Save
    }
</button>

Confirm Dialog Service

typescript
@Injectable({ providedIn: 'root' })
export class ConfirmService {
    private dialog = inject(MatDialog);
    
    confirm(title: string, message: string): Promise<boolean> {
        const ref = this.dialog.open(ConfirmDialogComponent, {
            data: { title, message }
        });
        return firstValueFrom(ref.afterClosed()).then(r => !!r);
    }
}

// Use
async delete(item: Item) {
    if (await this.confirm.confirm('Delete?', `Delete ${item.name}?`)) {
        this.service.delete(item.id);
    }
}

Snackbar Service

typescript
@Injectable({ providedIn: 'root' })
export class NotifyService {
    private snack = inject(MatSnackBar);
    
    success(msg: string) {
        this.snack.open(msg, 'OK', { duration: 3000, panelClass: 'success' });
    }
    
    error(msg: string) {
        this.snack.open(msg, 'Dismiss', { duration: 5000, panelClass: 'error' });
    }
}

Table with Pagination + Sort

typescript
import { MatTableDataSource } from '@angular/material/table';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';

// AfterViewInit = lifecycle hook ที่รันหลัง Angular render template เสร็จ
// จำเป็นเพราะ paginator/sort element ต้องมีอยู่ใน DOM ก่อนจึงจะผูกได้
// (ดู template ที่มี <mat-paginator> และ matSort ด้านล่างของ class นี้ — ต้องมี element จริงถึงจะ query เจอ)
@Component({...})
export class UserTableComponent implements AfterViewInit {
    // ✅ signal-based query (consistent กับ §21 + บทอื่น ๆ)
    paginator = viewChild.required(MatPaginator);
    sort = viewChild.required(MatSort);
    
    dataSource = new MatTableDataSource<User>([]);
    displayedColumns = ['name', 'email', 'role', 'actions'];
    
    ngAfterViewInit() {
        this.dataSource.paginator = this.paginator();
        this.dataSource.sort = this.sort();
    }
    
    setData(users: User[]) {
        this.dataSource.data = users;
    }
    
    filter(value: string) {
        this.dataSource.filter = value.trim().toLowerCase();
    }
}
html
<!-- ($event.target as HTMLInputElement).value เป็นวิธีที่ type-safe กว่า $any() ซึ่งเป็น escape hatch ที่ควรหลีกเลี่ยงใน strict TypeScript project -->
<input matInput (input)="filter(($event.target as HTMLInputElement).value)" placeholder="Filter">

<table mat-table [dataSource]="dataSource" matSort>
    <ng-container matColumnDef="name">
        <th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
        <td mat-cell *matCellDef="let user">{{ user.name }}</td>
    </ng-container>
    <!-- other columns -->
    
    <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
    <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>

<mat-paginator [pageSize]="20" [pageSizeOptions]="[5, 10, 20, 50]" />

18. Tailwind + Material (Combine)

หลายทีมชอบใช้ Material สำหรับ component ที่ซับซ้อน (ตาราง, datepicker) แต่ใช้ Tailwind สำหรับจัด layout และ spacing ทั้งสองทำงานร่วมกันได้ — แค่ import ทั้งคู่ใน styles แล้วใช้ utility class ของ Tailwind กับ component ของ Material ได้เลย:

bash
# Install Tailwind
npm install -D tailwindcss @tailwindcss/postcss postcss

📖 หลัง install ยังต้องสร้างไฟล์ config เพิ่ม เช่น tailwind.config.js และ postcss.config.js — ดูวิธีตั้งค่าสำหรับ Angular โดยเฉพาะที่ Tailwind CSS Angular Guide

css
/* styles.scss */
@use '@angular/material' as mat;
@use 'tailwindcss';

html {
    @include mat.theme((color: (theme-type: light, primary: mat.$azure-palette)));
}
html
<!-- Use both -->
<mat-card class="m-4 p-6 hover:shadow-lg transition-shadow">
    <button matButton="filled" class="mt-2">Click</button>
</mat-card>

→ Material = จัดการ behavior + accessibility. Tailwind = utility สำหรับ layout และ spacing รวดเร็ว

⚠️ อย่าใช้ Tailwind override สไตล์ภายในของ Material — ให้ใช้ Material API (theme token, density) ก่อนเสมอ


19. ⚠️ Common Mistakes

ตารางนี้สรุปกับดักที่พบบ่อยตอนใช้ Material คู่กับวิธีที่ถูกต้อง โดยเฉพาะการพยายาม override สไตล์ภายในแทนที่จะใช้ theme token และการลืม provideAnimations:

Override Material with ::ng-deep everywhereUse theme tokens + density (ใช้ตัวแปร CSS ของ theme แทน)
Import all Material modules globallyStandalone import per component (import เฉพาะ component ที่ใช้)
Hard-code color (#1976d2)Use theme tokens (var(--mat-sys-primary))
ลืม setup animationsใช้ provideAnimationsAsync() ใน app.config.ts (standalone app, Angular 15+) — BrowserAnimationsModule เป็น NgModule API สำหรับ app เก่า
Custom dialog from scratchUse MatDialog (a11y included — มี accessibility ในตัว)
Build dropdown from scratchUse cdk/overlay
Test Material with querySelectorUse Component Harness (ทดสอบผ่าน API ทางการ — ไม่แตะ HTML)
Mix Material + BootstrapPick one — collision in styles (สไตล์ชนกัน)
ใช้ Material 2 API (mat.define-palette, color=) กับ Material v17.2+ใช้ M3 API (mat.theme(), variant, token) ซึ่ง stable ตั้งแต่ Material v17.2

20. Migration: Bootstrap → Material

ถ้ามีแอปเดิมที่ใช้ Bootstrap อยู่ การย้ายมา Material ควรทำแบบค่อยเป็นค่อยไป ไม่ใช่รื้อทีเดียว — ตั้ง Material ขึ้นมาขนานกันก่อน แล้วแทนที่ component ทีละตัว (modal→MatDialog, dropdown→MatMenu) สุดท้ายค่อยถอด Bootstrap ออก:

text
1. Audit Bootstrap usage:
   - Components (modal, dropdown, navbar, ...)
   - Utility classes (m-2, p-3, ...)
   
2. Setup Material parallel:
   - Add Material + theme
   - Keep Bootstrap utility (or replace with Tailwind)
   
3. Replace components 1-by-1:
   - bootstrap modal → MatDialog
   - bootstrap dropdown → MatMenu
   - bootstrap form → mat-form-field
   - bootstrap card → mat-card
   
4. Remove Bootstrap import + CSS

→ อย่ารื้อทีเดียวทั้งหมด (big bang) — ค่อย ๆ ย้ายทีละ component (incremental migration)


21. ตัวอย่างเต็ม — User Management App

ปิดท้ายด้วยตัวอย่างหน้าจัดการผู้ใช้แบบครบเครื่องที่ประกอบ component ของ Material หลายตัวเข้าด้วยกัน — ตารางพร้อม sort/pagination/filter, ปุ่ม, dialog และ snackbar ใช้เป็นแม่แบบของหน้า CRUD ทั่วไปได้เลย:

typescript
// UserService, UserFormDialogComponent, ConfirmDialogComponent เป็น class ที่ต้องสร้างเอง
// UserService: service สำหรับ CRUD user (inject + call API)
// UserFormDialogComponent: dialog form สำหรับเพิ่ม/แก้ไข user
// ConfirmDialogComponent: ดูตัวอย่างใน section 4 (Dialog) ด้านบน
import { firstValueFrom } from 'rxjs';
import { Component, inject, signal, viewChild, AfterViewInit } from '@angular/core';
import { MatTableModule, MatTableDataSource } from '@angular/material/table';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';

@Component({
    selector: 'app-users',
    standalone: true,
    imports: [
        MatTableModule, MatPaginatorModule, MatSortModule,
        MatButtonModule, MatIconModule, MatFormFieldModule,
        MatInputModule, MatDialogModule, MatProgressSpinnerModule
    ],
    template: `
        <div class="page">
            <header class="header">
                <h1>Users</h1>
                <button matButton="filled" (click)="add()">
                    <mat-icon>add</mat-icon> Add User
                </button>
            </header>
            
            <mat-form-field class="search">
                <mat-label>Search</mat-label>
                <input matInput (input)="filter(($event.target as HTMLInputElement).value)">
                <mat-icon matPrefix>search</mat-icon>
            </mat-form-field>
            
            @if (loading()) {
                <div class="loading">
                    <mat-spinner />
                </div>
            } @else {
                <table mat-table [dataSource]="dataSource" matSort class="users-table">
                    <ng-container matColumnDef="name">
                        <th mat-header-cell *matHeaderCellDef mat-sort-header>Name</th>
                        <td mat-cell *matCellDef="let u">{{ u.name }}</td>
                    </ng-container>
                    
                    <ng-container matColumnDef="email">
                        <th mat-header-cell *matHeaderCellDef mat-sort-header>Email</th>
                        <td mat-cell *matCellDef="let u">{{ u.email }}</td>
                    </ng-container>
                    
                    <ng-container matColumnDef="role">
                        <th mat-header-cell *matHeaderCellDef>Role</th>
                        <td mat-cell *matCellDef="let u">
                            <span class="role-chip">{{ u.role }}</span>
                        </td>
                    </ng-container>
                    
                    <ng-container matColumnDef="actions">
                        <th mat-header-cell *matHeaderCellDef></th>
                        <td mat-cell *matCellDef="let u">
                            <button mat-icon-button (click)="edit(u)" aria-label="Edit">
                                <mat-icon>edit</mat-icon>
                            </button>
                            <button mat-icon-button (click)="remove(u)" aria-label="Delete">
                                <!-- color="warn" deprecated ใน M3 — ใช้ CSS token แทน: style="color: var(--mat-sys-error)" -->
                                <mat-icon style="color: var(--mat-sys-error)">delete</mat-icon>
                            </button>
                        </td>
                    </ng-container>
                    
                    <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
                    <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
                </table>
                
                <mat-paginator [pageSize]="20" [pageSizeOptions]="[10, 20, 50]" />
            }
        </div>
    `,
    styles: `
        .page { padding: 24px; max-width: 1200px; margin: 0 auto; }
        .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
        .search { width: 100%; max-width: 400px; margin-bottom: 16px; }
        .users-table { width: 100%; }
        .loading { display: flex; justify-content: center; padding: 48px; }
        .role-chip {
            padding: 4px 8px;
            border-radius: 12px;
            background: var(--mat-sys-secondary-container);
            color: var(--mat-sys-on-secondary-container);
            font-size: 12px;
        }
    `
})
export class UsersComponent implements AfterViewInit {
    private userService = inject(UserService);
    private dialog = inject(MatDialog);
    private snack = inject(MatSnackBar);
    
    paginator = viewChild.required(MatPaginator);
    sort = viewChild.required(MatSort);
    
    displayedColumns = ['name', 'email', 'role', 'actions'];
    dataSource = new MatTableDataSource<User>([]);
    loading = signal(false);
    
    ngAfterViewInit() {
        // ⚠️ ต้องผูก paginator/sort **ก่อน** เซตข้อมูล — ไม่งั้น page แรกไม่ทำงาน
        // ถ้าโหลดใน constructor + data มาก่อน ngAfterViewInit = race condition
        this.dataSource.paginator = this.paginator();
        this.dataSource.sort = this.sort();
        this.load();
    }
    
    async load() {
        this.loading.set(true);
        try {
            const users = await firstValueFrom(this.userService.getAll());
            this.dataSource.data = users;
        } catch (e: unknown) {
            this.snack.open('Failed to load', 'OK', { duration: 3000 });
        } finally {
            this.loading.set(false);
        }
    }
    
    filter(value: string) {
        this.dataSource.filter = value.trim().toLowerCase();
    }
    
    add() {
        this.dialog.open(UserFormDialogComponent).afterClosed()
            .subscribe(user => user && this.load());
    }
    
    edit(user: User) {
        this.dialog.open(UserFormDialogComponent, { data: user }).afterClosed()
            .subscribe(updated => updated && this.load());
    }
    
    async remove(user: User) {
        const ref = this.dialog.open(ConfirmDialogComponent, {
            data: { title: 'Delete?', message: `Delete ${user.name}?` }
        });
        const confirmed = await firstValueFrom(ref.afterClosed());
        if (!confirmed) return;
        
        await firstValueFrom(this.userService.delete(user.id));
        this.snack.open('User deleted', 'OK', { duration: 3000 });
        this.load();
    }
}

22. Resources

แหล่งอ้างอิงหลักสำหรับค้นต่อเวลาใช้งานจริง — เอกสารทางการของ Material และ CDK ที่มีตัวอย่างทุก component และคู่มือ Material Design:

Official

Alternatives

Theming Tools

Community

  • Angular Material discord
  • /r/angular

23. Checkpoint

ลองฝึกตามโจทย์ด้านล่างเพื่อให้ใช้ Material และ CDK ได้คล่อง ตั้งแต่ติดตั้งและตั้งธีม ไปจนถึงสร้างฟีเจอร์ลากวางและตารางข้อมูลจริง:

🛠️ Checkpoint 14.1 — Setup Material

  • ng add @angular/material
  • เลือก theme + typography
  • Verify ทดสอบ <button mat-flat-button>Hello</button>

🛠️ Checkpoint 14.2 — Custom Theme

  • Custom primary palette
  • Dark mode toggle (signal + class binding)
  • Save preference to localStorage

🛠️ Checkpoint 14.3 — Form with Material

  • Login form with mat-form-field
  • Validation + mat-error
  • Date picker + select
  • Snackbar on submit

🛠️ Checkpoint 14.4 — CDK Drag-drop

  • Kanban board (3 columns)
  • Drag card between columns
  • Persist order

🛠️ Checkpoint 14.5 — Component Harness

  • Test Material component
  • Use MatButtonHarness, MatInputHarness
  • Verify form submission via harness

24. สรุปบท

Angular Material = official UI library — Material Design 3, ~30 components
ng add @angular/material = setup + theme + animations
Material 3 theme: palette + typography + density via mat.theme()
Dark mode: separate mat.theme() block + class toggle
Tokens: var(--mat-sys-primary), --mat-sys-surface, ฯลฯ
CDK = headless primitives — overlay, portal, drag-drop, virtual scroll
BreakpointObserver for responsive
Component Harness = stable test API (no querySelector)
Common alternatives: PrimeNG, NG-ZORRO, Taiga, Spartan UI
Best practices: standalone imports, async animations, theme tokens (not hardcoded)
✅ Combine กับ Tailwind ได้ — Material สำหรับ behavior, Tailwind สำหรับ utility


← บทที่ 13: Security | สารบัญ | บทที่ 15: i18n →