スキル一覧に戻る
dylantarre

angular

by dylantarre

Design system skills for agentic coding

3🍴 0📅 2026年1月6日
GitHubで見るManusで実行

SKILL.md


name: angular description: Builds token-driven Angular components with signals and standalone components. Use when creating Angular component libraries, integrating design tokens, or building design system components with new control flow syntax.

Angular Component Patterns

Overview

Build accessible, token-driven Angular components using modern patterns including signals, standalone components, and the new control flow syntax. Covers component architecture, TypeScript integration, services, and consuming design tokens.

When to Use

  • Creating an Angular component library
  • Building components that use design tokens
  • Setting up a design system in Angular
  • Converting designs to Angular components

The Process

  1. Identify component type: Primitive, composite, or layout?
  2. Choose styling approach: Component styles, CSS Modules, or Tailwind?
  3. Define inputs/outputs: Use signal-based inputs with transforms
  4. Implement with tokens: CSS custom properties via styles or host bindings
  5. Add accessibility: ARIA, keyboard handling, focus management
  6. Create services: Extract shared state and logic

Angular 17+ Features Quick Reference

FeaturePurposeExample
input()Signal-based inputvariant = input<'primary'>('primary')
output()Event emitterclicked = output<void>()
model()Two-way bindingvalue = model<string>('')
computed()Derived signalsisDisabled = computed(() => ...)
@if/@forControl flow@if (loading) { ... }
StandaloneNo NgModule neededstandalone: true

Project Structure

src/
├── lib/
│   ├── components/
│   │   ├── primitives/
│   │   │   ├── button/
│   │   │   │   ├── button.component.ts
│   │   │   │   ├── button.component.html
│   │   │   │   ├── button.component.css
│   │   │   │   ├── button.component.spec.ts
│   │   │   │   └── index.ts
│   │   │   ├── input/
│   │   │   └── text/
│   │   ├── composite/
│   │   │   ├── card/
│   │   │   ├── modal/
│   │   │   └── dropdown/
│   │   └── layout/
│   │       ├── stack/
│   │       ├── grid/
│   │       └── container/
│   ├── services/
│   │   └── theme.service.ts
│   ├── directives/
│   │   └── focus-trap.directive.ts
│   ├── tokens/
│   │   └── tokens.css
│   └── index.ts
└── public-api.ts

Component Patterns

Button Component

button.component.ts:

import {
  Component,
  computed,
  input,
  output,
  ChangeDetectionStrategy,
} from '@angular/core';
import { NgClass } from '@angular/common';

export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';

@Component({
  selector: 'ui-button',
  standalone: true,
  imports: [NgClass],
  templateUrl: './button.component.html',
  styleUrl: './button.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: {
    '[class.btn--full-width]': 'fullWidth()',
    '[attr.data-loading]': 'loading()',
  },
})
export class ButtonComponent {
  /** Visual style variant */
  variant = input<ButtonVariant>('primary');

  /** Size of the button */
  size = input<ButtonSize>('md');

  /** Full width button */
  fullWidth = input<boolean>(false);

  /** Loading state */
  loading = input<boolean>(false);

  /** Disabled state */
  disabled = input<boolean>(false);

  /** Button type */
  type = input<'button' | 'submit' | 'reset'>('button');

  /** Click event */
  clicked = output<MouseEvent>();

  /** Computed disabled state */
  isDisabled = computed(() => this.disabled() || this.loading());

  /** CSS classes */
  classes = computed(() => ({
    btn: true,
    [`btn--${this.variant()}`]: true,
    [`btn--${this.size()}`]: true,
    'btn--loading': this.loading(),
  }));

  handleClick(event: MouseEvent): void {
    if (!this.isDisabled()) {
      this.clicked.emit(event);
    }
  }
}

button.component.html:

<button
  [ngClass]="classes()"
  [type]="type()"
  [disabled]="isDisabled()"
  [attr.aria-busy]="loading()"
  (click)="handleClick($event)"
>
  @if (loading()) {
    <span class="btn__spinner" aria-hidden="true"></span>
  }

  <ng-content select="[leftIcon]" />

  <span class="btn__label">
    <ng-content />
  </span>

  <ng-content select="[rightIcon]" />
</button>

button.component.css:

:host {
  display: inline-block;
}

:host(.btn--full-width) {
  display: block;
  width: 100%;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--spacing-xs);
  width: 100%;
  font-family: inherit;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  transition:
    background-color 150ms ease,
    border-color 150ms ease,
    transform 100ms ease;
}

.btn:focus-visible {
  outline: 2px solid var(--color-primary-500);
  outline-offset: 2px;
}

.btn:active:not(:disabled) {
  transform: scale(0.98);
}

.btn:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

/* Variants */
.btn--primary {
  background-color: var(--color-primary-500);
  color: white;
}

.btn--primary:hover:not(:disabled) {
  background-color: var(--color-primary-600);
}

.btn--secondary {
  background-color: transparent;
  border-color: var(--color-gray-300);
  color: var(--color-gray-700);
}

.btn--secondary:hover:not(:disabled) {
  background-color: var(--color-gray-50);
  border-color: var(--color-gray-400);
}

.btn--ghost {
  background-color: transparent;
  color: var(--color-gray-700);
}

.btn--ghost:hover:not(:disabled) {
  background-color: var(--color-gray-100);
}

.btn--danger {
  background-color: var(--color-error-500);
  color: white;
}

.btn--danger:hover:not(:disabled) {
  background-color: var(--color-error-600);
}

/* Sizes */
.btn--sm {
  height: 32px;
  padding: 0 var(--spacing-sm);
  font-size: var(--text-sm);
}

.btn--md {
  height: 40px;
  padding: 0 var(--spacing-md);
  font-size: var(--text-base);
}

.btn--lg {
  height: 48px;
  padding: 0 var(--spacing-lg);
  font-size: var(--text-lg);
}

/* Loading */
.btn--loading .btn__label {
  opacity: 0;
}

.btn__spinner {
  position: absolute;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 600ms linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

Input Component

input.component.ts:

import {
  Component,
  computed,
  input,
  model,
  signal,
  output,
  forwardRef,
  ChangeDetectionStrategy,
  ElementRef,
  ViewChild,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

export type InputSize = 'sm' | 'md' | 'lg';

@Component({
  selector: 'ui-input',
  standalone: true,
  templateUrl: './input.component.html',
  styleUrl: './input.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => InputComponent),
      multi: true,
    },
  ],
  host: {
    '[class.input-container--full-width]': 'fullWidth()',
  },
})
export class InputComponent implements ControlValueAccessor {
  @ViewChild('inputRef') inputRef!: ElementRef<HTMLInputElement>;

  /** Bound value */
  value = model<string>('');

  /** Label text */
  label = input<string>('');

  /** Placeholder */
  placeholder = input<string>('');

  /** Helper text */
  helperText = input<string>('');

  /** Error message */
  error = input<string>('');

  /** Input type */
  type = input<'text' | 'email' | 'password' | 'number' | 'tel'>('text');

  /** Size variant */
  size = input<InputSize>('md');

  /** Disabled */
  disabled = input<boolean>(false);

  /** Required */
  required = input<boolean>(false);

  /** Full width */
  fullWidth = input<boolean>(false);

  /** Input ID */
  inputId = input<string>(`input-${crypto.randomUUID().slice(0, 8)}`);

  /** Blur event */
  blurred = output<FocusEvent>();

  /** Focus event */
  focused = output<FocusEvent>();

  /** CVA callbacks */
  private onChange: (value: string) => void = () => {};
  private onTouched: () => void = () => {};

  /** Computed IDs */
  helperId = computed(() => `${this.inputId()}-helper`);
  errorId = computed(() => `${this.inputId()}-error`);

  /** Wrapper classes */
  wrapperClasses = computed(() => ({
    'input-wrapper': true,
    [`input-wrapper--${this.size()}`]: true,
    'input-wrapper--error': !!this.error(),
    'input-wrapper--disabled': this.disabled(),
  }));

  /** Focus the input */
  focus(): void {
    this.inputRef?.nativeElement?.focus();
  }

  /** Handle input changes */
  onInput(event: Event): void {
    const target = event.target as HTMLInputElement;
    this.value.set(target.value);
    this.onChange(target.value);
  }

  /** Handle blur */
  onBlur(event: FocusEvent): void {
    this.onTouched();
    this.blurred.emit(event);
  }

  /** ControlValueAccessor */
  writeValue(value: string): void {
    this.value.set(value ?? '');
  }

  registerOnChange(fn: (value: string) => void): void {
    this.onChange = fn;
  }

  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    // Handle via input signal in template
  }
}

input.component.html:

<div class="input-container">
  @if (label()) {
    <label [for]="inputId()" class="input-label">
      {{ label() }}
      @if (required()) {
        <span class="input-required" aria-hidden="true">*</span>
      }
    </label>
  }

  <div [ngClass]="wrapperClasses()">
    <span class="input-adornment">
      <ng-content select="[startAdornment]" />
    </span>

    <input
      #inputRef
      [id]="inputId()"
      [type]="type()"
      [value]="value()"
      [placeholder]="placeholder()"
      [disabled]="disabled()"
      [required]="required()"
      [attr.aria-invalid]="!!error()"
      [attr.aria-describedby]="error() ? errorId() : helperText() ? helperId() : null"
      class="input"
      (input)="onInput($event)"
      (blur)="onBlur($event)"
      (focus)="focused.emit($event)"
    />

    <span class="input-adornment">
      <ng-content select="[endAdornment]" />
    </span>
  </div>

  @if (error()) {
    <span [id]="errorId()" class="input-error" role="alert">
      {{ error() }}
    </span>
  } @else if (helperText()) {
    <span [id]="helperId()" class="input-helper">
      {{ helperText() }}
    </span>
  }
</div>

Stack Layout Component

stack.component.ts:

import {
  Component,
  input,
  computed,
  ChangeDetectionStrategy,
} from '@angular/core';
import { NgStyle, NgClass } from '@angular/common';

export type SpacingToken = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl';
export type AlignOption = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
export type JustifyOption = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';

@Component({
  selector: 'ui-stack',
  standalone: true,
  imports: [NgStyle, NgClass],
  template: `
    <ng-content />
  `,
  styles: [`
    :host {
      display: flex;
      gap: var(--stack-gap, var(--spacing-md));
      align-items: var(--stack-align, stretch);
      justify-content: var(--stack-justify, flex-start);
    }

    :host(.stack--column) {
      flex-direction: column;
    }

    :host(.stack--row) {
      flex-direction: row;
    }

    :host(.stack--wrap) {
      flex-wrap: wrap;
    }

    :host(.stack--full-width) {
      width: 100%;
    }
  `],
  changeDetection: ChangeDetectionStrategy.OnPush,
  host: {
    '[class.stack--column]': "direction() === 'column'",
    '[class.stack--row]': "direction() === 'row'",
    '[class.stack--wrap]': 'wrap()',
    '[class.stack--full-width]': 'fullWidth()',
    '[style.--stack-gap]': "'var(--spacing-' + gap() + ')'",
    '[style.--stack-align]': 'alignValue()',
    '[style.--stack-justify]': 'justifyValue()',
  },
})
export class StackComponent {
  direction = input<'row' | 'column'>('column');
  gap = input<SpacingToken>('md');
  align = input<AlignOption>('stretch');
  justify = input<JustifyOption>('start');
  wrap = input<boolean>(false);
  fullWidth = input<boolean>(false);

  private alignMap: Record<AlignOption, string> = {
    start: 'flex-start',
    center: 'center',
    end: 'flex-end',
    stretch: 'stretch',
    baseline: 'baseline',
  };

  private justifyMap: Record<JustifyOption, string> = {
    start: 'flex-start',
    center: 'center',
    end: 'flex-end',
    between: 'space-between',
    around: 'space-around',
    evenly: 'space-evenly',
  };

  alignValue = computed(() => this.alignMap[this.align()]);
  justifyValue = computed(() => this.justifyMap[this.justify()]);
}

Services

Theme Service

theme.service.ts:

import { Injectable, signal, computed, effect } from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { inject } from '@angular/core';

export type Theme = 'light' | 'dark' | 'system';

@Injectable({ providedIn: 'root' })
export class ThemeService {
  private document = inject(DOCUMENT);

  /** Current theme setting */
  theme = signal<Theme>('system');

  /** Resolved theme (light or dark) */
  resolvedTheme = signal<'light' | 'dark'>('light');

  constructor() {
    // Load stored preference
    const stored = localStorage.getItem('theme') as Theme | null;
    if (stored) this.theme.set(stored);

    // Watch for system changes
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    mediaQuery.addEventListener('change', () => this.updateResolved());

    // React to theme changes
    effect(() => {
      this.updateResolved();
    });
  }

  setTheme(theme: Theme): void {
    this.theme.set(theme);
    localStorage.setItem('theme', theme);
  }

  private updateResolved(): void {
    let resolved: 'light' | 'dark';

    if (this.theme() === 'system') {
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      resolved = prefersDark ? 'dark' : 'light';
    } else {
      resolved = this.theme() as 'light' | 'dark';
    }

    this.resolvedTheme.set(resolved);
    this.document.documentElement.dataset['theme'] = resolved;
  }
}

Usage:

import { Component, inject } from '@angular/core';
import { ThemeService } from './theme.service';

@Component({
  selector: 'app-theme-toggle',
  template: `
    <button (click)="toggleTheme()">
      Current: {{ themeService.resolvedTheme() }}
    </button>
  `,
})
export class ThemeToggleComponent {
  themeService = inject(ThemeService);

  toggleTheme(): void {
    const next = this.themeService.resolvedTheme() === 'light' ? 'dark' : 'light';
    this.themeService.setTheme(next);
  }
}

Directives

Focus Trap Directive

focus-trap.directive.ts:

import {
  Directive,
  ElementRef,
  OnInit,
  OnDestroy,
  inject,
} from '@angular/core';

@Directive({
  selector: '[uiFocusTrap]',
  standalone: true,
})
export class FocusTrapDirective implements OnInit, OnDestroy {
  private el = inject(ElementRef<HTMLElement>);
  private focusableSelector =
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';

  ngOnInit(): void {
    this.el.nativeElement.addEventListener('keydown', this.handleKeydown);
    this.focusFirst();
  }

  ngOnDestroy(): void {
    this.el.nativeElement.removeEventListener('keydown', this.handleKeydown);
  }

  private handleKeydown = (event: KeyboardEvent): void => {
    if (event.key !== 'Tab') return;

    const focusable = this.el.nativeElement.querySelectorAll<HTMLElement>(
      this.focusableSelector
    );
    const first = focusable[0];
    const last = focusable[focusable.length - 1];

    if (event.shiftKey && document.activeElement === first) {
      event.preventDefault();
      last.focus();
    } else if (!event.shiftKey && document.activeElement === last) {
      event.preventDefault();
      first.focus();
    }
  };

  private focusFirst(): void {
    const first = this.el.nativeElement.querySelector<HTMLElement>(
      this.focusableSelector
    );
    first?.focus();
  }
}

Component Communication

Input transforms

import { booleanAttribute, numberAttribute } from '@angular/core';

@Component({...})
export class ExampleComponent {
  // Transform string "true"/"false" to boolean
  disabled = input(false, { transform: booleanAttribute });

  // Transform string to number
  count = input(0, { transform: numberAttribute });

  // Custom transform
  items = input<string[]>([], {
    transform: (value: string | string[]) =>
      typeof value === 'string' ? value.split(',') : value,
  });
}

Content projection with selectors

@Component({
  selector: 'ui-card',
  template: `
    <div class="card">
      <header class="card__header">
        <ng-content select="[cardHeader]" />
      </header>
      <div class="card__body">
        <ng-content />
      </div>
      <footer class="card__footer">
        <ng-content select="[cardFooter]" />
      </footer>
    </div>
  `,
})
export class CardComponent {}

// Usage:
// <ui-card>
//   <h2 cardHeader>Title</h2>
//   <p>Body content</p>
//   <button cardFooter>Action</button>
// </ui-card>

Library Export

public-api.ts:

// Components
export * from './lib/components/primitives/button';
export * from './lib/components/primitives/input';
export * from './lib/components/layout/stack';
export * from './lib/components/composite/card';

// Services
export * from './lib/services/theme.service';

// Directives
export * from './lib/directives/focus-trap.directive';

// Types
export type { ButtonVariant, ButtonSize } from './lib/components/primitives/button';
export type { InputSize } from './lib/components/primitives/input';

Testing

button.component.spec.ts:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ButtonComponent } from './button.component';
import { By } from '@angular/platform-browser';

describe('ButtonComponent', () => {
  let component: ButtonComponent;
  let fixture: ComponentFixture<ButtonComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [ButtonComponent],
    }).compileComponents();

    fixture = TestBed.createComponent(ButtonComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });

  it('should emit clicked event', () => {
    const spy = jest.spyOn(component.clicked, 'emit');
    const button = fixture.debugElement.query(By.css('button'));
    button.triggerEventHandler('click', new MouseEvent('click'));
    expect(spy).toHaveBeenCalled();
  });

  it('should be disabled when loading', () => {
    fixture.componentRef.setInput('loading', true);
    fixture.detectChanges();
    const button = fixture.debugElement.query(By.css('button'));
    expect(button.nativeElement.disabled).toBe(true);
  });

  it('should apply variant class', () => {
    fixture.componentRef.setInput('variant', 'danger');
    fixture.detectChanges();
    const button = fixture.debugElement.query(By.css('button'));
    expect(button.nativeElement.classList).toContain('btn--danger');
  });

  it('should have aria-busy when loading', () => {
    fixture.componentRef.setInput('loading', true);
    fixture.detectChanges();
    const button = fixture.debugElement.query(By.css('button'));
    expect(button.nativeElement.getAttribute('aria-busy')).toBe('true');
  });
});

スコア

総合スコア

60/100

リポジトリの品質指標に基づく評価

SKILL.md

SKILL.mdファイルが含まれている

+20
LICENSE

ライセンスが設定されている

+10
説明文

100文字以上の説明がある

0/10
人気

GitHub Stars 100以上

0/15
最近の活動

3ヶ月以内に更新がある

0/10
フォーク

10回以上フォークされている

0/5
Issue管理

オープンIssueが50未満

+5
言語

プログラミング言語が設定されている

+5
タグ

1つ以上のタグが設定されている

0/5

レビュー

💬

レビュー機能は近日公開予定です