ファイルドロップゾーン
stimeo--file-dropzone
クリック、キーボード、ドラッグの 3 通りでファイルを選べます。画像は先に見られます。
ふつうのファイル選択と、ドラッグで落とせる領域をつなぎます。ボタンからは標準のファイル選択が開くので、キーボードだけでも選べます。領域にファイルを重ねると、その状態が言葉でも伝わるので、色の変化だけに頼りません。選ばれたファイルは、種類、大きさ、重複、枚数の順に見て、引っかかった理由をひとつだけ知らせます。受け入れたファイルはテンプレートから並ぶので、見た目は自由です。画像はその場で確認できます。選んだ結果は元のファイル選択へ書き戻すので、ふつうにフォームを送信すればそのまま送れます。追加、拒否、削除は読み上げにも届きます。削除したときはフォーカスを隣へ渡すので、キーボードの居場所を見失いません。
キーボード操作
| キー | 動作 |
|---|---|
| Enter / Space | トリガーボタンからネイティブのファイル選択ダイアログを開く。 |
<%# Markup for the file-dropzone (file drag & drop / image preview) demo.
Click or keyboard launches the native file input, and drag & drop also adds files.
It validates accept / maxSize / duplicates / count and shows image thumbnails via
objectURL. The library handles the drop bridge, validation, preview create/revoke,
focus handoff on removal, mirroring the accepted files back onto the native input,
and the announcements read by the shared stimeo--announcer your app seats once,
in its layout (additions, rejections, removals, and the drag affordance are the
transitions worth reading; the dropzone owns no live region of its own).
Drag & drop is only an aid — selection is always possible via click / keyboard. %>
<div class="file-dropzone" data-controller="stimeo--file-dropzone"
data-stimeo--file-dropzone-max-size-value="5242880"
data-stimeo--file-dropzone-max-files-value="4"
data-stimeo--file-dropzone-announce-drag-text-value="<%= t(
'components.file_dropzone.demo.drag_label'
) %>"
data-stimeo--file-dropzone-announce-added-text-value="<%= t(
'components.file_dropzone.demo.announce_added'
) %>"
data-stimeo--file-dropzone-announce-removed-text-value="<%= t(
'components.file_dropzone.demo.announce_removed'
) %>"
data-stimeo--file-dropzone-announce-rejected-type-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_type'
) %>"
data-stimeo--file-dropzone-announce-rejected-size-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_size'
) %>"
data-stimeo--file-dropzone-announce-rejected-duplicate-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_duplicate'
) %>"
data-stimeo--file-dropzone-announce-rejected-count-text-value="<%= t(
'components.file_dropzone.demo.announce_reject_count'
) %>">
<div
class="file-dropzone__zone"
data-stimeo--file-dropzone-target="zone"
data-action="dragover->stimeo--file-dropzone#onDragOver
dragleave->stimeo--file-dropzone#onDragLeave
drop->stimeo--file-dropzone#onDrop">
<button
type="button"
class="demo-trigger file-dropzone__trigger"
data-stimeo--file-dropzone-target="trigger"
data-action="click->stimeo--file-dropzone#openDialog">
<%= t("components.file_dropzone.demo.trigger") %>
</button>
<input
type="file"
accept="image/*"
multiple
aria-label="<%= t('components.file_dropzone.demo.input_label') %>"
class="file-dropzone__input visually-hidden"
data-stimeo--file-dropzone-target="input"
data-action="change->stimeo--file-dropzone#onChange" />
</div>
<ul
class="file-dropzone__list"
aria-label="<%= t('components.file_dropzone.demo.list_label') %>"
data-stimeo--file-dropzone-target="list"></ul>
<%# Demo-only: the library accepts only unique images within the limits (5 MB / 4 files)
and fires stimeo--file-dropzone:reject for the rest — accepted images get a thumbnail
preview. The reason is already spoken by the shared announcer; this repeats it on
screen so a rejected file (e.g. an over-size photo) is not mistaken for a broken
preview. demo.js fills this from the reject event; the reason copy is localized here. %>
<p class="file-dropzone__error"
data-file-dropzone-error
hidden
data-reason-type="<%= t('components.file_dropzone.demo.reject_type') %>"
data-reason-size="<%= t('components.file_dropzone.demo.reject_size') %>"
data-reason-duplicate="<%= t('components.file_dropzone.demo.reject_duplicate') %>"
data-reason-count="<%= t('components.file_dropzone.demo.reject_count') %>"></p>
<template data-stimeo--file-dropzone-target="itemTemplate">
<li class="file-dropzone__item" data-stimeo--file-dropzone-target="item">
<img class="file-dropzone__thumb" data-stimeo--file-dropzone-target="thumb" alt="" hidden />
<span class="file-dropzone__name" data-stimeo--file-dropzone-target="name"></span>
<%# Removal is handled by a delegated listener on the list container, so it
works instantly without waiting on Stimulus wiring a data-action onto the
dynamically-added button. The library keeps this aria-label and only
expands {name}, so the accessible name stays in the page's language. %>
<button
type="button"
class="file-dropzone__remove"
aria-label="<%= t('components.file_dropzone.demo.remove_label') %>"
data-stimeo--file-dropzone-target="remove">
<%= t("components.file_dropzone.demo.remove") %>
</button>
</li>
</template>
</div>
/*
* Presentation-only styles for the file-dropzone demo.
* The library toggles the zone's data-dragover for drag state and
* data-stimeo--file-dropzone-invalid for a rejected validation (both are also
* spoken through the shared announcer, never signalled via color alone).
* Creating/revoking previews and moving focus on removal are also the library's
* responsibility.
*/
.file-dropzone {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 30rem;
}
.file-dropzone__zone {
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
border: 2px dashed var(--border-strong);
border-radius: 0.625rem;
background: var(--surface-subtle);
text-align: center;
}
.file-dropzone__zone[data-dragover] {
border-color: var(--accent, var(--color-primary));
background: var(--color-primary-soft);
}
.file-dropzone__zone[data-stimeo--file-dropzone-invalid] {
border-color: var(--danger-500);
background: var(--danger-50);
}
/* Weightier than a plain launcher: it is the zone's own call to action. */
.file-dropzone__trigger {
font-weight: 600;
}
.file-dropzone__list {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin: 0;
padding: 0;
list-style: none;
}
.file-dropzone__item {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.6rem;
border: 1px solid var(--border-default);
border-radius: 0.375rem;
}
.file-dropzone__thumb {
width: 2.5rem;
height: 2.5rem;
object-fit: cover;
border-radius: 0.25rem;
}
.file-dropzone__name {
flex: 1;
font-size: 0.875rem;
color: var(--fg, var(--color-text));
word-break: break-all;
}
.file-dropzone__remove {
border: 0;
background: transparent;
color: var(--danger-500);
font: inherit;
cursor: pointer;
}
.file-dropzone__remove:focus-visible {
outline: 2px solid var(--accent, var(--color-primary));
outline-offset: 2px;
border-radius: 0.25rem;
}
/* Visible reason for a rejected file (filled by demo.js from the reject event). */
.file-dropzone__error {
margin: 0;
font-size: 0.875rem;
color: var(--danger-500);
}
// Demo: the library validates every file against accept / maxSize / duplicates / maxFiles
// and fires stimeo--file-dropzone:reject for the ones it turns away — accepted images get a
// thumbnail preview. The reason is already spoken through the shared announcer (the
// announce-rejected-*-text values on the root); this repeats it on screen so a rejected file
// (e.g. an image over the 5 MB limit) is not mistaken for a broken preview. The reason copy
// is localized in the view; reason is one of type / size / duplicate / count and is read
// straight off data-reason-<reason> (no dataset camelCase round-trip). A drop that
// mixes accepted and rejected files reports the accepted set first, so clearing on
// change never wipes the notice raised by that same drop.
document.querySelectorAll('[data-controller~="stimeo--file-dropzone"]').forEach((root) => {
const error = root.querySelector("[data-file-dropzone-error]");
if (!error) return;
root.addEventListener("stimeo--file-dropzone:reject", (event) => {
const { file, reason } = event.detail;
const detail = error.getAttribute(`data-reason-${reason}`);
if (!detail) return;
error.textContent = `${file.name}: ${detail}`;
error.hidden = false;
});
const clear = () => {
error.hidden = true;
error.textContent = "";
};
// A successful add clears the last rejection notice. So does the cache rewind:
// it reports the selection it discards as reconcile, and a notice about a file
// that is no longer listed must not ride into the Turbo snapshot.
root.addEventListener("stimeo--file-dropzone:change", clear);
root.addEventListener("stimeo--file-dropzone:reconcile", clear);
});
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--file-dropzone"
ターゲット
| 名前 | 説明 | 属性 |
|---|---|---|
zone
|
ドロップ対象領域。ドラッグ中は data-dragover、拒否時は data-…-invalid が付く。 |
data-stimeo--file-dropzone-target="zone" |
trigger
必須
|
ネイティブのファイルダイアログを開くボタン。削除後のフォーカス戻り先にもなる。 | data-stimeo--file-dropzone-target="trigger" |
input
必須
|
ネイティブの <input type=file>。キーボード操作可能な主要アップロード経路で、受理集合が書き戻される。 |
data-stimeo--file-dropzone-target="input" |
list
|
受理済みファイルのプレビュー項目を描画するコンテナ。 | data-stimeo--file-dropzone-target="list" |
item
|
受理された 1 ファイルのプレビュー項目(テンプレート内に必須)。 | data-stimeo--file-dropzone-target="item" |
itemTemplate
|
各ファイル項目を生成する複製元の <template>。 |
data-stimeo--file-dropzone-target="itemTemplate" |
name
|
ファイル名を書き込む要素(テンプレート内に必須)。 | data-stimeo--file-dropzone-target="name" |
thumb
|
画像サムネイルの <img>(任意。画像以外では hidden が付く)。 |
data-stimeo--file-dropzone-target="thumb" |
remove
|
その項目を削除する <button>(テンプレート内に必須。空でない aria-label が要る)。 |
data-stimeo--file-dropzone-target="remove" |
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
maxSize
|
許可する最大ファイルサイズ(バイト、0 = 無制限、既定 0)。 | data-stimeo--file-dropzone-max-size-value |
maxFiles
|
最大ファイル数(0 = input の multiple/単一の規則に従う、既定 0)。 | data-stimeo--file-dropzone-max-files-value |
allowDuplicates
|
true で同一ファイル(名前・サイズ・更新日時が一致)の重複追加を許す(既定 false)。 | data-stimeo--file-dropzone-allow-duplicates-value |
announceDragText
|
ドラッグがゾーンへ入ったとき 1 回だけ共有 Announcer へ送る文言。{total} を展開(既定は空=通知しない)。 |
data-stimeo--file-dropzone-announce-drag-text-value |
announceAddedText
|
受理時に送る文言。{name} / {count} / {total} を展開(既定は空=通知しない)。 |
data-stimeo--file-dropzone-announce-added-text-value |
announceRemovedText
|
削除時に送る文言。{name} / {total} を展開(既定は空=通知しない)。 |
data-stimeo--file-dropzone-announce-removed-text-value |
announceRejectedTypeText
|
accept 不一致で拒否したときの文言。{name} / {count} / {total} を展開。 |
data-stimeo--file-dropzone-announce-rejected-type-text-value |
announceRejectedSizeText
|
サイズ超過で拒否したときの文言。同じプレースホルダを展開。 | data-stimeo--file-dropzone-announce-rejected-size-text-value |
announceRejectedDuplicateText
|
重複で拒否したときの文言。同じプレースホルダを展開。 | data-stimeo--file-dropzone-announce-rejected-duplicate-text-value |
announceRejectedCountText
|
枚数超過で拒否したときの文言。同じプレースホルダを展開。 | data-stimeo--file-dropzone-announce-rejected-count-text-value |
アクション
| 名前 | 説明 | アクション |
|---|---|---|
onChange
|
ネイティブダイアログで選ばれたファイルを追加する。 | stimeo--file-dropzone#onChange |
onDragLeave
|
ポインタがゾーンの外へ出たら drag-over フラグを解除する。 | stimeo--file-dropzone#onDragLeave |
onDragOver
|
ゾーンをドロップ対象として示し、ドラッグ開始を 1 回だけ読み上げる。 | stimeo--file-dropzone#onDragOver |
onDrop
|
ドロップされたファイルを受理し、drag-over 状態を解除する。 | stimeo--file-dropzone#onDrop |
openDialog
|
ネイティブのファイルダイアログを開く。 | stimeo--file-dropzone#openDialog |
イベント
| 名前 | 説明 | イベント |
|---|---|---|
change
|
受理済みファイル集合が変化したときに発火。detail は { files }(現在の File[])。 |
stimeo--file-dropzone:change |
reject
|
ファイルが拒否されたときに発火。detail は { file, reason }(type/size/duplicate/count)。 |
stimeo--file-dropzone:reject |
reconcile
|
Turbo キャッシュの巻き戻しが選択集合を捨てたとき発火。detail は change と同じ形(常に空)。 |
stimeo--file-dropzone:reconcile |
状態フック
ライブラリが操作するのはこれらの ARIA / data 属性、カスタムプロパティだけです。見た目は利用側 CSS がこれらに反応して作ります([aria-selected] / [aria-expanded] / var(--stimeo--…) などのセレクタでフックします)。
| フック | 対象 | 意味 |
|---|---|---|
data-dragover |
ゾーン | ドラッグオーバー中に付与。ゾーン内の子要素へポインタが移っても保たれる。 |
data-stimeo--file-dropzone-invalid |
ゾーン | 検証拒否の後に付与。次のバッチ冒頭で外れる。 |
hidden |
アイテムのサムネイル | 画像以外のファイルでは付与(プレビューなし)。 |