Avatar Upload

概要

プロフィール画像のアップロードフォームを実装するサンプル。FormValueControl<File | null> によるカスタムコントロールで非プリミティブ値(File オブジェクト)をフォームに統合し、resource() で非同期プレビュー生成を行う。

学習ポイント

  • FormValueControl<File | null> の実装(非プリミティブ値のフォーム統合)
  • validate() によるカスタムバリデーション(必須・ファイル形式・サイズ上限)
  • resource() による File → Data URL の非同期変換
  • カスタムコントロールの focus() メソッド実装

フォーム構造

フィールド バリデーション
avatar File | null 必須 / image/* のみ / 2MB 以下

実装の要点

カスタムコントロール(ImageUploadInput)

FormValueControl<File | null> インターフェースを実装し、model()value を公開する。[formField] ディレクティブがこの value を通じてフォームと双方向バインドする。

export class ImageUploadInput implements FormValueControl<File | null> {
  // FormValueControl の value: model() で双方向バインド
  readonly value = model<File | null>(null);

  // 隠し file input への参照
  private readonly fileInput = viewChild.required<ElementRef<HTMLInputElement>>('fileInput');

  // ファイル選択時にモデルを更新
  protected onFileChange(event: Event): void {
    const input = event.target as HTMLInputElement;
    const file = input.files?.[0] ?? null;
    this.value.set(file);
  }

  // focusBoundControl() から呼び出される focus メソッド
  // ファイル入力の場合、ファイル選択ダイアログを開く
  focus(): void {
    this.fileInput().nativeElement.click();
  }
}

resource() による非同期プレビュー生成

resource()paramsvalue シグナルを渡し、loader で FileReader による Data URL 変換を行う。value が変わるたびに自動的にプレビューが再生成される。

protected readonly previewUrl = resource({
  // value シグナルの変更を追跡
  params: () => this.value(),
  loader: async ({ params: file }) => {
    if (!file || !file.type.startsWith('image/')) {
      return null;
    }
    // FileReader で File → Data URL に非同期変換
    return new Promise<string>((resolve) => {
      const reader = new FileReader();
      reader.onload = () => resolve(reader.result as string);
      reader.readAsDataURL(file);
    });
  },
});

フォーム定義

File | null には required() が使えないため、validate() で null チェックする。ファイル形式とサイズのバリデーションも validate() で実装する。

readonly avatarForm = form(this.avatarModel, (schema) => {
  // 必須: null でないこと
  validate(schema.avatar, ({ value }) => {
    if (value() === null) {
      return { kind: 'required', message: 'Please select an image' };
    }
    return undefined;
  });

  // ファイル形式: image/* のみ
  validate(schema.avatar, ({ value }) => {
    const file = value();
    if (file && !file.type.startsWith('image/')) {
      return { kind: 'fileType', message: 'Please select an image file' };
    }
    return undefined;
  });

  // サイズ上限: 2MB
  validate(schema.avatar, ({ value }) => {
    const file = value();
    if (file && file.size > 2 * 1024 * 1024) {
      return { kind: 'fileSize', message: 'File size must be 2MB or less' };
    }
    return undefined;
  });
});

テンプレート

カスタムコントロールは [formField] ディレクティブで通常のフォーム要素と同様にバインドする。

<!-- FormValueControl を実装しているため [formField] で直接バインド可能 -->
<app-image-upload-input [formField]="avatarForm.avatar" />

<!-- エラー表示 -->
@if (avatarErrors().length > 0) {
  <ul>
    @for (message of avatarErrors(); track message) {
      <li>{{ message }}</li>
    }
  </ul>
}

送信処理

onSubmit(event: Event) {
  event.preventDefault();
  submit(this.avatarForm, async () => {
    const file = this.avatarModel().avatar;
    if (file) {
      this.submittedValue.set({ avatar: file });
    }
  });

  // 無効時にフォーカス → カスタムコントロールの focus() が呼ばれる
  if (this.avatarForm.avatar().invalid()) {
    this.avatarForm.avatar().focusBoundControl();
  }
}

コード