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

Dialog, Snackbar ve Bottom Sheet: MatDialog ile Veri Alışverişi, afterClosed, CDK Dialog ve Native dialog Karşılaştırması

Mert Özen 15 Kas 2026 13 dk 5 görüntülenme
Dialog, Snackbar ve Bottom Sheet: MatDialog ile Veri Alışverişi, afterClosed, CDK Dialog ve Native dialog Karşılaştırması

MatDialog'a yalnız id ver, kararı afterClosed() ile al ve closeOnNavigation: 'url-change' kullan: varsayılan değer yalnız geri/ileri tuşunda kapattığı için kod ile yapılan navigation'da dialog açık kaldı. Bookstore'da sipariş iptal dialog'u, Undo snackbar'ı ve toplu işlem bottom sheet'i kuruyor; değersiz mat-dialog-close'un '' döndürmesini, onaydan sonra focus kaybını, snackbar'ın initial bundle'a 34 kB eklemesini ve MatDialog, CDK Dialog ile native dialog farkını ö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ı.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim panelindeki siparişler tablosuna "Siparişi iptal et" onay dialog'unu, mobil için toplu işlem menüsünü ve müşterinin sepetine "Geri al" diyen bir snackbar ekliyoruz. Üç soru: Dialog açıkken uygulama kod ile başka bir route'a geçerse (bir guard, oturum süresinin dolması, bir bildirime tıklama) MatDialog kendiliğinden kapanır mı? Dialog'daki "Keep order" butonuna değersiz bir mat-dialog-close koyarsan afterClosed() ne verir? Native dialog, MatDialog ve CDK Dialog'dan hangileri top layer'da açılır?

MatDialog ile Veri Vermek ve Sonuç Almak

Dialog bir component. Açan taraf ona MAT_DIALOG_DATA ile veri veriyor, dialog da MatDialogRef.close() ile bir sonuç döndürüyor. Dialog'a yalnız göstereceği şeyi veriyoruz: sipariş id'leri ve bir özet satırı. Tablonun satır object'leri, servisler ya da "onaylanınca şunu çalıştır" callback'leri data'ya girmiyor:

// Yalnız dialog'un gösterdiği şey: id'ler ve bir özet satırı; tablonun satırları, servisler ya da callback'ler değil
export interface CancelOrderData {
  orderIds: number[];
  summary: string;
}

export interface CancelOrderResult {
  reason: string;
}

@Component({
  selector: 'app-cancel-order-dialog',
  imports: [
    MatDialogTitle, MatDialogContent, MatDialogActions, MatButton, MatFormField, MatLabel, MatSelect, MatOption,
  ],
  template: `
    <h2 mat-dialog-title>Cancel {{ title }}?</h2>
    <mat-dialog-content>
      <p>{{ data.summary }}</p>
      <mat-form-field>
        <mat-label>Reason</mat-label>
        <mat-select [value]="reason()" (valueChange)="reason.set($event)">
          @for (r of reasons; track r) {
            <mat-option [value]="r">{{ r }}</mat-option>
          }
        </mat-select>
      </mat-form-field>
    </mat-dialog-content>
    <mat-dialog-actions align="end">
      <!-- Değersiz bir mat-dialog-close undefined değil '' (attribute'un değeri) ile kapatır -->
      <button matButton (click)="dialogRef.close()">Keep order</button>
      <button matButton="filled" [disabled]="!reason()" (click)="confirm()">Cancel order</button>
    </mat-dialog-actions>
  `,
})
export class CancelOrderDialog {
  protected readonly data = inject<CancelOrderData>(MAT_DIALOG_DATA);
  protected readonly dialogRef = inject<MatDialogRef<CancelOrderDialog, CancelOrderResult>>(MatDialogRef);

  protected readonly title =
    this.data.orderIds.length === 1 ? `order #${this.data.orderIds[0]}` : `${this.data.orderIds.length} orders`;
  protected readonly reasons = ['Customer request', 'Out of stock', 'Payment failed', 'Duplicate order'];
  protected readonly reason = signal('');

  protected confirm(): void {
    this.dialogRef.close({ reason: this.reason() });
  }
}

Açan taraf sipariş sayfası. Dialog kararı döndürüyor, API çağrısını, tabloyu yenilemeyi ve bildirimi sayfa yapıyor; böylece dialog'u başka bir ekrandan da aynı şekilde açabiliyoruz:

private cancelOrders(orderIds: number[], summary: string): void {
  const dialogRef = this.dialog.open<CancelOrderDialog, CancelOrderData, CancelOrderResult>(CancelOrderDialog, {
    data: { orderIds, summary },
    width: '440px',
    // Varsayılan değer yalnız tarayıcının geri/ileri tuşunda kapatır; her route değişiminde kapat
    closeOnNavigation: 'url-change',
  });
  // afterClosed() bir kez değer verir (Esc, backdrop ya da "Keep order" ile kapanınca undefined) ve tamamlanır
  dialogRef.afterClosed().subscribe(result => {
    if (result) {
      void this.submitCancel(orderIds, result.reason);
    }
  });
}

Tahminin ikinci sorusu testte çıktı. "Keep order" ilk sürümde <button matButton mat-dialog-close>'du ve test şunu dedi:

AssertionError: expected [ '' ] to deeply equal [ undefined ]

mat-dialog-close bir input; değeri yazılmamış attribute boş string verir ve dialog '' ile kapanır. Bizim if (result)'umuz bunu yanlış değer sayıp doğru davrandı, ama type CancelOrderResult | undefined derken gelen değer string'di. Butonu açıkça dialogRef.close() çağıracak şekilde değiştirdik. afterClosed()'ın kendisini Playwright'ta da izledik: Escape'ten sonra next(undefined) complete, yani tek bir değer ve tamamlanma. Unsubscribe gerekmiyor; asıl dikkat edilecek nokta dialog açıkken component'in kendisinin yok olması, aşağıda.

Angular Material 22.0.0 ile open()'a bindings da verilebiliyor (CHANGELOG'da "dialog: add the ability to pass bindings"); dialog component'i o zaman MAT_DIALOG_DATA yerine normal input()'lar kullanabiliyor. Biz bu yazıda klasik yolu kullandık; MAT_DIALOG_DATA'nın bir anlık değer olduğunu, sonradan değişen bir signal olmadığını bilmek yeterli.

Focus, Escape ve Screen Reader

Bir satırdaki iptal butonuna tıklayıp dialog'u Playwright ile inceledik:

opened from      : Cancel order 8781
dialog           : role=dialog aria-modal=false labelledby="Cancel order #8781?" | host cdk-overlay-popover popover=manual :popover-open=true
page behind      : app-root aria-hidden=true inert=false
initial focus    : mat-select
Tab 1            : button Keep order
Tab 2            : mat-select
Tab 3            : button Keep order
Tab 4            : mat-select
after Escape     : dialogs 0 | afterClosed next(undefined) complete | focus button "Cancel order 8781" cancel
confirm          : POST /api/admin/orders/8781/cancel {"reason":"Customer request"}
snackbar         : Order #8781 cancelled OK | live: polite
focus after      : button "Cancel order 8781" cancel

Dialog mat-dialog-title'daki metinle adlandırılmış bir role="dialog". Material aria-modal'ı false bırakıyor ve arkadaki uygulamayı aria-hidden="true" ile screen reader'dan gizliyor; focus dialog'un içinde dönüyor (Tab, "Cancel order" disabled olduğu için iki element arasında gidip geliyor). İlk focus, varsayılan autoFocus: 'first-tabbable' yüzünden reason select'inde. Escape dialog'u kapattı ve focus açan butona döndü. Focus trap ve focus'un geri dönmesinin neden önemli olduğunu Accessibility (a11y) serisinin "Keyboard Navigation ve Focus Yönetimi: Tab Sırası, :focus-visible, Skip Link, Focus Trap, inert ve Roving Tabindex" yazısı anlatıyor.

Son satır ilk denemede böyle değildi. Onaydan sonra tablo yenileniyor, iptal edilen siparişin butonu disabled oluyor ve focus şuraya düşüyordu:

focus after      : body Bookstore AdminStorefrontdashb

İki sebep birlikte çalışıyordu ve ikisini ayrı ayrı denedik; yalnız biri düzeltildiğinde focus yine body'ye düştü. Birincisi: her cevapta satırlar yeni object olduğu için mat-table bütün satır element'lerini yeniden oluşturuyordu; focus'taki buton DOM'dan (Document Object Model; sayfanın tarayıcıdaki element ağacı) çıkıyordu. İkincisi: focus'taki bir buton disabled olunca tarayıcı focus'u bırakıyor. Çözüm tabloya trackBy ve butona Material'ın disabledInteractive'i:

<table mat-table [dataSource]="rows()" [trackBy]="trackById" matSort [matSortActive]="sort()" [matSortDirection]="dir()"
       (matSortChange)="sortChanged($event)">
<!-- disabledInteractive: disabled buton focus'u <body>'ye düşürmek yerine tutar (aria-disabled) -->
<button matIconButton type="button" [disabled]="!canCancel(order)" disabledInteractive
        [attr.aria-label]="'Cancel order ' + order.id"
        (click)="cancelOne(order)">
  <mat-icon>cancel</mat-icon>
</button>
// Yenilemeden sonra aynı sipariş aynı satır element'i: focus ve seçim yerinde kalır
protected readonly trackById = (_: number, order: OrderRow) => order.id;

disabledInteractive butonu gerçekten disabled yapmak yerine aria-disabled="true" veriyor ve tıklamayı yutuyor; buton focus alabildiği için klavye kullanıcısı nerede kaldığını kaybetmiyor. Filtreli bir listede (ör. yalnız "Paid") iptal edilen sipariş listeden çıktığı için bu yetmiyor; orada focus'u bir sonraki satıra ya da tablo başlığına bilinçli olarak taşımak gerekir.

04-dialog-sheet-tr.png

Route Değişince Açık Kalan Dialog

Tahminin ilk sorusu. Dialog açıkken sipariş sayfasının router'ı ile /admin/dashboard'a gittik, sonra başka bir denemede tarayıcının geri tuşuna bastık. Önce closeOnNavigation'ı vermeden (varsayılan), sonra 'url-change' ile:

default      router.navigateByUrl: url=/admin/dashboard dialogs=1
default      browser back:         url=/admin/orders dialogs=0
url-change   router.navigateByUrl: url=/admin/dashboard dialogs=0
url-change   browser back:         url=/admin/orders dialogs=0

Varsayılan closeOnNavigation: true, CDK'nın tip tanımındaki açıklamaya göre yalnız tarayıcının geri/ileri tuşlarını izliyor. Kod ile yapılan bir navigation'da dialog dashboard'un üstünde açık kaldı ve kullanıcı oradan "Cancel order"a basarsa, artık ekranda olmayan bir sayfanın siparişi iptal edilirdi. 22.2.7'nin tipi 'url-change' değerini de kabul ediyor: her URL değişiminde kapat. Tip açıklaması bunun bir route resolver'ından açılan overlay'leri hemen kapatabileceğini de not ediyor; resolver'dan dialog açmıyorsan güvenli bir varsayılan.

MatSnackBar ile "Geri Al"

Müşteri sepetinden bir kitabı silince beş saniye boyunca "Undo" diyen bir snackbar çıkıyor. Silme hemen API'ye gidiyor; Undo aynı adedi geri koyuyor:

// Hemen sil, 5 saniye "Undo" teklif et: Undo aynı adedi geri koyar
protected async remove(line: CartLine): Promise<void> {
  await this.store.remove(line.isbn);
  const snackBarRef = this.snackBar.open(`Removed ${line.title}`, 'Undo', { duration: 5000 });
  snackBarRef.onAction().subscribe(() => void this.store.setQuantity(line.isbn, line.quantity));
}
cart          : ["Dune ₺489.00 ₺978.00 Remove"]
snackbar      : Removed Dune Undo | aria-live polite | cart lines 0
after Undo    : ["Dune ₺489.00 ₺978.00 Remove"] | snackbars 0
no action     : snackbar gone after 5.3 s | cart lines 0
API calls     : PUT /api/carts/{cartId}/items/9780441172719 {"quantity":1} | PUT /api/carts/{cartId}/items/9780441172719 {"quantity":2} | DELETE /api/carts/{cartId}/items/9780441172719 | PUT /api/carts/{cartId}/items/9780441172719 {"quantity":2} | DELETE /api/carts/{cartId}/items/9780441172719

Undo iki adetlik satırı geri getirdi (978 TL); snackbar'ın içeriği aria-live="polite" bir bölgede, yani screen reader mesajı okuyor. Toast'ların ve live region'ların ayrıntısı Accessibility (a11y) serisinin "Dinamik İçerik ve Screen Reader Bildirimleri: Live Region'lar, ariaNotify(), Form Hataları, Toast ve Loading State'leri" yazısında. Bir şeyi unutma: beş saniye, klavyeyle snackbar'a ulaşmaya çalışan biri için kısa olabilir; geri alınamayan işlemler için snackbar yerine onay dialog'u kullan.

"Sil, sonra geri ekle" yaklaşımının bir alternatifi silmeyi snackbar kapanana kadar ertelemek. O zaman Undo bir API çağrısı değil, sadece hiçbir şey yapmamak; ama kullanıcı o beş saniyede sayfayı kapatırsa silme hiç olmaz. Sepet için ilkini seçtik: sunucudaki sepet her an gerçek durumu gösteriyor.

Sepet sayfası Material kullanan ilk müşteri sayfası oldu ve bunun bedelini ölçtük. CartPage eager route'ken snackbar initial bundle'a girdi; sepet route'unu loadComponent ile lazy yapınca çıktı:

# snackbar'lı CartPage, eager route
                    | Initial total            | 510.06 kB |               127.89 kB
# aynı sayfa, loadComponent ile
                    | Initial total            | 349.12 kB |                93.91 kB
chunk-BTPSdOPZ.js   | cart-page                |   3.57 kB |                 1.51 kB

Tek bir snackbar katalog sayfasının indirdiği JavaScript'e 34 kB transfer ekliyordu.

// Lazy: snackbar katalogun ihtiyaç duymadığı Material kodunu getirir
{ path: 'cart', loadComponent: () => import('./cart/cart-page').then(m => m.CartPage), title: 'Cart · Bookstore' },

MatBottomSheet ile Toplu İşlemler

Bir önceki yazının tablosu seçili siparişleri biliyordu. Seçim boş değilken toolbar'da bir "Actions" butonu çıkıyor ve ekranın altından bir bottom sheet açıyor; telefonda başparmağın ulaştığı yer:

// Seçili siparişler için toplu işlemler; ekranın alt kenarından açılan bir sheet (telefonda başparmağın ulaştığı yer)
@Component({
  selector: 'app-order-actions-sheet',
  imports: [MatActionList, MatListItem, MatListItemTitle],
  template: `
    <mat-action-list>
      <button mat-list-item type="button" (click)="choose('cancel')">
        <span matListItemTitle>Cancel {{ data.count }} {{ data.count === 1 ? 'order' : 'orders' }}</span>
      </button>
      <button mat-list-item type="button" (click)="choose('clear')">
        <span matListItemTitle>Clear selection</span>
      </button>
    </mat-action-list>
  `,
})
export class OrderActionsSheet {
  protected readonly data = inject<{ count: number }>(MAT_BOTTOM_SHEET_DATA);
  private readonly sheetRef = inject<MatBottomSheetRef<OrderActionsSheet, OrderAction>>(MatBottomSheetRef);

  protected choose(action: OrderAction): void {
    this.sheetRef.dismiss(action);
  }
}
protected openActions(): void {
  const ids = [...this.selection.selected];
  this.bottomSheet
    .open<OrderActionsSheet, { count: number }, OrderAction>(OrderActionsSheet, { data: { count: ids.length } })
    .afterDismissed()
    .subscribe(action => {
      if (action === 'cancel') {
        this.cancelOrders(ids, `${ids.length} selected orders`);
      } else if (action === 'clear') {
        this.selection.clear();
      }
    });
}

390px genişlikte iki sipariş seçip akışı uçtan uca çalıştırdık:

sheet         : role=dialog aria-modal=false items=["Cancel 2 orders","Clear selection"] focus=Cancel 2 orders
dialog title  : Cancel 2 orders?
requests      : POST /api/admin/orders/9501/cancel | POST /api/admin/orders/1892/cancel
snackbar      : 2 orders cancelled OK | 0 selected

Bottom sheet de bir role="dialog" ve focus ilk eylemde açılıyor. Seçim ids'e sheet açılmadan kopyalanıyor; sheet'ten sonra açılan dialog ve iki POST bu kopyayla çalışıyor, yani o arada seçim değişse de kullanıcının onayladığı siparişler iptal ediliyor. Masaüstünde aynı menü için mat-menu daha doğal; bottom sheet'i ekran genişliğine göre seçmek 07. yazının BreakpointObserver konusu.

CDK Dialog ve Native dialog

Aynı onayı iki farklı yolla daha kurduk ve snapshot'a koymadan ölçtük. CDK Dialog, MatDialog'un altındaki stilsiz katman: Dialog servisi, DIALOG_DATA ve DialogRef; başlık, padding ve köşe yuvarlaklığı bizim CSS'imiz:

// CDK Dialog: aynı davranış (overlay, focus trap, Esc, aria-hidden), Material görünümü yok; stiller bizim
@Component({
  selector: 'app-cancel-order-dialog',
  imports: [MatButton, MatFormField, MatLabel, MatSelect, MatOption],
  template: `
    <h2 [id]="titleId">Cancel {{ title }}?</h2>
    <p>{{ data.summary }}</p>
    <mat-form-field>
      <mat-label>Reason</mat-label>
      <mat-select [value]="reason()" (valueChange)="reason.set($event)">
        @for (r of reasons; track r) {
          <mat-option [value]="r">{{ r }}</mat-option>
        }
      </mat-select>
    </mat-form-field>
    <div class="actions">
      <button matButton (click)="dialogRef.close()">Keep order</button>
      <button matButton="filled" [disabled]="!reason()" (click)="confirm()">Cancel order</button>
    </div>
  `,
  styles: `
    :host { display: block; padding: 24px; border-radius: 16px; background: var(--mat-sys-surface-container-high); }
    .actions { display: flex; justify-content: flex-end; gap: 8px; }
  `,
})
export class CancelOrderDialog {
  protected readonly data = inject<CancelOrderData>(DIALOG_DATA);
  protected readonly dialogRef = inject<DialogRef<CancelOrderResult, CancelOrderDialog>>(DialogRef);
  // ...
}

Native sürümde dialog component'i yok; sayfanın template'inde bir <dialog> var ve showModal() ile açılıyor:

<dialog #confirmDialog aria-labelledby="native-title" (close)="pending.set(null)">
  @if (pending(); as p) {
    <h2 id="native-title">Cancel {{ p.orderIds.length === 1 ? 'order #' + p.orderIds[0] : p.orderIds.length + ' orders' }}?</h2>
    <p>{{ p.summary }}</p>
    <label>Reason <select #reason>@for (r of reasons; track r) {<option>{{ r }}</option>}</select></label>
    <button type="button" (click)="confirmDialog.close()">Keep order</button>
    <button type="button" (click)="confirmNative(reason.value)">Cancel order</button>
  }
</dialog>

Üç sürümü production build olarak servis edip /admin/orders'a girip dialog'u açan bir kullanıcının indirdiği JavaScript'i topladık ve davranışı karşılaştırdık:

native    JS for /admin/orders + dialog: 938051 bytes
native    role=dialog aria-modal=null top layer=true | app-root aria-hidden=null | focus dialog
native    Escape: open=0 focus button "Cancel order 5123"
cdk       JS for /admin/orders + dialog: 938410 bytes
cdk       role=dialog aria-modal=false top layer=true | app-root aria-hidden=true | focus mat-select
cdk       Escape: open=0 focus button "Cancel order 5123"
material  JS for /admin/orders + dialog: 957185 bytes
material  role=dialog aria-modal=false top layer=true | app-root aria-hidden=true | focus mat-select
material  Escape: open=0 focus button "Cancel order 5123"

Native satırında role=dialog bir attribute değil, element'in adı: <dialog>'un role'ü zaten dialog. Tahminin üçüncü sorusunun cevabı: üçü de. CDK 22'nin overlay'leri varsayılan olarak bir popover="manual" host'u içinde açıyor; bu yüzden MatDialog da CDK Dialog da native dialog gibi top layer'da ve z-index yarışına girmiyor. Top layer ve stacking context'in kendisi CSS serisinin "Normal Flow, display ve Positioning: sticky, z-index, Stacking Context ve Top Layer" yazısında. Farklar başka yerde: HTML spec'ine göre native showModal() sayfanın geri kalanını tarayıcı seviyesinde inert yapıyor ve ilk focus'u dialog'un kendisine veriyor; CDK ve Material arka planı aria-hidden ile gizleyip focus'u JS ile tutuyor ve ilk tabbable element'e veriyor.

Byte farkları bu sayfaya özgü: bottom sheet ve snackbar CDK overlay'ini ve CDK Dialog'u zaten getirdiği için CDK Dialog'un ek maliyeti 359 byte, MatDialog'unki 19.134 byte. Sayfada başka hiçbir overlay yoksa farkın çok daha büyük olmasını bekleriz; bunu ayrıca ölçmedik. Native dialog ve popover'ın kendisini HTML serisinin "Native Interaktif Element'ler 1: details/summary, dialog, popover ve Invoker Commands" yazısı anlatıyor. Karar şöyle: tek bir onay kutusu, Material'sız bir sayfa → native dialog; Material kullanan bir ekranda MatDialog (görünüm, density ve tema token'ları hazır); kendi tasarımın ve CDK'nın davranışı lazımsa CDK Dialog.

Üretimde Dikkat

Dialog'a ID ver, object verme. MAT_DIALOG_DATA açılış anındaki bir değer; tablo yenilenince dialog'daki satır object'i eskide kalır. Dialog'u açarken taşıdığın her şey, dialog'u başka ekranlardan açmayı da zorlaştırır.

Kapanışı türüyle ele al. afterClosed() Escape, backdrop ve "vazgeç" için undefined (ya da değersiz mat-dialog-close için '') verir; onayı yalnız beklediğin şekildeki bir sonuçla kabul et.

Navigation'da kapat. closeOnNavigation: 'url-change' vermediğin bir dialog, kod ile yapılan route değişimlerinde açık kalır.

Material'ı müşteri sayfasına getirirken bundle'ı ölç. Tek bir snackbar initial bundle'ı 34 kB transfer büyüttü; lazy route ile sıfıra indi.

Testlerde animasyonu kapat. afterClosed() kapanış animasyonundan sonra geliyor; testte MATERIAL_ANIMATIONS'a { animationsDisabled: true } verince sonuç close()'un hemen ardından geldi. Dialog'lar overlay'de, fixture'ın dışında render edildiği için harness'ları TestbedHarnessEnvironment.documentRootLoader ile arıyoruz:

 ✓ src/app/cart/cart-page.spec.ts > CartPage > removes a book and puts the same quantity back on Undo
 ✓ src/app/admin/orders/cancel-order-dialog.spec.ts > CancelOrderDialog > asks for a reason before it can cancel and returns it
 ✓ src/app/admin/orders/cancel-order-dialog.spec.ts > CancelOrderDialog > returns undefined when the user keeps the order
      Tests  16 passed (16)

Sık Düşülen Tuzaklar

Birincisi, dialog component'ine servisleri, callback'leri ya da tablonun state'ini data ile taşımak. Dialog kararı döndürsün, işi açan taraf yapsın.

İkincisi, afterClosed()'ın her kapanışta aynı şeyi verdiğini sanmak. Değersiz mat-dialog-close boş string döndürür.

Üçüncüsü, açık dialog'un route değişince kapanacağını varsaymak. Varsayılan yalnız geri/ileri tuşunu dinler.

Dördüncüsü, onaydan sonra focus'u unutmak. Satırlar yeniden oluşturuluyor ya da buton disabled oluyorsa focus body'ye düşer.

Beşincisi, geri alınamayan bir işlemi yalnız snackbar'la "geri alınabilir" göstermek. Undo'nun gerçekten geri alabildiği işlemlerde kullan.

Küçük Bir Deneme

★☆☆ CancelOrderDialog'u açarken autoFocus: 'dialog' ver ve ilk focus'un nereye gittiğini ölç. Screen reader için hangisi daha iyi: başlığın okunması mı, doğrudan select'e gitmek mi?

★★☆ Dialog'u bindings: [inputBinding('orderIds', …)] ile aç ve MAT_DIALOG_DATA yerine input() kullan. Açıkken tablo yenilenirse dialog'daki özet güncelleniyor mu?

★★★ Snackbar'ı "silmeyi ertele" yaklaşımına çevir: DELETE'i yalnız snackbar Undo'suz kapandığında gönder. Kullanıcı o beş saniye içinde sekmeyi kapatırsa ne olur? afterDismissed()'ın dismissedByAction değerini ve pagehide'ı kullanarak bunu çöz.

Material'ın hazır overlay'lerini kullandık: dialog, bottom sheet, snackbar. Sonraki yazıda bunların altına iniyoruz: CDK Overlay ve Portal ile katalogda bir "hızlı önizleme" paneli ve kullanıcı menüsü kuruyor, position strategy'leri, scroll strategy'leri ve CSS anchor positioning ile aynı işin JavaScript'siz hâlini karşılaştırıyoruz.