Image Uploader

ImageUploaderは、画像ファイルをアップロードする際に使用されるコンポーネントです。
選択済みの画像プレビューを表示し、スロットで未選択時と選択後のUIを切り替えられます。

プロパティ

ImageUploaderは、以下のプロパティをサポートしています。

名前 デフォルト値 説明
disabled boolean false 入力を無効化します。操作できません。
isError boolean false エラー状態を視覚的に示します。バリデーション用など。
src string | null null 選択された画像のURLをバインドします。
readonly boolean false 表示専用の状態にします。操作できません。
clearable boolean false 選択された画像をクリアできるかどうかを指定します。true に設定すると、選択された画像をクリアできるようになります。

インストールの手順

以下のコンポーネントのコードを、使いたいプロジェクトにコピー&ペーストします。
パスは実際のプロジェクトの構成にあわせて更新してください。

atoms/ImageUploader.svelte
        <!--
@component
## 概要
- 画像ファイルを選択・プレビューするコンポーネントです

## 機能
- ユーザーが画像ファイルを端末から選択できます
- 選択された画像のプレビューを表示します
- スロットを用いて未選択時と選択後の表示を切り替えできます

## Props
- isError: true の場合エラー時のスタイルを適用します
- disabled: 指定するとグレーアウトされ、クリック不可になります
- src: 選択済み画像のURLをバインドできます
- readonly: 画像の選択はできませんがプレビューは表示されます
- clearable: true の場合、選択済み画像をクリアするボタンが表示されます

## Usage
```svelte
<ImageUploader bind:src>
  {#if src}
    <img src={src} alt="preview" class="w-32 h-32 rounded-md object-cover" />
  {:else}
    <span>画像を選択</span>
  {/if}
</ImageUploader>
```
-->

<script module lang="ts">
  import type { Snippet } from 'svelte';
  import type { ClassValue, HTMLInputAttributes } from 'svelte/elements';
  import { cva, type VariantProps } from 'class-variance-authority';

  export const imageUploaderVariants = cva('relative flex items-center justify-center min-h-4 text-base-foreground-muted text-sm group overflow-hidden outline-primary transition-colors cursor-pointer', {
    variants: {
      /** 操作できるかどうか */
      disabled: {
        true: ['opacity-50 pointer-events-none'],
        false: ['active:bg-base-container-accent hover:bg-base-container-accent/90'],
      },
      /** エラーかどうか */
      isError: {
        true: ['border-destructive'],
        false: [],
      },
      /** 画像が選択されているかどうか */
      filled: {
        true: ['!border-none text-base-foreground-default'],
        false: [],
      },
      /** 読み取り専用かどうか */
      readonly: {
        true: ['pointer-events-none'],
        false: [],
      },
      /** 編集可能かどうか */
      isEditable: {
        true: ['hover:bg-base-foreground-muted/50'],
        false: [],
      },
    },
    defaultVariants: {
      disabled: false,
      isError: false,
      filled: false,
      readonly: false,
      isEditable: false,
    },
  });

  export type ImageUploaderVariants = VariantProps<typeof imageUploaderVariants>;

  export interface ImageUploaderProps extends ImageUploaderVariants, HTMLInputAttributes {
    /** クラス */
    class?: ClassValue;
    /** 選択された画像のURL */
    src: string | null;
    /** 選択したファイル */
    files?: FileList | null;
    /** エラー状態のスタイルを適用するか */
    isError?: boolean;
    /** 読み取り専用モード */
    readonly?: boolean;
    /** 値をリセットできるか */
    clearable?: boolean;
    /** 画像が変更されたときのコールバック */
    onChangeImage?: (src: string | null) => void;
    children?: Snippet<[]>;
  }
</script>

<script lang="ts">
  import { ImagePlus, Pen, X } from '@lucide/svelte';

  let { isError = false, readonly = false, class: className, src = $bindable(null), files = $bindable(null), onChangeImage, clearable = false, children, ...inputAttributes }: ImageUploaderProps = $props();

  /**
   * 画像をクリアする関数。srcとfilesをnullにリセットし、onChangeコールバックを呼び出します。
   */
  export function clear() {
    src = null;
    files = null;
    onChangeImage?.(null);
  }

  let hasSrc = $derived(!!src);
  let isNotEditable = readonly || inputAttributes.disabled;
  let isEditable = $derived(hasSrc && !isNotEditable);
  let fileInput: HTMLInputElement;

  let imageUploaderVariantClass = $derived(imageUploaderVariants({ disabled: inputAttributes.disabled, isError, readonly, filled: !!src, class: className, isEditable }));

  let isDragging = $state(false);

  function onChange(e: Event) {
    const input = e.target;
    if (!(input instanceof HTMLInputElement)) return;
    onFiles(input.files);
  }

  // ファイルリストから最初のファイルを取得し、URLを生成してsrcに設定
  function onFiles(fileList: FileList | null) {
    const file = fileList?.[0];
    src = file ? URL.createObjectURL(file) : null;
    onChangeImage?.(src);
  }

  // ドラッグ&ドロップイベントのハンドラ
  function onDrop(e: DragEvent) {
    e.preventDefault();
    isDragging = false;
    if (readonly || inputAttributes.disabled) return;
    onFiles(e.dataTransfer?.files ?? null);
  }

  // ドラッグオーバーイベントのハンドラ
  function onDragOver(e: DragEvent) {
    e.preventDefault();
    if (!readonly && !inputAttributes.disabled) isDragging = true;
  }

  // ドラッグリーブイベントのハンドラ
  function onDragLeave(e: DragEvent) {
    e.preventDefault();
    isDragging = false;
  }

  // クリアボタン押下時の処理
  function onClear(e) {
    e.stopPropagation();

    clear();
  }
</script>

<div class="relative inline-block">
  <button class={[imageUploaderVariantClass, !children && 'rounded-full']} type="button" onclick={() => fileInput?.click()} ondrop={onDrop} ondragover={onDragOver} ondragleave={onDragLeave} disabled={inputAttributes.disabled}>
    <input class="sr-only cursor-pointer" tabindex="-1" aria-hidden="true" type="file" accept="image/*" bind:this={fileInput} bind:files onchange={onChange} {...inputAttributes} />
    <div class="relative grid place-content-center">
      {#if isEditable}
        <div class="absolute inset-0 flex items-center justify-center size-full bg-alpha-gloom/10 transition-colors duration-200 group-hover:bg-alpha-gloom/20">
          <Pen class="text-base-foreground-on-fill-bright" />
        </div>
      {/if}
      {#if children}
        {@render children?.()}
      {:else if src}
        <img class="size-20 rounded-full object-cover" {src} alt="preview" />
      {:else}
        <div class={['flex items-center justify-center size-20 border border-dashed rounded-full', isError ? 'border-destructive' : 'border-base-stroke-default']}>
          <ImagePlus class="object-contain text-base-foreground-subtle" size="1.5rem" />
        </div>
      {/if}
    </div>
  </button>

  <!-- 画像が選択されていて、クリア可能で、読み取り専用でなく、入力が無効でない場合にクリアボタンを表示 -->
  {#if src && clearable && !readonly && !inputAttributes.disabled}
    <button class="absolute top-0.5 right-0.5 flex items-center justify-center size-5.5 bg-base-container-default border border-base-stroke-default rounded-full cursor-pointer" onclick={onClear} aria-label="画像をクリア">
      <X size="0.875rem" />
    </button>
  {/if}
</div>

      

使い方


サンプル

Default

特に操作が行われていない、デフォルトの状態です。

Filled

画像が選択され、プレビューが表示されている状態です。

Error

入力内容に問題があり、エラーが表示されている状態です。

Disabled

操作不可の状態です。

Readonly

写真の選択ができない、表示のみの状態です。

Clearable

clearableプロパティをtrueに設定すると、選択済み画像をクリアするボタンが表示されます。

Slot

任意の見た目やレイアウトをスロットで上書きできます。