โหมดมืด
บทที่ 14 — Angular Material + CDK
🟡 ก่อนอ่าน: ควรผ่านบท 1 (Components + Templates), บท 5 (Forms) และบท 12 (Accessibility) มาก่อน
ต้องรู้จัก: standalone component (บท 1), reactive forms + FormControl (บท 5), ARIA basics (บท 12) — บทนี้ใช้ทั้งสามอย่างนี้หนัก
หลังจบบท คุณจะ:
- เข้าใจ 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)
| Library | Style | License | Use case |
|---|---|---|---|
| Angular Material | Material 3 | Free | แอปองค์กร (Enterprise), Google ecosystem |
| PrimeNG | Multiple themes | Free | Feature-rich (มี component ครบกว่า) |
| NG-ZORRO | Ant Design | Free | Enterprise, ตลาดจีน |
| NG-Bootstrap | Bootstrap 5 | Free | ผู้ใช้ Bootstrap |
| Taiga UI | Custom | Free | Modern, ปรับแต่งได้มาก |
| Tailwind UI + headless | Utility-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 pathManual Install
bash
npm install @angular/material @angular/cdkscss
// 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) — ต้อง importReactiveFormsModuleและมี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);
}Menu + Tooltip + Badge
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 primitives9. 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
⚠️
autosizedirective เป็น experimental และถูก deprecate ใน CDK v17+ เพราะ performance ไม่ดีและอาจเกิด scroll jumping ใน production — แนะนำให้ใช้ fixeditemSizeเมื่อทำได้ หรือ implement customVirtualScrollStrategyสำหรับ 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 everywhere | Use theme tokens + density (ใช้ตัวแปร CSS ของ theme แทน) |
| Import all Material modules globally | Standalone 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 scratch | Use MatDialog (a11y included — มี accessibility ในตัว) |
| Build dropdown from scratch | Use cdk/overlay |
| Test Material with querySelector | Use Component Harness (ทดสอบผ่าน API ทางการ — ไม่แตะ HTML) |
| Mix Material + Bootstrap | Pick 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
- material.angular.io — Angular Material docs
- material.angular.io/cdk — CDK docs
- Material Design 3
- GitHub: angular/components
Alternatives
- PrimeNG
- NG-ZORRO
- Taiga UI
- Spartan UI — shadcn-style for Angular
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