Перейти к содержимому

37. Component Dev Kit (CDK)

CDK (Component Dev Kit) — это низкоуровневый набор инструментов от Angular команды. В отличие от Angular Material, CDK не навязывает дизайн — он даёт тебе примитивы для поведения: drag-and-drop, оверлеи, виртуальный скролл, управление фокусом и доступность. Строй что хочешь поверх него 🏗️


Окно терминала
npm install @angular/cdk

CDK уже включён в Angular Material, но можно использовать отдельно.


import { DragDropModule, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
@Component({
standalone: true,
imports: [DragDropModule],
template: `
<!-- Список с перетаскиванием -->
<div cdkDropList [cdkDropListData]="todo" (cdkDropListDropped)="drop($event)">
@for (item of todo; track item) {
<div cdkDrag class="drag-item">{{ item }}</div>
}
</div>
<!-- Перемещение между двумя списками -->
<div class="kanban">
<div
cdkDropList
#todoList="cdkDropList"
[cdkDropListData]="todoItems"
[cdkDropListConnectedTo]="[doneList]"
(cdkDropListDropped)="drop($event)"
>
@for (item of todoItems; track item.id) {
<div cdkDrag [cdkDragData]="item">{{ item.title }}</div>
}
</div>
<div
cdkDropList
#doneList="cdkDropList"
[cdkDropListData]="doneItems"
[cdkDropListConnectedTo]="[todoList]"
(cdkDropListDropped)="drop($event)"
>
@for (item of doneItems; track item.id) {
<div cdkDrag [cdkDragData]="item">{{ item.title }}</div>
}
</div>
</div>
`
})
export class KanbanComponent {
todoItems = [
{ id: 1, title: 'Настроить роутинг' },
{ id: 2, title: 'Написать тесты' },
{ id: 3, title: 'Задеплоить' },
];
doneItems: any[] = [];
drop(event: CdkDragDrop<any[]>) {
if (event.previousContainer === event.container) {
// Перемещение внутри одного списка
moveItemInArray(
event.container.data,
event.previousIndex,
event.currentIndex
);
} else {
// Перемещение между списками
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
}
}

import { Overlay, OverlayModule, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal, PortalModule } from '@angular/cdk/portal';
@Component({
standalone: true,
imports: [OverlayModule, PortalModule],
template: `
<button #origin (click)="openTooltip(origin)">Наведи/Кликни</button>
`
})
export class TooltipTriggerComponent {
private overlay = inject(Overlay);
private overlayRef: OverlayRef | null = null;
openTooltip(origin: HTMLElement) {
if (this.overlayRef) {
this.close();
return;
}
// Позиционируем относительно элемента
const positionStrategy = this.overlay
.position()
.flexibleConnectedTo(origin)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 8,
},
{
// Фоллбэк: если снизу нет места — показать сверху
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -8,
}
]);
this.overlayRef = this.overlay.create({
positionStrategy,
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
scrollStrategy: this.overlay.scrollStrategies.reposition(),
});
// Монтируем компонент
const portal = new ComponentPortal(TooltipContentComponent);
this.overlayRef.attach(portal);
// Закрываем по клику на бэкдроп
this.overlayRef.backdropClick().subscribe(() => this.close());
}
close() {
this.overlayRef?.dispose();
this.overlayRef = null;
}
}

Незаменим для рендера больших списков (10 000+ элементов):

import { ScrollingModule } from '@angular/cdk/scrolling';
@Component({
standalone: true,
imports: [ScrollingModule],
template: `
<cdk-virtual-scroll-viewport
itemSize="50"
class="scroll-viewport"
style="height: 400px"
>
@for (item of items; track item.id) {
<div *cdkVirtualFor="let item of items; trackBy: trackById"
class="list-item"
style="height: 50px">
{{ item.name }}
</div>
}
</cdk-virtual-scroll-viewport>
`
})
export class BigListComponent {
items = Array.from({ length: 10_000 }, (_, i) => ({
id: i,
name: \`Пользователь #\${i + 1}\`
}));
trackById = (i: number, item: { id: number }) => item.id;
}

import { TextFieldModule, CdkTextareaAutosize } from '@angular/cdk/text-field';
@Component({
standalone: true,
imports: [TextFieldModule],
template: `
<textarea
cdkTextareaAutosize
cdkAutosizeMinRows="2"
cdkAutosizeMaxRows="8"
placeholder="Введи текст — textarea растёт автоматически"
></textarea>
`
})
export class AutosizeTextareaComponent {
@ViewChild('autosize') autosize!: CdkTextareaAutosize;
// Принудительный ресайз
triggerResize() {
// Нужен NgZone для корректной работы
this.ngZone.onStable.pipe(take(1)).subscribe(() => {
this.autosize.resizeToFitContent(true);
});
}
}

import { A11yModule, FocusTrap, FocusTrapFactory } from '@angular/cdk/a11y';
@Component({
standalone: true,
imports: [A11yModule],
template: `
<!-- cdkTrapFocus блокирует фокус внутри элемента -->
<div cdkTrapFocus cdkTrapFocusAutoCapture>
<button>Первая кнопка (автофокус)</button>
<input placeholder="Поле" />
<button>Закрыть</button>
</div>
`
})
export class ModalComponent {}
import { LiveAnnouncer } from '@angular/cdk/a11y';
@Injectable({ providedIn: 'root' })
export class NotificationService {
private announcer = inject(LiveAnnouncer);
announce(message: string) {
// Скринридер прочитает это сообщение
this.announcer.announce(message, 'assertive');
}
}
// Использование
this.notificationService.announce('3 файла загружено');

import { Portal, ComponentPortal, TemplatePortal, PortalModule, CdkPortalOutlet } from '@angular/cdk/portal';
import { ComponentRef, TemplateRef, ViewContainerRef } from '@angular/core';
@Component({
standalone: true,
imports: [PortalModule],
template: `
<!-- Место для монтирования портала -->
<ng-template [cdkPortalOutlet]="currentPortal"></ng-template>
<button (click)="showComponent()">Показать компонент</button>
<button (click)="showTemplate(myTpl)">Показать шаблон</button>
<ng-template #myTpl>
<p>Это шаблон из портала 🎭</p>
</ng-template>
`
})
export class PortalDemoComponent {
currentPortal: Portal<any> | null = null;
private vcr = inject(ViewContainerRef);
showComponent() {
this.currentPortal = new ComponentPortal(SomeOtherComponent);
}
showTemplate(tpl: TemplateRef<any>) {
this.currentPortal = new TemplatePortal(tpl, this.vcr);
}
}

import { BreakpointObserver, Breakpoints } from '@angular/cdk/layout';
@Component({
standalone: true,
template: `
@if (isMobile()) {
<app-mobile-nav />
} @else {
<app-desktop-nav />
}
`
})
export class AppLayoutComponent {
private breakpoints = inject(BreakpointObserver);
isMobile = toSignal(
this.breakpoints
.observe([Breakpoints.Handset, Breakpoints.TabletPortrait])
.pipe(map(state => state.matches)),
{ initialValue: false }
);
}
// Кастомные breakpoints
this.breakpoints.observe(['(max-width: 768px)']).subscribe(state => {
if (state.matches) console.log('Мобильный вид');
});

import { ClipboardModule } from '@angular/cdk/clipboard';
@Component({
standalone: true,
imports: [ClipboardModule],
template: `
<button [cdkCopyToClipboard]="textToCopy" (cdkCopyToClipboardCopied)="onCopied($event)">
📋 Скопировать
</button>
`
})
export class CopyButtonComponent {
textToCopy = 'Скопированный текст';
onCopied(success: boolean) {
console.log(success ? 'Скопировано!' : 'Ошибка копирования');
}
}