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

CDK Overlay ve Portal: Kendi Açılır Panelini Kurmak, Position Strategy'leri ve CSS Anchor Positioning ile Karşılaştırma

Mert Özen 15 Kas 2026 16 dk 6 görüntülenme
CDK Overlay ve Portal: Kendi Açılır Panelini Kurmak, Position Strategy'leri ve CSS Anchor Positioning ile Karşılaştırma

CDK Overlay'de paneli createOverlayRef ve ComponentPortal ile aç, FlexibleConnectedPositionStrategy'ye fallback pozisyonlar ve reposition scroll strategy'si ver, DestroyRef ile dispose et: etmeyince panel ve backdrop'u sayfa değiştikten sonra ekranda kaldı. Bookstore'da hızlı önizleme ve hesap paneli kuruyor; Tab ile focus kaybını, eager import'un 14.5 kB maliyetini ve aynı paneli popover + CSS anchor positioning ile ö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çı) katalog sayfasında her kitap kartına bir "Quick look" butonu ekliyoruz: tıklayınca kartın yanında kitabın açıklamasını, fiyatını ve stoğunu gösteren küçük bir panel açılıyor. Yönetim panelinin toolbar'ına da bir hesap paneli koyuyoruz. İkisini de Material'ın hazır component'leri olmadan, doğrudan CDK Overlay ve Portal ile kuruyoruz. Üç soru: Panel açıkken kullanıcı panelin içindeki "View details" link'ine tıklarsa ve sayfa değişirse panel kendiliğinden kapanır mı? Panel en sağ kolondaki kartta açılırsa nereye yerleşir? Panelin son butonundayken Tab'a basan klavye kullanıcısının focus'u nereye gider?

Overlay'i Kodla Açmak: createOverlayRef ve ComponentPortal

Bir önceki yazıdaki dialog, bottom sheet ve snackbar'ın hepsi aynı altyapının üstünde duruyor: CDK Overlay. Overlay, sayfanın akışının dışında, body'nin sonundaki bir container'da render edilen ve bir position strategy ile yerleştirilen bir kutu. İçine ne konacağını Portal belirliyor: ComponentPortal bir component'i, TemplatePortal bir ng-template'i "başka bir yerde" render ediyor. Hızlı önizlemenin içeriği sıradan bir component; panelin nerede durduğundan haberi yok:

// Panelin içeriği: ComponentPortal bunu overlay'in içinde, DOM'da karttan uzakta render eder
@Component({
  selector: 'app-book-quick-view',
  imports: [CurrencyPipe, RouterLink],
  host: {
    role: 'dialog',
    'aria-labelledby': 'quick-view-title',
    tabindex: '-1',
    class: 'quick-view',
  },
  template: `
    @if (book.value(); as b) {
      <h2 id="quick-view-title">{{ b.title }}</h2>
      <p class="muted">{{ b.author }} · {{ b.publishedYear }}</p>
      <p>{{ b.description }}</p>
      <p>
        <strong>{{ b.price | currency: 'TRY' : 'symbol-narrow' }}</strong>
        <span class="muted"> · {{ b.available > 0 ? b.available + ' in stock' : 'Out of stock' }}</span>
      </p>
      <div class="actions">
        <a [routerLink]="['/books', b.isbn]">View details</a>
        <button type="button" (click)="closed.emit()">Close</button>
      </div>
    } @else if (book.error()) {
      <h2 id="quick-view-title">Not available</h2>
      <button type="button" (click)="closed.emit()">Close</button>
    } @else {
      <p id="quick-view-title" class="muted">Loading…</p>
    }
  `,
})
export class BookQuickView {
  readonly isbn = input.required<string>();
  readonly closed = output<void>();

  protected readonly book = httpResource<BookDetails>(() => `/api/books/${this.isbn()}`);
}

Overlay'i açan kod ayrı bir dosyada. CDK 20.0.0'dan beri Overlay servisinin yanında tree-shaking'e uygun function'lar var (CHANGELOG'da "add tree-shakeable alternatives for overlay APIs"): createOverlayRef, createFlexibleConnectedPositionStrategy, createRepositionScrollStrategy. Hepsi bir Injector alıyor:

// Kartın sağı; sığmazsa solu; ikisi de sığmazsa altı
const positions: ConnectedPosition[] = [
  { originX: 'end', originY: 'top', overlayX: 'start', overlayY: 'top', offsetX: 8 },
  { originX: 'start', originY: 'top', overlayX: 'end', overlayY: 'top', offsetX: -8 },
  { originX: 'center', originY: 'bottom', overlayX: 'center', overlayY: 'top', offsetY: 8 },
];

// İlk "Quick look" tıklamasında yüklenir: CDK overlay kodu katalogun initial bundle'ına girmez
export function attachQuickView(injector: Injector, isbn: string, card: HTMLElement, close: () => void): OverlayRef {
  const overlayRef = createOverlayRef(injector, {
    positionStrategy: createFlexibleConnectedPositionStrategy(injector, card)
      .withPositions(positions)
      .withViewportMargin(8)
      .withPush(false),
    // Sayfa kaydıkça panel kartla birlikte hareket eder
    scrollStrategy: createRepositionScrollStrategy(injector),
    hasBackdrop: true,
    backdropClass: 'cdk-overlay-transparent-backdrop',
  });
  const view = overlayRef.attach(new ComponentPortal(BookQuickView, null, injector));
  view.setInput('isbn', isbn);
  view.instance.closed.subscribe(close);
  overlayRef.backdropClick().subscribe(close);
  const panel: HTMLElement = view.location.nativeElement;
  // Escape ya da panelden Tab ile çıkış: kapat ve focus'u butona geri ver
  overlayRef.keydownEvents().subscribe(event => {
    if (event.key === 'Escape' || tabLeavesPanel(event, panel)) {
      event.preventDefault();
      close();
    }
  });
  // Klavye ve screen reader kullanıcısı panelin arkasında değil, içinde başlar
  panel.focus();
  return overlayRef;
}

attach() bir ComponentRef döndürüyor; input'u setInput ile veriyoruz, output'a normal bir subscription ile bağlanıyoruz. Component'e bir MAT_DIALOG_DATA benzeri token gerekmedi. Portal'a verdiğimiz injector sayfanın injector'ı; bu yüzden panel HttpClient'ı, router'ı ve sayfaya ait servisleri görüyor. Overlay'in yaşam süresini ise sayfaya bağlı küçük bir servis tutuyor:

// Kullanan sayfa provide eder: aynı anda tek panel, sayfayla birlikte yok olur
@Injectable()
export class QuickView {
  private readonly injector = inject(Injector);
  private overlayRef: OverlayRef | null = null;
  private trigger: HTMLElement | null = null;
  private destroyed = false;
  readonly openIsbn = signal<string | null>(null);

  constructor() {
    // Bu olmadan panel ve backdrop'u sayfa gittikten sonra ekranda kalır
    inject(DestroyRef).onDestroy(() => {
      this.destroyed = true;
      this.overlayRef?.dispose();
    });
  }

  async open(isbn: string, card: HTMLElement, trigger: HTMLElement): Promise<void> {
    this.close();
    const { attachQuickView } = await import('./quick-view-overlay');
    // Chunk yüklenirken sayfa yok olmuş olabilir
    if (this.destroyed) {
      return;
    }
    this.trigger = trigger;
    this.overlayRef = attachQuickView(this.injector, isbn, card, () => this.close());
    this.openIsbn.set(isbn);
  }

  close(): void {
    if (!this.overlayRef) {
      return;
    }
    this.overlayRef.dispose();
    this.overlayRef = null;
    this.openIsbn.set(null);
    // Focus paneli açan butona döner
    this.trigger?.focus();
    this.trigger = null;
  }
}
<li class="book-card" #card>
  <!-- ... başlık, yazar, fiyat ... -->
  <div class="card-actions">
    <button type="button" (click)="cartStore.add(book.isbn)">Add to cart</button>
    <button #trigger type="button" class="secondary" aria-haspopup="dialog"
            [attr.aria-expanded]="quickView.openIsbn() === book.isbn"
            [attr.aria-label]="'Quick look: ' + book.title"
            (click)="quickView.open(book.isbn, card, trigger)">Quick look</button>
  </div>
</li>

QuickView'ı CatalogPage'in providers'ına koyduk; servis sayfa ile oluşuyor, sayfa ile yok oluyor ve DestroyRef o anda overlay'i dispose ediyor.

Position Strategy: Tetikleyiciye Bağlı Panel ve Fallback Pozisyonlar

FlexibleConnectedPositionStrategy paneli bir origin'e bağlıyor; origin burada butonun kendisi değil, kartın tamamı. positions sırayla denenen bir liste: her biri "origin'in hangi köşesi, panelin hangi köşesine değsin" diyor. İlk pozisyon kartın sağ üst köşesine panelin sol üst köşesini koyuyor; panel viewport'a sığmazsa ikinci (kartın solu), o da sığmazsa üçüncü (kartın altı) deneniyor. withViewportMargin(8) viewport kenarında 8px boşluk bırakıyor; withPush(false) ise sığmayan paneli ekrana doğru itmek yerine bir sonraki pozisyona geçmesini sağlıyor. 1280x700 viewport'ta dört kolonlu gridde ölçtük:

columns          : 4
card 1 (col 1)   : right of card (card x=160-388, panel x=396-710)
focus            : app-book-quick-view role=dialog
overlay host     : popover=manual :popover-open=true
aria-expanded    : true
after Escape     : panes 0 | focus button "Quick look: 1984"
card 4 (col 4)   : left of card (card x=892-1120, panel x=570-884)
backdrop click   : panes 0 | focus button "Quick look: Crime and Punishment"
View details     : url /books/9780060934347 | panes 0 | backdrops 0

Tahminin ikinci sorusunun cevabı dördüncü satırda: en sağ kolondaki kartın sağında 314px'lik panele yer yok, strategy ikinci pozisyona geçip paneli kartın soluna koydu. Hangi pozisyonun seçildiğini kodda bilmek istersen positionStrategy.positionChanges her değişimde seçilen connectionPair'i veriyor; örneğin panelin oku varsa yönünü buna göre çevirirsin.

Overlay host'unun popover="manual" olması 04. yazıda dialog'da gördüğümüz şeyin aynısı: CDK 22'de overlay'ler varsayılan olarak native popover içinde, yani top layer'da açılıyor ve sayfadaki z-index'lerle yarışmıyor. Bu davranışı kapatmak isteyenler için 21.0.0 bir DI (dependency injection; Angular'ın bağımlılıkları sağlama mekanizması) token'ı ekledi.

Scroll Strategy'leri ve Backdrop

Panel açıkken sayfa kayarsa ne olacağına scroll strategy karar veriyor. Aynı paneli dört strategy ile açtık, ikinci satırdaki bir kartta fare tekerleğiyle 150px kaydırdık:

reposition : before card top 330 panel top 330 | after wheel 150px scrollY 150 card top 180 panel top 180
block      : before card top 330 panel top 330 | after wheel 150px scrollY 0 card top 330 panel top 330
close      : before card top 330 panel top 330 | after wheel 150px scrollY 150 card top 180 panel top closed
noop       : before card top 330 panel top 330 | after wheel 150px scrollY 150 card top 180 panel top 330

reposition paneli kartla birlikte taşıdı; hızlı önizleme için doğru seçim bu. block sayfanın kaymasını engelledi (scrollY 0 kaldı); modal bir şey için mantıklı, küçük bir panel için kullanıcıyı şaşırtıyor. close paneli kapattı; tooltip gibi kısa ömürlü şeyler için uygun. noop ise hiçbir şey yapmadı: kart 180'e çıktı, panel 330'da kaldı ve artık başka bir kartın üstünde duruyor. Strategy vermediğinde varsayılan noop; bağlı bir panelde bunu bilinçli seçmedikçe görmek istemeyeceğin sonuç bu.

Backdrop'u şeffaf yaptık (cdk-overlay-transparent-backdrop). İşi panelin dışına tıklamayı yakalamak: backdropClick() paneli kapatıyor. Fare tekerleği backdrop'un üstünde de sayfayı kaydırdı, yukarıdaki reposition ölçümü bunu gösteriyor. Bedeli şu: backdrop açıkken sayfadaki hiçbir şeye tıklanamıyor; ilk tıklama yalnız paneli kapatıyor. Paneli kapatmadan başka bir karta tıklanabilsin istiyorsan backdrop yerine overlayRef.outsidePointerEvents()'i dinleyebilirsin.

Declarative Yol: cdkConnectedOverlay ile Hesap Paneli

Her overlay için servis yazmak gerekmiyor. Toolbar'daki hesap panelini cdkConnectedOverlay directive'i ile template'te kurduk. Directive ng-template'i bir TemplatePortal'a çeviriyor, cdkConnectedOverlayOpen true olunca attach, false olunca detach ediyor:

<button matButton type="button" cdkOverlayOrigin #userOrigin="cdkOverlayOrigin" aria-haspopup="dialog"
        [attr.aria-expanded]="accountOpen()" (click)="accountOpen.set(!accountOpen())">
  <mat-icon>account_circle</mat-icon>
  {{ user.name }}
</button>

<!-- Declarative overlay: directive bu template'i bir TemplatePortal'a çevirip attach eder -->
<ng-template cdkConnectedOverlay [cdkConnectedOverlayOrigin]="userOrigin" [cdkConnectedOverlayOpen]="accountOpen()"
             [cdkConnectedOverlayPositions]="accountPositions" (overlayOutsideClick)="closeAccount(userOrigin.elementRef.nativeElement, false)"
             (detach)="closeAccount(userOrigin.elementRef.nativeElement, true)" (attach)="focusFirst()"
             (overlayKeydown)="accountKeydown($event)">
  <section #accountPanel class="account-panel" role="dialog" aria-label="Account">
    <p class="name">{{ user.name }}</p>
    <p class="email">{{ user.email }}</p>
    <p class="role">{{ user.role }}</p>
    <a matButton routerLink="/admin/orders" (click)="accountOpen.set(false)">Orders</a>
    <a matButton routerLink="/" (click)="accountOpen.set(false)">Sign out</a>
  </section>
</ng-template>
// Butonun altında, sağ kenarlar hizalı; altta yer yoksa üstünde
protected readonly accountPositions: ConnectedPosition[] = [
  { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 4 },
  { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -4 },
];

protected focusFirst(): void {
  // Panelin DOM'u ancak bir sonraki render'dan sonra var
  afterNextRender(() => this.accountPanel()?.nativeElement.querySelector<HTMLElement>('a, button')?.focus(),
    { injector: this.injector });
}

// Escape'i directive kendisi ele alır (detach eder); panelden Tab ile çıkmak da paneli kapatır
protected accountKeydown(event: KeyboardEvent): void {
  const panel = this.accountPanel()?.nativeElement;
  if (panel && tabLeavesPanel(event, panel)) {
    event.preventDefault();
    this.accountOpen.set(false);
  }
}

// detach: directive paneli kapattı (Escape); dışarı tıklama: biz kapatıyoruz, focus kullanıcının tıkladığı yerde kalır
protected closeAccount(button: HTMLElement, returnFocus: boolean): void {
  const focusInside = this.accountPanel()?.nativeElement.contains(document.activeElement) ?? false;
  this.accountOpen.set(false);
  if (returnFocus && (focusInside || document.activeElement === document.body)) {
    button.focus();
  }
}

İki yol arasındaki fark davranışta değil, kimin neyi yönettiğinde. Directive Escape'i kendisi yakalayıp paneli detach ediyor ama dışarı tıklamada yalnız overlayOutsideClick event'ini veriyor; paneli kapatmak bize kalıyor. Ne focus'u panele taşıyor ne de kapanınca geri getiriyor. Escape'te directive'in kontrol ettiği şey event.keyCode; birim testte sentetik event'e keyCode: 27 vermeden directive tepki vermedi. Template'te yaşayan, tek bir yerde açılan paneller için directive yeterli. Panel birden çok yerden açılıyorsa, içeriği bir component ise ya da açılmadan önce bir chunk yüklemek gerekiyorsa servis yolu daha rahat.

05-overlays-tr.png

Klavye ve Focus: Overlay body'nin Sonunda Yaşar

Tahminin üçüncü sorusu. Panel top layer'da, ama DOM'da (Document Object Model; sayfanın tarayıcıdaki element ağacı) body'nin en sonunda duruyor. İlk sürümde Tab tuşuna bastığımızda şu oldu:

# hızlı önizleme, ilk sürüm
Tab 1: a "View details" panes=1
Tab 2: button "Close" panes=1
Tab 3: body "BookstoreCatalogCart (0)Sear" panes=1
Shift+Tab: button "Quick look: Wuthering Height" panes=1
# hesap paneli, ilk sürüm
Tab x2 out    : panels 1 | focus body "Bookstore AdminStorefrontaccou"

Son elemandan sonra Tab, focus'u sayfanın dışına (body'ye, oradan tarayıcının adres çubuğuna) taşıdı. Shift+Tab daha da kafa karıştırıcı: focus, açtığımız kartın butonuna değil, DOM'da panelden hemen önce gelen element'e, yani sayfanın en sondaki kitabının butonuna gitti. Panel bu sırada açık kalıyor. Fareyle hiç fark edilmeyen bir sorun. Çözüm, Tab panelden çıkacaksa paneli kapatmak ve focus'u açan butona vermek:

// Overlay <body>'nin sonunda render edilir: son element'ten Tab (ya da ilkinden Shift+Tab) sayfadan çıkar
// ya da sayfanın son butonuna atlar. Bu keydown focus'u panelin dışına taşıyacaksa true.
export function tabLeavesPanel(event: KeyboardEvent, panel: HTMLElement): boolean {
  if (event.key !== 'Tab') {
    return false;
  }
  const focusable = [...panel.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input, select, textarea')];
  const active = document.activeElement;
  return event.shiftKey
    ? active === panel || active === focusable[0]
    : focusable.length === 0 || active === focusable.at(-1);
}
Tab 1: a "View details" panes=1
Tab 2: button "Close" panes=1
Tab 3: button "Quick look: Anna Karenina" panes=0
Shift+Tab: button "Quick look: Anna Karenina" panes=0
Tab x2 out    : panels 0 | focus button "account_circle Ada Lovelace"

Bu bir focus trap değil; modal olmayan bir panelde kullanıcıyı içeride tutmak istemiyoruz, sadece kaybolmasını istemiyoruz. Modal bir şey gerekiyorsa CDK'nın cdkTrapFocus'u ve Material dialog'u var; bunların ayrıntısı Accessibility (a11y) serisinin "Angular'da Accessible Component'ler: Angular Aria, CDK a11y, Material ve Kendi Combobox'ını Yazmamak" yazısında. Panelin role="dialog" ve aria-labelledby ile adlandırılması, butondaki aria-expanded ve aria-haspopup ARIA (Accessible Rich Internet Applications; screen reader'a role ve state bildiren attribute'lar) tarafı. ARIA'yı yazan WAI (Web Accessibility Initiative; web erişilebilirliği standartlarını hazırlayan çalışma grubu) kurallarına göre bunları doğru seçmek aynı serinin "WAI-ARIA'yı Doğru Kullanmak: Role, State, Property, Accessible Name ve "Kötü ARIA'dan Hiç ARIA İyidir"" yazısının konusu.

CSS Anchor Positioning ve popover ile Aynı Panel

Aynı hızlı önizlemeyi bir de JavaScript yazmadan kurduk ve snapshot'a koymadan ölçtük. popover attribute'u açma, kapatma, Escape ve dışarı tıklamayı; CSS anchor positioning yerleşimi üstleniyor:

<li class="book-card" [style.anchor-name]="'--card-' + $index">
  <!-- ... -->
  <button type="button" class="secondary" [attr.popovertarget]="'peek-' + $index">Quick look</button>
  <!-- JavaScript yok: popover açıp kapatır, anchor positioning kartın yanına yerleştirir -->
  <div popover class="peek" [id]="'peek-' + $index" [style.position-anchor]="'--card-' + $index" role="dialog"
       [attr.aria-label]="book.title">
    <h2>{{ book.title }}</h2>
    <p class="muted">{{ book.author }} · {{ book.publishedYear }}</p>
    <p><strong>{{ book.price | currency: 'TRY' : 'symbol-narrow' }}</strong></p>
    <a [routerLink]="['/books', book.isbn]">View details</a>
  </div>
</li>
.peek {
  /* Popover'ın UA stilleri onu viewport'un ortasına koyar (inset: 0; margin: auto) */
  inset: auto;
  margin: 0 8px;
  width: 280px;
  /* Kartın sağı, üst kenarından başlayarak; sığmazsa solu; o da olmazsa altı */
  position-area: right span-bottom;
  position-try-fallbacks: flip-inline, bottom;
}

Her kart kendi anchor-name'ini alıyor (--card-0, --card-1…), panel position-anchor ile kendi kartına bağlanıyor. position-try-fallbacks CDK'daki positions listesinin karşılığı. Sonuç:

support       : anchor-name true position-area true popover true
card 1        : right of card (card x=160-388 top=162, panel x=396-710 top=162)
top layer     : popover= :popover-open=true aria-expanded on button=null
focus         : button "Quick look"
Tab           : a "View details"
after Escape  : open 0 | focus button "Quick look"
card 4        : left of card (card x=892-1120 top=162, panel x=570-884 top=162)
outside click : open 0 | focus body "BookstoreCatalogCart (0)Sear"
scroll 150px  : card top 330 -> 180, panel top 330 -> 180
700px wide    : below of card (card x=247-453 top=217, panel x=193-507 top=425)
AX closed     : expanded=false
AX open       : expanded=true

Yerleşim birebir aynı çıktı: ilk kartta sağda, dördüncüde solda, 700px'lik dar ekranda altta. Sayfa kayınca panel kartla birlikte gitti, yani reposition'ın karşılığı tarayıcıda hazır. Escape ve dışarı tıklama popover'ın light dismiss davranışı. Focus farklı çalışıyor: popover focus'u taşımıyor, buton focus'ta kalıyor; ama tarayıcı popover'ı Tab sırasında butondan hemen sonraya koyduğu için bir sonraki Tab "View details"e gitti, Escape'ten sonra da focus butona döndü. aria-expanded'ı biz yazmadık; DOM'da yok ama Chromium'un kendi accessibility tree'sinde (CDP, yani Chrome DevTools Protocol ile okuduk) buton kapalıyken expanded=false, açıkken expanded=true. Bu panelde JavaScript'in tek işi Angular'ın template'i render etmesi.

Peki hangisi? MDN'in (Mozilla Developer Network; Mozilla'nın web dokümantasyonu) tarayıcı uyumluluk verisine göre anchor-name Chrome 125, Firefox 147 ve Safari 26'dan, position-try-fallbacks Chrome 128'den, position-area Chrome 129'dan beri var; MDN özelliği "Baseline 2026" olarak gösteriyor, yani en yeni sürümlerde her yerde çalışıyor ama eski tarayıcılarda çalışmıyor. Anchor positioning'in kendisini CSS serisinin "Anchor Positioning: JavaScript'siz Tooltip, Dropdown ve Popover Konumlandırma" yazısı, popover'ı HTML serisinin "Native Interaktif Element'ler 1: details/summary, dialog, popover ve Invoker Commands" yazısı anlatıyor. Bizim kararımız şu: içerik statikse ve kullanıcılarının tarayıcıları yeniyse popover + anchor. CDK Overlay şu durumlarda hâlâ gerekli: eski tarayıcıları desteklemen gerekiyorsa; içerik ancak açılınca yüklenen bir component ise (bizim önizlememiz açıklamayı ve stoğu ayrı bir istekle alıyor); seçilen pozisyona göre kod çalışması gerekiyorsa (positionChanges); ya da overlay Material'ın kendi component'leriyle aynı stack'te yönetilmeliyse (Escape'in en üstteki overlay'i kapatması gibi).

Üretimde Dikkat

Overlay'in bundle maliyetini ölç. Katalog sayfası eager bir route. İlk sürümde QuickView overlay function'larını doğrudan import ediyordu ve production build initial bundle'ı şöyle değiştirdi:

# önce (05 öncesi)
                    | Initial total            | 349.12 kB |                93.97 kB
# createOverlayRef + ComponentPortal, eager import
                    | Initial total            | 413.79 kB |               108.49 kB
# aynısı Overlay servisiyle (overlay.create, overlay.position(), overlay.scrollStrategies)
                    | Initial total            | 415.79 kB |               108.57 kB
# attachQuickView dinamik import ile
                    | Initial total            | 351.26 kB |                94.45 kB
chunk-DtuH_jRq.js   | quick-view-overlay       |   2.87 kB |                 1.32 kB

Bir hızlı önizleme paneli, katalogu açan her kullanıcının indirdiği JavaScript'e 14.5 kB transfer ekliyordu. import('./quick-view-overlay') ile bu kod ilk tıklamaya kaydı. Production build'i servis edip ilk tıklamada inen dosyaları saydık:

load : 1 JS files, 339523 bytes  main-JMV5IMZ4.js 339523
click: 3 JS files, 63630 bytes  chunk-DtuH_jRq.js 2869, chunk-C_jCNfGN.js 233, chunk-CBHxC1D_.js 60528

İlk tıklama 63.630 byte indiriyor; yavaş bağlantıda panelin açılması gecikir, ama paneli hiç açmayan kullanıcı bunu ödemiyor. Function'larla servis arasındaki fark bu sayfada küçük (2 kB raw); function'ların asıl faydası servisin bütün strategy'leri birlikte getirmemesi. QuickView'daki destroyed kontrolü de bu lazy yüklemenin yan etkisi: chunk inerken kullanıcı sayfadan çıkarsa overlay'i hiç açmıyoruz.

Testleri overlay'in DOM'una göre yaz. Overlay fixture'ın dışında, document'ta; birim testleri .cdk-overlay-pane'i document'tan arıyor ve httpResource'un isteği gitsin diye TestBed.tick() çağırıyor:

 ✓ src/app/core/overlay-focus.spec.ts > tabLeavesPanel > is true for Tab on the last element and for Shift+Tab on the panel or its first element
 ✓ src/app/catalog/quick-view.spec.ts > QuickView > shows the book in an overlay and moves focus into it
 ✓ src/app/catalog/quick-view.spec.ts > QuickView > closes on Escape and gives focus back to the button
 ✓ src/app/catalog/quick-view.spec.ts > QuickView > removes the overlay when the page that opened it is destroyed
 ✓ src/app/admin/admin-shell.spec.ts > AdminShell > opens the account panel with focus inside and closes it on Escape with focus back on the button
      Tests  22 passed (22)

Sık Düşülen Tuzaklar

Birincisi, overlay'i dispose etmeden component'i yok etmek. Tahminin ilk sorusunu DestroyRef satırını yoruma alarak ölçtük:

View details     : url /books/9780060934347 | panes 1 | backdrops 1

Sayfa kitap detayına geçti, katalog component'i yok oldu, ama panel ve şeffaf backdrop'u yerinde kaldı. Kullanıcı detay sayfasında hiçbir yere tıklayamıyor; ilk tıklama backdrop'a gidiyor ve artık var olmayan bir sayfanın close()'unu çağırıyor. Panelin içindeki component de httpResource'uyla birlikte yaşamaya devam ediyor. Overlay, açan component'in view'una bağlı değil; dispose etmek senin işin. disposeOnNavigation: 'url-change' seçeneği 22.2.7'nin tipinde var ve route değişiminde kapatır, ama bir @if ile yok olan component'i kapsamaz; DestroyRef her ikisini kapsıyor. ('url-change''i overlay için ayrıca ölçmedik.)

İkincisi, yalnız fareyle çalışan panel. mouseenter ile açılan, focus'u içeri almayan, Escape'i dinlemeyen bir önizleme klavye kullanıcısı için yok. Yukarıdaki Tab ölçümü fareyle hiç görünmeyen bir sorunu gösterdi; paneli butonla aç, focus'u içeri al, Escape ve Tab çıkışında açan butona geri dön.

Üçüncüsü, scroll strategy vermemek. Varsayılan noop, bağlı bir paneli sayfa kayarken başka bir kartın üstünde bırakıyor.

Dördüncüsü, backdrop'un bedelini unutmak. Şeffaf backdrop görünmüyor ama açıkken sayfadaki ilk tıklamayı yutuyor.

Beşincisi, Material'ın bir component'ini tekrar yazmak. Bir aksiyon listesi gerekiyorsa mat-menu, bir seçim listesi gerekiyorsa mat-select ya da mat-autocomplete zaten klavye navigasyonu ve ARIA ile geliyor; CDK Overlay'i Material'da karşılığı olmayan paneller için kullan.

Küçük Bir Deneme

★☆☆ attachQuickView'a disposeOnNavigation: 'url-change' ekle, DestroyRef satırını yoruma al ve "View details" ölçümünü tekrarla. Sonra paneli açıkken katalogda bir arama yap: panel kapanıyor mu, kapanmalı mı?

★★☆ positionStrategy.positionChanges'e subscribe ol ve seçilen pozisyona göre panele data-side="left" ya da "right" yaz; CSS ile kartı gösteren küçük bir ok çiz. Anchor positioning sürümünde aynı oku nasıl yaparsın?

★★★ Backdrop'u kaldırıp overlayRef.outsidePointerEvents() kullan: panel açıkken başka bir kartın "Quick look"una tıklamak doğrudan o kartın panelini açsın. Bunu kırmadan "Add to cart" butonuna tıklamak paneli kapatmalı mı?

CDK Overlay ile bir şeyi sayfanın akışından çıkarıp istediğimiz yere koyduk. Sonraki yazıda tersini yapıyoruz: kullanıcının element'leri kendisinin taşımasını. CDK Drag and Drop ile "Okuma listem"i sıralanabilir yapıyor, kurumsal siparişin kalemlerini iki sevkiyat arasında taşıyor ve aynı işi sürüklemeden yapmanın yolunu ekliyoruz.