Image Uploader
ImageUploaderは、画像ファイルをアップロードする際に使用されるコンポーネントです。
選択済みの画像プレビューを表示し、スロットで未選択時と選択後のUIを切り替えられます。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>(null);
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<ImageUploader {src} {onChangeImage}></ImageUploader>
プロパティ
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>
使い方
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>(null);
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<ImageUploader {src} {onChangeImage}></ImageUploader>
サンプル
Default
特に操作が行われていない、デフォルトの状態です。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>(null);
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<ImageUploader {src} {onChangeImage}></ImageUploader>
Filled
画像が選択され、プレビューが表示されている状態です。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>('/images/sample.png');
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<div class="size-20">
<ImageUploader {onChangeImage} {src}></ImageUploader>
</div>
Error
入力内容に問題があり、エラーが表示されている状態です。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>(null);
let isError = $derived(!src);
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<div class="flex flex-col items-center">
<ImageUploader {src} {isError} {onChangeImage}></ImageUploader>
{#if isError}
<p class="text-destructive text-sm mt-2">ここにエラーメッセージが入ります。</p>
{/if}
</div>
Disabled
操作不可の状態です。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>('/images/sample.png');
let noImage = $state<string | null>(null);
</script>
<div class="flex items-center justify-center w-full gap-10">
<div class="size-20">
<ImageUploader src={noImage} disabled></ImageUploader>
</div>
<div class="size-20">
<ImageUploader {src} disabled></ImageUploader>
</div>
</div>
Readonly
写真の選択ができない、表示のみの状態です。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
import { UserRound } from '@lucide/svelte';
let images = ['', '/images/sample.png'];
</script>
<div class="flex items-center justify-center w-full gap-10">
{#each images as src}
<div class="size-20">
<ImageUploader class="rounded-full" {src} readonly>
{#if !src}
<div class="size-20 flex items-center justify-center rounded-full border border-base-stroke-default">
<UserRound class="text-base-foreground-subtle object-contain" size="1.5rem" />
</div>
{:else}
<img class="size-20 object-cover rounded-full" {src} alt="preview" />
{/if}
</ImageUploader>
</div>
{/each}
</div>
Clearable
clearableプロパティをtrueに設定すると、選択済み画像をクリアするボタンが表示されます。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
let src = $state<string | null>('/images/sample.png');
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<div class="size-20">
<ImageUploader {onChangeImage} {src} clearable></ImageUploader>
</div>
Slot
任意の見た目やレイアウトをスロットで上書きできます。
<script lang="ts">
import ImageUploader from '$lib/components/ui/atoms/ImageUploader.svelte';
import { ImagePlus } from '@lucide/svelte';
let src = $state<string | null>(null);
function onChangeImage(newSrc: string | null) {
src = newSrc;
}
</script>
<ImageUploader {src} {onChangeImage}>
{#if src}
<img class="w-100 h-20 object-cover" {src} alt="preview" />
{:else}
<div class="flex items-center justify-center w-100 h-20 border border-base-stroke-default border-dashed">
<ImagePlus class="object-contain text-base-foreground-subtle" size="1.5rem" />
</div>
{/if}
</ImageUploader>