Mert Özen Her Satırda Daha İleri
Frontend Angular Material ve CDK — #08

Material'ı Özelleştirmek ve Sürümler: Token Override'ları, Density, CDK ile Kendi Component'in ve Material 12'den 22'ye

Mert Özen 15 Kas 2026 13 dk 5 görüntülenme
Material'ı Özelleştirmek ve Sürümler: Token Override'ları, Density, CDK ile Kendi Component'in ve Material 12'den 22'ye

Material'ı token'larla özelleştir: bir bölgeyi yeniden boyamak için class içinde yalnız color veren mat.theme 4.2 kB ekledi, eski all-component-themes alışkanlığı 53 kB; overlay'ler tema class'ının dışında kaldığı için class'ı overlay container'a da ver. Bookstore'un kurumsal portalında density ölçeğini, CDK Menu ile bir split button ve harness'ını, Material 12'den 22'ye değişimi ve eski .mat-* override'larının neden sıfır element'e uyduğunu ölçüyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), Angular Material ve CDK (Component Dev Kit; Angular ekibinin stilsiz davranış library'si) 22.2.7, .NET 10 ve Node.js 24 ile denenmiştir; tarayıcı davranışı Playwright 1.65 ile headless Chromium'da gözlenmiştir. Bookstore.Api ölçümlerde SQL Server yerine SQLite ile çalıştı. Material 12–21 sürüm bilgileri angular/components CHANGELOG'undan alınmıştır; eski sürümler çalıştırılmadı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim panelinde kurumsal siparişlerle ilgilenen iki ekran var: yeni kurumsal sipariş formu ve sevkiyat planı. Hakan'ın ekibi (kurumsal satış) bu ekranların okul ve kütüphanelerle yapılan işi hatırlatacak şekilde kurumsal portalın yeşil-turkuaz rengiyle görünmesini istiyor. Serinin bu son yazısında Material'ı yeniden boyuyor, sıkılaştırıyor, Material'da olmayan bir component'i CDK ile yazıyor ve Material'ın 12'den 22'ye geçirdiği değişime bakıyoruz. Üç soru: Sayfanın host element'ine bir tema class'ı verirsen o sayfadan açılan datepicker yeni rengi alır mı? Portal temasını üretmenin dört yolu var; en pahalısı CSS'e kaç kB ekler? Material 12 zamanında yazılmış .mat-form-field-outline override'ı Material 22'deki bir formda kaç element'e uyar?

Sistem Token'ları ve Kapsamlı Bir Tema

01. yazıda mat.theme'i html'e uygulamıştık: Bookstore mavisinden üretilmiş palette'ler, Roboto ve density. Bu mixin component stillerini değil, --mat-sys-primary, --mat-sys-surface-container gibi sistem token'larını CSS custom property olarak üretiyor; component'ler kendi stillerinde bu değişkenleri okuyor. Bu yüzden bir bölgeyi yeniden boyamak için o bölgenin class'ında aynı değişkenleri yeniden tanımlamak yetiyor. Kurumsal portal rengi (#0f766e) için palette'i 01'deki gibi Material'ın schematic'iyle ürettik:

$ ng generate @angular/material:theme-color --primary-color='#0f766e' --directory=src/tmp-inst/ --defaults --interactive=false
CREATE src/tmp-inst/_theme-colors.scss (2516 bytes)
// Kurumsal portal rengi #0f766e'den üretildi: ng generate @angular/material:theme-color
@use './institutional-colors' as inst;

// Kurumsal portal: aynı component'ler kurum renginde. Bu class için yalnız renk sistem token'ları yeniden
// üretilir, component stilleri değil
.institutional-portal {
  @include mat.theme((color: (primary: inst.$primary-palette, tertiary: inst.$tertiary-palette)));
  // Portal'da daha köşeli butonlar: .mat-mdc-* class'ları yerine public token API'si
  @include mat.button-overrides((filled-container-shape: 8px, outlined-container-shape: 8px, text-container-shape: 8px));
}

Class'ı iki sayfanın host element'ine verdik (host: { class: 'institutional-portal' }). Tahminin ikinci sorusu için aynı işi dört yolla yapıp production build'in global stil dosyasını ölçtük:

a) no portal theme       | styles-BC4VCQTW.css | styles                   |  11.73 kB |                 2.02 kB
b) mat.theme color       | styles-VUMMKPZJ.css | styles                   |  15.93 kB |                 2.34 kB
c) theme-overrides 4     | styles-DTYTJQJ7.css | styles                   |  11.89 kB |                 2.05 kB
d) all-component-themes  | styles-MINI35OH.css | styles                   |  65.29 kB |                 6.82 kB

(b) bizim seçtiğimiz yol: class'ın içinde yalnız color veren bir mat.theme, bütün renk token'larını yeni palette'ten üretiyor ve 4.2 kB ekliyor. (c) mat.theme-overrides ile yalnız dört token'ı (primary, on-primary, primary-container, on-primary-container) elle değiştirmek 0.16 kB ekliyor; ama secondary, tertiary ve surface tonları mavi palette'ten kalıyor ve hangi token'ı unuttuğunu ancak ekranda görürsün. Tek bir rengi değiştirmek için (c), bütün bir bölgeyi başka bir markaya çevirmek için (b). (d) Material 17 ve öncesinin alışkanlığı: mat.define-theme ile bir tema object'i kurup mat.all-component-themes'i class'ın içinde çağırmak. Bu bütün component'lerin token'larını yeniden basıyor ve stil dosyasını 11.73 kB'tan 65.29 kB'a çıkardı. Her alt bölüm ya da her component için ayrı tema üretmek CSS'i işte böyle katlıyor. M3 (Material Design 3; Google'ın tasarım sisteminin güncel sürümü) temasının Sass tarafını, palette'leri ve mat.theme'in parametrelerini Sass/SCSS serisinin "Sass ile Theming: Map'ler, CSS Custom Property Üretmek, Dark Mode ve Angular Material M3 Teması" yazısı anlatıyor.

Overlay'ler Tema Class'ının Dışında

Tahminin ilk sorusu. Sevkiyat sayfasında "Save plan"a basıp snackbar'ı, sipariş formunda datepicker'ı açtık ve --mat-sys-primary'nin değerine baktık:

orders page          : --mat-sys-primary light-dark(#2151da, #b7c4ff)
shipments page       : --mat-sys-primary light-dark(#006a63, #80d5cb) | Save plan background color(srgb 0.0941176 0.109804 0.109804 / 0.12) radius 8px
snackbar (overlay)   : --mat-sys-primary light-dark(#2151da, #b7c4ff) | action color rgb(183, 196, 255) | inside .institutional-portal false
datepicker (overlay) : --mat-sys-primary light-dark(#2151da, #b7c4ff) | today/selected cell color(srgb 0.101961 0.105882 0.137255 / 0.38) | inside .institutional-portal false

Sayfa turkuaz, ama snackbar ve datepicker hâlâ mavi. 05. yazıdan biliyoruz: overlay'ler body'nin sonundaki overlay container'da render ediliyor, sayfanın host element'inin içinde değiller ve oraya tanımlanan değişkenleri miras almıyorlar. (İkinci satırdaki butonun rengi henüz disabled olduğu için soluk; aşağıdaki ölçümde gerçek rengini görüyoruz.) Çözüm, sayfa ekrandayken aynı class'ı overlay container'a da vermek:

// Overlay'ler (datepicker, autocomplete, snackbar) <body>'nin sonundaki overlay container'da, sayfanın tema class'ının
// dışında render edilir. Sayfa ekrandayken container'a da aynı class'ı ver. Injection context'ten çağır.
export function useOverlayThemeClass(themeClass: string): void {
  const container = inject(OverlayContainer).getContainerElement();
  container.classList.add(themeClass);
  inject(DestroyRef).onDestroy(() => container.classList.remove(themeClass));
}
export class ShipmentPlanPage {
  // ...
  constructor() {
    useOverlayThemeClass('institutional-portal');
    // ...
  }
}
orders page          : --mat-sys-primary light-dark(#2151da, #b7c4ff)
shipments page       : --mat-sys-primary light-dark(#006a63, #80d5cb) | Save plan background rgb(0, 106, 99) radius 8px
snackbar (overlay)   : --mat-sys-primary light-dark(#006a63, #80d5cb) | action color rgb(128, 213, 203) | inside .institutional-portal true
datepicker (overlay) : --mat-sys-primary light-dark(#006a63, #80d5cb) | today/selected cell color(srgb 0.0941176 0.109804 0.109804 / 0.38) | inside .institutional-portal true

Bu yaklaşım tek bir sayfa için doğru; iki farklı temalı bölge aynı anda ekranda olsaydı container'ın tek class'ı yetmezdi. O durumda overlay açan her component'in kendi seçeneğini (panelClass, mat-autocomplete'te class) kullanman gerekir.

08-portal-theme-tr.png

Component Token'ları: overrides Mixin'leri

Sistem token'ları her component'i etkiliyor. Tek bir component'in tek bir özelliğini değiştirmek için her component'in kendi *-overrides mixin'i var: yukarıda mat.button-overrides portal butonlarının köşesini 8px yaptı. 01. yazıda mat.toolbar-overrides ile toolbar'ın arka planını değiştirmiştik. Bu API'nin iki faydası var. Birincisi, Material'ın iç DOM'una (Document Object Model; sayfanın tarayıcıdaki element ağacı) ve class adlarına dokunmuyor; sürüm değişince bozulmuyor. İkincisi, token adını derleme sırasında doğruluyor. Bir harfi eksik yazınca build şunu söyledi:

✘ [ERROR] Error: Invalid token name `filled-container-shap`. Valid tokens are: filled-container-shape, filled-horizontal-padding, filled-icon-offset, filled-icon-spacing, filled-label-text-transform, filled-touch-target-size, outlined-container-shape, ...
# (liste kısaltıldı: button-overrides'ın kabul ettiği bütün token'lar sayılıyor)

Aynı hatayı .mat-mdc-unelevated-button { border-radius: 8px; } gibi bir class override'ı ile yapsaydın hiçbir şey söylenmez, sadece bir şey olmazdı. Hangi component'in hangi token'ları olduğunu öğrenmenin en hızlı yolu bu hata mesajı ya da Material dokümanındaki her component'in "Styling" sekmesi.

Density Ölçeği

Material'ın theming kılavuzuna göre density 0 ile -5 arasında bir tam sayı ve her adım etkilenen boyutları 4px küçültüyor, ama yalnız component'in düzgün çizilebileceği en küçük boyuta kadar. 01. yazıda yönetim paneline -1 vermiştik. Ölçeğin tamamını .admin-shell'deki değeri değiştirerek ölçtük (px):

densitytoolbarnav itemselect fieldtablo başlığıtablo satırıcheckboxbuton
064485656524040
-160445252483636
-256404848443232
-352364444402828
-452324040362828
-552243640362828

Kılavuzdaki "en küçük boyut" tabloda görünüyor: toolbar, buton ve checkbox -3'ten, tablo -4'ten sonra küçülmüyor; nav item ise -5'te 24px'e kadar iniyor. Butondaki 28px görünen kutu; Material'ın bunun etrafına ayrıca bir touch target (dokunma alanı) ekleyip eklemediğini ölçmedik. Biz yönetim panelinde -1'de kaldık: tablolar sıkı, ama nav item'lar ve butonlar hâlâ rahat tıklanıyor. Hedef boyutunun erişilebilirlik tarafını, yani WCAG'in (Web Content Accessibility Guidelines; web erişilebilirlik yönergeleri) 2.2 sürümündeki 24px kuralını, Accessibility (a11y) serisi anlatıyor; -4 ve -5'i dokunmatik bir ekranda kullanmadan önce o yazıya bak.

Material'a Uymayan Bir İhtiyaç: CDK Menu ile Split Button

Sevkiyat planına ikinci bir kaydetme seçeneği gerekti: "kaydet ve kurum birimine haber ver". Bunun için yaygın kalıp split button: ana işlem ve yanında ilgili işlemlerin menüsü. Material'da split button yok. mat-menu'yü bir buton grubuna bağlamak mümkün, ama biz burada davranışı CDK'dan, görünümü Material butonlarından ve sistem token'larından alan küçük bir component yazdık. Menü, ok tuşları, Escape ve focus'un geri dönmesi @angular/cdk/menu'den geliyor:

// Material'da split button yok: ana işlem ve ilgili işlemlerin menüsü.
// Davranış CDK'dan (menü, klavye, focus), görünüm Material butonlarından ve sistem token'larından.
@Component({
  selector: 'app-split-button',
  imports: [CdkMenuTrigger, CdkMenu, CdkMenuItem, MatButton, MatIcon],
  template: `
    <div class="split" role="group" [attr.aria-label]="label()">
      <button matButton="filled" type="button" class="main" [disabled]="disabled()" (click)="run.emit(primary().id)">
        {{ primary().label }}
      </button>
      <button matButton="filled" type="button" class="toggle" [disabled]="disabled()" [cdkMenuTriggerFor]="menu"
              [attr.aria-label]="'More ' + label() + ' actions'">
        <mat-icon>arrow_drop_down</mat-icon>
      </button>
    </div>
    <ng-template #menu>
      <div class="split-menu" cdkMenu>
        @for (action of more(); track action.id) {
          <button type="button" class="split-menu-item" cdkMenuItem (cdkMenuItemTriggered)="run.emit(action.id)">
            {{ action.label }}
          </button>
        }
      </div>
    </ng-template>
  `,
  styleUrl: './split-button.scss',
})
export class SplitButton {
  readonly label = input.required<string>();
  readonly primary = input.required<SplitAction>();
  readonly more = input<SplitAction[]>([]);
  readonly disabled = input(false);
  readonly run = output<string>();
}
// Tek buton gibi görünen iki buton: köşeler .mdc-button class'larıyla değil, butonun public token'larıyla
.main {
  @include mat.button-overrides((filled-container-shape: 20px 4px 4px 20px));
}

.toggle {
  min-width: 40px;
  padding: 0 8px;
  @include mat.button-overrides((filled-container-shape: 4px 20px 20px 4px));
}

// Menü <body>'nin sonundaki bir overlay'de render edilir ama bu component'in template'inden gelir;
// emulated encapsulation attribute'ları üstünde olduğu için bu stiller ::ng-deep olmadan uygulanır
.split-menu {
  display: flex;
  flex-direction: column;
  min-width: 220px;
  padding: 8px 0;
  border-radius: 4px;
  background: var(--mat-sys-surface-container);
  box-shadow: var(--mat-sys-level2);
}

Token override'ını burada component'in kendi SCSS'inde (Sass'ın CSS'e benzeyen syntax'ı), kendi class'ımızda kullandık: filled-container-shape dört köşeyi ayrı ayrı alabiliyor ve iki buton birleşik bir şekil oluşturuyor. Playwright'ta klavyeyle denedik; menü kurum temasının içinde, popover'da açıldı:

shape        : button.main radius 20px 4px 4px 20px | button.toggle radius 4px 20px 20px 4px
ArrowDown    : button "Save and notify the unit" role=menuitem | menu role=menu background rgb(235, 239, 237) in popover true
Escape       : button "More Save actions" role=button | menus 0
Enter, Enter : /api/admin/institutional-orders/1042/shipments?notifyUnit=true | Shipment plan saved, Main Campus Library notified

"Notify" seçeneği API'ye ?notifyUnit=true olarak gidiyor; Bookstore.Api şimdilik yalnız bir log satırı yazıyor (gerçek e-posta Notifications module'ünün işi):

info: Bookstore.Institutional[0]
      Shipment plan of order 1042 saved, notifying Main Campus Library

Kendi component'ini yazınca testini de Material'ın component'leri gibi bir harness'la yazmak, testleri markup'tan bağımsız tutuyor. Harness, menünün overlay'de olduğunu bilen tek yer:

// Testler split button'la markup'ı ya da CSS class'ları üzerinden değil, bu API üzerinden konuşur
export class SplitButtonHarness extends ComponentHarness {
  static hostSelector = 'app-split-button';

  private readonly main = this.locatorFor('button.main');
  private readonly toggle = this.locatorFor('button.toggle');
  // Menü overlay'de, host element'in dışında
  private readonly menuItems = this.documentRootLocatorFactory().locatorForAll('.split-menu-item');

  async openMenu(): Promise<void> {
    const toggle = await this.toggle();
    if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
      await toggle.click();
    }
  }

  async chooseFromMenu(text: string): Promise<void> {
    await this.openMenu();
    for (const item of await this.menuItems()) {
      if ((await item.text()) === text) {
        return item.click();
      }
    }
    throw new Error(`No menu item "${text}"`);
  }
  // getPrimaryText, isDisabled, clickPrimary, getMenuItems ...
}

Harness'ın ilk sürümünde chooseFromMenu her seferinde toggle'a tıklıyordu; getMenuItems'ten sonra çağrılınca açık menüyü kapattı ve test "No menu item" ile düştü. aria-expanded'a bakan openMenu bunu düzeltti. Harness yazmanın ayrıntısı Frontend Testleri serisinin "Component Harness'ları: CDK Harness Kullanmak, Kendi Harness'ını Yazmak ve Testleri Markup'tan Bağımsızlaştırmak" yazısında. Yeni bir component için CDK'nın yanında Angular Aria'yı da değerlendir; ikisinin farkını Accessibility (a11y) serisinin "Angular'da Accessible Component'ler: Angular Aria, CDK a11y, Material ve Kendi Combobox'ını Yazmamak" yazısı tartışıyor.

Material 12'den 22'ye

Eski bir projeyi devralırsan hangi dönemden geldiğini bilmek, override'larının neden çalışmadığını açıklar. CHANGELOG'dan derlediğimiz kısa harita:

SürümTarihNe değişti
12.0.02021-05Sass theming API'si @use ile yeniden yazıldı; node-sass desteği bitti
15.0.02022-11Button, select, list, menu, dialog, table, tabs ve diğerleri MDC (Material Components for the Web; Google'ın framework'süz Material component'leri) tabanlı yeni implementasyona geçti; DOM ve CSS class'ları değişti, eskiler legacy-* entry point'lerinde kaldı
17.0.02023-11Legacy component'ler kaldırıldı; legacy kullanan projede ng update v17'ye geçmeyi durduruyor
18.0.02024-05M3 desteği stable oldu; component token override API'si geldi; M2 (Material Design 2; önceki tasarım sürümü) fonksiyonları m2- önekini aldı
19.0.02024-11mat.theme ve mat.theme-overrides; ng add ve hazır temalar mat.theme'e geçti
20.0.02025-05Tonal buton ve dinamik appearance; overlay için tree-shaking'e uygun function'lar
21.0.02025-11Overlay'lerin popover kullanımını kapatan DI (dependency injection; Angular'ın bağımlılık sağlama mekanizması) token'ı
22.0.02026-06Dialog ve bottom sheet'e bindings; Angular Aria'da combobox stable

MDC geçişi asıl kırılma noktası. 15.0.0 notları TypeScript API'sinin büyük ölçüde aynı kaldığını, ama DOM ve CSS class'larının değiştiğini açıkça yazıyor. 12 dönemi override'larını bugünkü bir formda aradık:

.mat-form-field-outline     0   ->   .mdc-notched-outline         6
.mat-form-field-underline   0   ->   .mdc-line-ripple             0
.mat-form-field-wrapper     0   ->   .mat-mdc-text-field-wrapper  6
.mat-select-trigger         0   ->   .mat-mdc-select-trigger      1
.mat-flat-button            0   ->   .mat-mdc-unelevated-button   2
.mat-button                 0   ->   .mat-mdc-button              10
.mat-list-item-content      0   ->   .mdc-list-item__content      10

Tahminin üçüncü sorusunun cevabı sıfır. Eski selector'ların hiçbiri bir element'e uymuyor; derleme hata vermiyor, tarayıcı da uymayan bir kuralı sessizce atlıyor. Bu yüzden eski bir projede yükseltmeden sonra "tema bozuldu" değil "bazı özelleştirmeler kayboldu" görürsün. Yeni class'lara (sağ kolon) taşımak da çözüm değil: 01. yazıda gördüğümüz gibi onlar da iç detay ve bir sonraki büyük değişiklikte aynı şey olur. Kalıcı çözüm her override'ı bir token'a çevirmek; karşılığı olmayan birkaç tanesi için de Material'ın dokümante ettiği class'ları kullanmak.

Yükseltmenin kendisi: ng update @angular/core @angular/cli ve ng update @angular/material, her major sürüm için ayrı ayrı. Material'ın update schematic'leri yeniden adlandırmaları kendisi yapıyor (18.0.0 notları define-palette → m2-define-palette gibi adları "ng update ile otomatik düzelir" diye listeliyor). 15'e geçerken MDC için ayrı bir ng generate @angular/material:mdc-migration schematic'i vardı. Bunların hiçbiri senin yazdığın CSS override'larını düzeltmiyor; onları yukarıdaki gibi bir sayımla bulman gerekiyor.

Üretimde Dikkat

Tema değişikliğini stil dosyasının boyutuyla birlikte gözden geçir. Bir satırlık mat.all-component-themes 53 kB ekledi; build çıktısındaki styles satırı bunu hemen gösteriyor.

Overlay açan her temalı bölgeyi overlay'le birlikte test et. Sayfa doğru renkteyken datepicker'ın yanlış renkte olması gözden en kolay kaçan şey; ekran görüntüsü testleri overlay açıkken alınmalı.

Token adlarını kodda değil, mixin'de yaz. --mat-button-filled-container-shape'i CSS'te elle set etmek bugün çalışır, ama adı değişirse sessizce kaybolur; mixin derlemede hata verir.

Testler. Seri boyunca 4'ten 33'e çıkan birim testlerinin son durumu:

 ✓ src/app/shared/split-button.spec.ts > SplitButton > runs the main action and the actions in its menu
 ✓ src/app/shared/split-button.spec.ts > SplitButton > disables both parts together
 ✓ src/app/admin/shipments/shipment-plan-page.spec.ts > ShipmentPlanPage > saves and notifies the unit from the split button menu
      Tests  33 passed (33)

Sık Düşülen Tuzaklar

Birincisi, eski projeden kalan ::ng-deep .mat-* override'larının MDC sonrası hâlâ çalıştığını sanmak. Hiçbir uyarı vermeden etkisiz kalıyorlar; yukarıdaki sayımı kendi projende yap.

İkincisi, her component ya da her bölge için ayrı tam tema üretip CSS'i katlamak. Bölgeye özel renk için mat.theme'in yalnız color'ı, tek bir değer için theme-overrides ya da component overrides'ı yeter.

Üçüncüsü, tema class'ını sayfaya verip overlay'leri unutmak. Snackbar, datepicker, autocomplete ve menü sayfanın dışında render edilir.

Dördüncüsü, density'yi her yerde -4 ya da -5'e çekmek. Masaüstündeki yoğun bir tabloda işe yarar, dokunmatik ekranda tıklama hedeflerini küçültür.

Beşincisi, Material'da olmayan bir component için Material'ın iç class'larını taklit eden bir kopya yazmak. Davranış için CDK ya da Angular Aria, görünüm için sistem token'ları kullan; böylece kendi component'in de tema değişince birlikte değişir.

Küçük Bir Deneme

★☆☆ Kurumsal portal temasına color-scheme: dark ekle ve aynı ölçümü tekrarla. light-dark() içindeki ikinci renkler devreye giriyor mu, snackbar ve datepicker de karanlık temayı alıyor mu?

★★☆ Split button'a bir MAT_*_DEFAULT_OPTIONS benzeri injection token ekle: varsayılan menü pozisyonu ve buton görünümü (filled, tonal) uygulama genelinde ayarlanabilsin. Harness'a getAppearance() ekle.

★★★ Kendi projende (ya da bu serinin 01 snapshot'ında) bütün .mat-* ve .mdc-* selector'larını bulan küçük bir script yaz; her biri için çalışan sayfada eşleşen element sayısını Playwright ile say ve sıfır olanları listele. Kalanları hangi token'lara çevirebilirsin?

Seri burada bitiyor: ng add ve M3 temasıyla başladık, form component'leri, server-side sayfalanan tablo, dialog'lar, CDK Overlay, drag and drop ve virtual scroll ile Bookstore'un yönetim panelini ve kurumsal portalını kurduk, son olarak Material'ı token'larla özelleştirdik. Bu ekranların state'ini (filtreler, seçimler, sayfalama) Signal Store ile yönetmek "Angular'da State Yönetimi" serisinin, component'leri harness'larla test etmek Frontend Testleri serisinin, klavye ve screen reader tarafını derinleştirmek Accessibility (a11y) serisinin konusu.