SKILL.md
唯讀
名稱
angular-directives
描述
在 Angular v20+ 中建立自訂指令,用於 DOM 操作與行為擴充。適用於屬性指令(修改元素行為/外觀)、結構指令(入口/覆蓋層)以及宿主指令(組合行為)。觸發時機:建立可重複使用的 DOM 行為、擴充元素功能、或跨元件組合行為。注意:控制流程請使用原生的 @if/@for/@switch,不要自訂結構指令。
Angular 指令
建立自訂指令,在 Angular v20+ 中進行可重複使用的 DOM 操作與行為控制。
屬性指令
修改元素的外觀或行為:
import { Directive, input, effect, inject, ElementRef } from '@angular/core';
@Directive({
selector: '[appHighlight]',
})
export class Highlight {
private el = inject(ElementRef<HTMLElement>);
// 使用別名與選擇器相符的輸入
color = input('yellow', { alias: 'appHighlight' });
constructor() {
effect(() => {
this.el.nativeElement.style.backgroundColor = this.color();
});
}
}
// 使用方式:<p appHighlight="lightblue">高亮文字</p>
// 使用方式:<p appHighlight>預設黃色高亮</p>
使用 host 屬性
優先使用 host 而非 @HostBinding/@HostListener:
@Directive({
selector: '[appTooltip]',
host: {
'(mouseenter)': 'show()',
'(mouseleave)': 'hide()',
'[attr.aria-describedby]': 'tooltipId',
},
})
export class Tooltip {
text = input.required<string>({ alias: 'appTooltip' });
position = input<'top' | 'bottom' | 'left' | 'right'>('top');
tooltipId = `tooltip-${crypto.randomUUID()}`;
private tooltipEl: HTMLElement | null = null;
private el = inject(ElementRef<HTMLElement>);
show() {
this.tooltipEl = document.createElement('div');
this.tooltipEl.id = this.tooltipId;
this.tooltipEl.className = `tooltip tooltip-${this.position()}`;
this.tooltipEl.textContent = this.text();
this.tooltipEl.setAttribute('role', 'tooltip');
document.body.appendChild(this.tooltipEl);
this.positionTooltip();
}
hide() {
this.tooltipEl?.remove();
this.tooltipEl = null;
}
private positionTooltip() {
// 根據 this.position() 和 this.el 進行定位
}
}
// 使用方式:<button appTooltip="點擊儲存" position="bottom">儲存</button>
類別與樣式操作
@Directive({
selector: '[appButton]',
host: {
'class': 'btn',
'[class.btn-primary]': 'variant() === "primary"',
'[class.btn-secondary]': 'variant() === "secondary"',
'[class.btn-sm]': 'size() === "small"',
'[class.btn-lg]': 'size() === "large"',
'[class.disabled]': 'disabled()',
'[attr.disabled]': 'disabled() || null',
},
})
export class Button {
variant = input<'primary' | 'secondary'>('primary');
size = input<'small' | 'medium' | 'large'>('medium');
disabled = input(false, { transform: booleanAttribute });
}
// 使用方式:<button appButton variant="primary" size="large">點擊</button>
事件處理
@Directive({
selector: '[appClickOutside]',
host: {
'(document:click)': 'onDocumentClick($event)',
},
})
export class ClickOutside {
private el = inject(ElementRef<HTMLElement>);
clickOutside = output<void>();
onDocumentClick(event: MouseEvent) {
if (!this.el.nativeElement.contains(event.target as Node)) {
this.clickOutside.emit();
}
}
}
// 使用方式:<div appClickOutside (clickOutside)="closeMenu()">...</div>
鍵盤快捷鍵
@Directive({
selector: '[appShortcut]',
host: {
'(document:keydown)': 'onKeydown($event)',
},
})
export class Shortcut {
key = input.required<string>({ alias: 'appShortcut' });
ctrl = input(false, { transform: booleanAttribute });
shift = input(false, { transform: booleanAttribute });
alt = input(false, { transform: booleanAttribute });
triggered = output<KeyboardEvent>();
onKeydown(event: KeyboardEvent) {
const keyMatch = event.key.toLowerCase() === this.key().toLowerCase();
const ctrlMatch = this.ctrl() ? event.ctrlKey || event.metaKey : !event.ctrlKey && !event.metaKey;
const shiftMatch = this.shift() ? event.shiftKey : !event.shiftKey;
const altMatch = this.alt() ? event.altKey : !event.altKey;
if (keyMatch && ctrlMatch && shiftMatch && altMatch) {
event.preventDefault();
this.triggered.emit(event);
}
}
}
// 使用方式:<button appShortcut="s" ctrl (triggered)="save()">儲存 (Ctrl+S)</button>
結構指令
使用結構指令進行控制流程以外的 DOM 操作(入口、覆蓋層、動態插入點)。條件與迴圈請使用原生 @if、@for、@switch。
入口指令
將內容渲染到不同的 DOM 位置:
import { Directive, inject, TemplateRef, ViewContainerRef, OnInit, OnDestroy, input } from '@angular/core';
@Directive({
selector: '[appPortal]',
})
export class Portal implements OnInit, OnDestroy {
private templateRef = inject(TemplateRef<any>);
private viewContainerRef = inject(ViewContainerRef);
private viewRef: EmbeddedViewRef<any> | null = null;
// 目標容器選擇器或元素
target = input<string | HTMLElement>('body', { alias: 'appPortal' });
ngOnInit() {
const container = this.getContainer();
if (container) {
this.viewRef = this.viewContainerRef.createEmbeddedView(this.templateRef);
this.viewRef.rootNodes.forEach(node => container.appendChild(node));
}
}
ngOnDestroy() {
this.viewRef?.destroy();
}
private getContainer(): HTMLElement | null {
const target = this.target();
if (typeof target === 'string') {
return document.querySelector(target);
}
return target;
}
}
// 使用方式:在 body 層級渲染 modal
// <div *appPortal="'body'">
// <div class="modal">Modal 內容</div>
// </div>
延遲渲染指令
延遲渲染直到條件滿足(僅一次):
@Directive({
selector: '[appLazyRender]',
})
export class LazyRender {
private templateRef = inject(TemplateRef<any>);
private viewContainer = inject(ViewContainerRef);
private rendered = false;
condition = input.required<boolean>({ alias: 'appLazyRender' });
constructor() {
effect(() => {
// 只在條件變為 true 時渲染一次
if (this.condition() && !this.rendered) {
this.viewContainer.createEmbeddedView(this.templateRef);
this.rendered = true;
}
});
}
}
// 使用方式:只在分頁首次啟用時渲染重量級元件
// <div *appLazyRender="activeTab() === 'reports'">
// <app-heavy-reports />
// </div>
帶上下文的模板出口
interface TemplateContext<T> {
$implicit: T;
item: T;
index: number;
}
@Directive({
selector: '[appTemplateOutlet]',
})
export class TemplateOutlet<T> {
private viewContainer = inject(ViewContainerRef);
private currentView: EmbeddedViewRef<TemplateContext<T>> | null = null;
template = input.required<TemplateRef<TemplateContext<T>>>({ alias: 'appTemplateOutlet' });
context = input.required<T>({ alias: 'appTemplateOutletContext' });
index = input(0, { alias: 'appTemplateOutletIndex' });
constructor() {
effect(() => {
const template = this.template();
const context = this.context();
const index = this.index();
if (this.currentView) {
this.currentView.context.$implicit = context;
this.currentView.context.item = context;
this.currentView.context.index = index;
this.currentView.markForCheck();
} else {
this.currentView = this.viewContainer.createEmbeddedView(template, {
$implicit: context,
item: context,
index,
});
}
});
}
}
// 使用方式:自訂列表與模板
// <ng-template #itemTemplate let-item let-i="index">
// <div>{{ i }}: {{ item.name }}</div>
// </ng-template>
// <ng-container
// *appTemplateOutlet="itemTemplate; context: item; index: i"
// />
宿主指令
在元件或其他指令上組合指令:
// 可重複使用的行為指令
@Directive({
selector: '[focusable]',
host: {
'tabindex': '0',
'(focus)': 'onFocus()',
'(blur)': 'onBlur()',
'[class.focused]': 'isFocused()',
},
})
export class Focusable {
isFocused = signal(false);
onFocus() { this.isFocused.set(true); }
onBlur() { this.isFocused.set(false); }
}
@Directive({
selector: '[disableable]',
host: {
'[class.disabled]': 'disabled()',
'[attr.aria-disabled]': 'disabled()',
},
})
export class Disableable {
disabled = input(false, { transform: booleanAttribute });
}
// 使用宿主指令的元件
@Component({
selector: 'app-custom-button',
hostDirectives: [
Focusable,
{
directive: Disableable,
inputs: ['disabled'],
},
],
host: {
'role': 'button',
'(click)': 'onClick($event)',
'(keydown.enter)': 'onClick($event)',
'(keydown.space)': 'onClick($event)',
},
template: `<ng-content />`,
})
export class CustomButton {
private disableable = inject(Disableable);
clicked = output<void>();
onClick(event: Event) {
if (!this.disableable.disabled()) {
this.clicked.emit();
}
}
}
// 使用方式:<app-custom-button disabled>點擊我</app-custom-button>
暴露宿主指令的輸出
@Directive({
selector: '[hoverable]',
host: {
'(mouseenter)': 'onEnter()',
'(mouseleave)': 'onLeave()',
'[class.hovered]': 'isHovered()',
},
})
export class Hoverable {
isHovered = signal(false);
hoverChange = output<boolean>();
onEnter() {
this.isHovered.set(true);
this.hoverChange.emit(true);
}
onLeave() {
this.isHovered.set(false);
this.hoverChange.emit(false);
}
}
@Component({
selector: 'app-card',
hostDirectives: [
{
directive: Hoverable,
outputs: ['hoverChange'],
},
],
template: `<ng-content />`,
})
export class Card {}
// 使用方式:<app-card (hoverChange)="onHover($event)">...</app-card>
指令組合 API
結合多個行為:
// 基礎指令
@Directive({ selector: '[withRipple]' })
export class Ripple {
// 漣漪效果實作
}
@Directive({ selector: '[withElevation]' })
export class Elevation {
elevation = input(2);
}
// 組合後的元件
@Component({
selector: 'app-material-button',
hostDirectives: [
Ripple,
{
directive: Elevation,
inputs: ['elevation'],
},
{
directive: Disableable,
inputs: ['disabled'],
},
],
template: `<ng-content />`,
})
export class MaterialButton {}
進階模式請參閱 references/directive-patterns.md。






