Theme
Design your own
On this page (12)
Component Skill — components/file-input/component-skill.md

Native basis

<input type="file"> element with custom ::file-selector-button styling.

Web Platform APIs

<input type="file">::file-selector-buttonDataTransferDrag and DropcreateObjectURL()

Classes

.file-input.file-drop.file-drop-zone.file-drop-input.file-drop-icon.file-drop-title.file-drop-hint.file-drop-error.file-drop-list

Notes

• Reuses the .input styling pattern for consistency.

• The ::file-selector-button is styled as a muted button with hover effect.

• The existing .input[type="file"] already covers this - the file-input component provides a standalone class.

§Drag and drop

A card to drop files on - or click it. The native input covers the card, so the drop lands on it and forms submit it as usual; the script highlights the drag, keeps only images and PDFs up to 10 MB (a drop bypasses accept), appends to the selection and lists the files with previews and remove buttons.

§Several images, nothing else

multiple + accept='image/*' + data-max-files='4': the picker shows images only, and dropped PDFs or a fifth image are named in the error line instead of being added.

§One document

Without multiple a new pick replaces the old one; accept lists extensions (.pdf, .doc, .docx) and MIME types alike.

§Compact drop zone

data-size='sm' - one row, for forms. It is still the whole drop target.

§Default

Single file selection.

§Multiple files

The native multiple attribute - the picker allows several files; the field names the count.

§Accept filter

accept limits the picker to types - here images; list extensions and MIME types comma-separated (.pdf,image/*).

§Sizes

The control follows the shared input ladder via data-size - md is 2.25rem, matching .input.

§States

Named states of the drop zone, bound on each .file-drop:

  • default - no files (setState clears the selection)
  • dragover - files are dragged over the zone
  • selected - files chosen (config.files sets placeholders)
  • error - some files were rejected (config.message)

The first example carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/file-input-{state}.png.

Machine contract - verified against file-input.schema.json by bun run verify:

StateTypeValuesDefaultDescription
disabledbooleantrue, falsefalsePicker off (native disabled attribute).
sizeenumsm, md, lg"md"Control scale (display sizes).
dragoverbooleantrue, falsefalseDrop zone: files hover it.
selectedbooleantrue, falsefalseDrop zone: files chosen.
errorbooleantrue, falsefalseDrop zone: some files rejected.

§API

Generated from file-input.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type FileInputState = 'default' | 'dragover' | 'selected' | 'error' - setState(name, config) takes the config of the state it names.

StateDescription
default
No files - setting it clears the selection.

No config.

dragover
Files are dragged over the zone.

No config.

selected
Files chosen.
Config fieldTypeDescription
files?Array<string | { name: string; size?: number; type?: string }>placeholder entries for the list (a name, or name + size in bytes + MIME type)
error
Some files were refused.
Config fieldTypeDescription
message?stringthe message shown (getState() reports the live one)

Every element

MemberDescription
el.api.setState<S extends FileInputState>(name: S, config?: FileInputStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?FileInputStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: FileInputState; config: FileInputStateConfigs[FileInputState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.fileInputApi.setState<S extends FileInputState>(el: HTMLElement, name: S, config?: FileInputStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?FileInputStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.fileInputApi.getState(el: HTMLElement): { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.fileInputApi.render(state: { name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: FileInputState; config: FileInputStateConfigs[FileInputState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.fileInputApi.store(el: HTMLElement): Store<{ name: FileInputState; config: FileInputStateConfigs[FileInputState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: FileInputState; config: FileInputStateConfigs[FileInputState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.fileInputApi.commit<S extends FileInputState>(el: HTMLElement, name: S, config?: FileInputStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?FileInputStateConfigs[S]its config
df$.shadcn.fileInputStates: FileInputState[]The declared states, 'default' first: default, dragover, selected, error.

Events

EventDescription
file-drop:rejected
Fires when chosen or dropped files are refused (type, size, count) - the files and the message shown.

detail: FileDropRejectedDetail

FieldTypeDescription
filesFile[]the files that were refused
messagestringthe message the component shows for them

Types

TypeDescription
FileDropRejectedDetail
What file-drop:rejected carries.
FieldTypeDescription
filesFile[]the files that were refused
messagestringthe message the component shows for them

§CSS view file

/* -- File Input component --------------------------------------- */
@layer components {
  .file-input {
    /* see .input - border-box so the declared ladder height is the real box;
       unsized default = the ladder's md step (field standard) */
    box-sizing: border-box;
    width: 100%;
    /* the chosen file name is the whole point of the control's text part -
       never let a flex/grid row squeeze it below a readable path length */
    min-inline-size: min(100%, 24rem);
    height: 2.25rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    font-size: 0.875rem;
    font-family: var(--font-sans);
    color: var(--foreground);
    outline: none;
    box-shadow: var(--shadow-xs);
    transition: border-color 150ms, box-shadow 150ms;
    padding: 0;
    cursor: pointer;
    &::file-selector-button {
      /* % resolves against the border-box's CONTENT height once the control is
         border-box - 100% would leave 1px gaps above/below the divider; +2px
         makes the button flush with the frame (its original visual) */
      height: calc(100% + 2px);
      border: none;
      border-right: 1px solid var(--input);
      background: var(--muted);
      color: var(--foreground);
      font-size: 0.875rem;
      font-weight: 500;
      font-family: var(--font-sans);
      padding: 0 0.75rem;
      margin-right: 0.75rem;
      cursor: pointer;
      transition: background 150ms;
      &:hover { background: var(--accent); }
    }
    &:focus {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    /* Disabled = legible but inert: muted surface + text inside the
       full-strength border - never an opacity fade */
    &:disabled {
      background-color: var(--muted);
      color: var(--muted-foreground);
      box-shadow: none;
      cursor: not-allowed;
      &::file-selector-button {
        color: var(--muted-foreground);
        cursor: not-allowed;
        &:hover { background: var(--muted); }
      }
    }
    /* -- Sizes: same ladder as .input/.date-input (md = 2.25rem; the
       unsized default keeps the historical 2.5rem). The file-selector
       button fills the container (height:100%), so only the box and the
       font scale. */
    &[data-size="xs"] { height: 1.75rem; font-size: 0.75rem;    &::file-selector-button { padding: 0 0.5rem; margin-right: 0.5rem; font-size: 0.75rem; } }
    &[data-size="sm"] { height: 2rem;    font-size: 0.8125rem;  &::file-selector-button { padding: 0 0.625rem; margin-right: 0.625rem; font-size: 0.8125rem; } }
    &[data-size="md"] { height: 2.25rem; font-size: 0.875rem; }
    &[data-size="lg"] { height: 2.75rem; font-size: 1rem;       &::file-selector-button { padding: 0 1rem; margin-right: 1rem; font-size: 1rem; } }
    &[data-size="xl"] { height: 3.25rem; font-size: 1.125rem;   &::file-selector-button { padding: 0 1.25rem; margin-right: 1.25rem; font-size: 1.125rem; } }
  }
  /* -- Drop zone ---------------------------------------------------------
     .file-drop wraps a <label class="file-drop-zone"> (the card) whose
     native <input type="file" class="file-drop-input"> covers it,
     invisible - so a click opens the picker and a DROP lands on the input
     itself: the browser fills input.files natively, no JavaScript needed.
     file-input.js adds the drag highlight, filters dropped files by
     accept / data-max-size / data-max-files (the browser does not filter
     drops), appends to the selection instead of replacing it, and lists the
     files in .file-drop-list with previews and remove buttons. */
  .file-drop {
    display: grid;
    gap: 0.75rem;
    width: 100%;
  }
  .file-drop-zone {
    position: relative;
    display: grid;
    justify-items: center;
    gap: 0.375rem;
    padding: 2rem 1.5rem;
    border: 2px dashed color-mix(in oklch, var(--foreground) 22%, transparent);
    border-radius: var(--radius-xl);
    background-color: var(--card);
    color: var(--card-foreground);
    text-align: center;
    cursor: pointer;
    transition: border-color 150ms ease, background-color 150ms ease, scale 150ms ease;
    &:hover { border-color: color-mix(in oklch, var(--foreground) 40%, transparent); }
    &:has(.file-drop-input:focus-visible) { outline: 2px solid var(--ring); outline-offset: 2px; }
    &:has(.file-drop-input:disabled) { opacity: 0.55; cursor: not-allowed; }
  }
  .file-drop-input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: inherit;
    font-size: 0; /* no native "No file chosen" hit-box quirks */
  }
  .file-drop-icon {
    display: grid;
    place-items: center;
    width: 3rem;
    height: 3rem;
    margin-bottom: 0.25rem;
    border-radius: 9999px;
    background-color: var(--muted);
    color: var(--muted-foreground);
    font-size: 1.5rem;
    line-height: 1;
    transition: background-color 150ms ease, color 150ms ease, translate 150ms ease;
    & svg { width: 1.5rem; height: 1.5rem; }
  }
  .file-drop-title {
    font-size: 0.9375rem;
    font-weight: 600;
    text-wrap: balance;
    & u, & .file-drop-browse { color: var(--primary); text-decoration: underline; text-underline-offset: 3px; }
  }
  .file-drop-hint {
    font-size: 0.8125rem;
    color: var(--muted-foreground);
    text-wrap: balance;
  }
  /* dragging files over the zone */
  .file-drop[data-state-name="dragover"] .file-drop-zone {
    border-color: var(--primary);
    border-style: solid;
    background-color: color-mix(in oklch, var(--primary) 6%, var(--card));
    scale: 1.01;
    & .file-drop-icon { background-color: var(--primary); color: var(--primary-foreground); translate: 0 -2px; }
  }
  /* rejected files (wrong type, too big, too many) */
  .file-drop[data-state-name="error"] .file-drop-zone { border-color: var(--destructive); }
  .file-drop-error {
    margin: 0;
    font-size: 0.8125rem;
    color: var(--destructive);
    &:empty { display: none; }
  }
  /* compact: one row - icon, text, and the whole row is the target */
  .file-drop[data-size="sm"] .file-drop-zone {
    grid-template-columns: auto 1fr;
    justify-items: start;
    align-items: center;
    column-gap: 0.75rem;
    row-gap: 0;
    padding: 0.75rem 1rem;
    text-align: start;
    border-radius: var(--radius-lg);
    & .file-drop-icon { grid-row: span 2; width: 2.25rem; height: 2.25rem; margin: 0; font-size: 1.125rem; & svg { width: 1.125rem; height: 1.125rem; } }
  }
  /* -- The file list ---------------------------------------------------- */
  .file-drop-list {
    display: grid;
    gap: 0.5rem;
    margin: 0;
    padding: 0;
    list-style: none;
    &:empty { display: none; }
  }
  .file-drop-item {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 0.5rem 0.5rem 0.625rem;
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    background-color: var(--card);
  }
  .file-drop-thumb {
    display: grid;
    place-items: center;
    width: 2.5rem;
    height: 2.5rem;
    overflow: hidden;
    border-radius: var(--radius-md);
    background-color: var(--muted);
    color: var(--muted-foreground);
    font-size: 0.625rem;
    font-weight: 700;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    & img { width: 100%; height: 100%; object-fit: cover; }
  }
  .file-drop-name {
    overflow: hidden;
    font-size: 0.875rem;
    font-weight: 500;
    white-space: nowrap;
    text-overflow: ellipsis;
  }
  .file-drop-meta {
    display: block;
    font-size: 0.75rem;
    font-weight: 400;
    color: var(--muted-foreground);
  }
  .file-drop-remove {
    display: grid;
    place-items: center;
    width: 2rem;
    height: 2rem;
    border: 0;
    border-radius: var(--radius-md);
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    &:hover { background-color: var(--accent); color: var(--accent-foreground); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
    & svg { width: 1rem; height: 1rem; }
  }
  @media (forced-colors: active) {
    .file-drop-zone { border-color: CanvasText; }
    .file-drop[data-state-name="dragover"] .file-drop-zone { border-color: Highlight; }
  }
}
/* Accessibility: suppress motion for users who request it (REQUIRED for all
   components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of
   `none` keeps transitionend/animationend (and discrete display flips)
   firing so JS state machines that await them keep working. */
@media (prefers-reduced-motion: reduce) {
  @layer components {
    .file-input,
    .file-input *,
    .file-input::before,
    .file-input::after,
    .file-input *::before,
    .file-input *::after,
    .file-input::backdrop {
      transition-duration: 0.01ms !important;
      animation-duration: 0.01ms !important;
      animation-iteration-count: 1 !important;
    }
  }
}

§JS view file

/* -- File Input component ----------------------------------------- */
// The plain .file-input is CSS only. This module drives the .file-drop zone:
// the native <input type="file"> covers the card, so clicking and dropping
// are the browser's own; on top of that it highlights a drag, filters the
// files by accept / data-max-size / data-max-files (a DROP bypasses accept),
// appends to a multiple selection instead of replacing it, lists the files
// (image previews, Intl-formatted sizes, remove buttons) and exposes the
// named State API (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
import { defussGlobals, defussQuery, componentState, bindComponent, textLocale } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** What file-drop:rejected carries. */
interface FileDropRejectedDetail {
  /** the files that were refused */
  files: File[];
  /** the message the component shows for them */
  message: string;
}
/** default = empty; dragover = files hover the zone; selected = files
 * chosen; error = some files were rejected (config.message). */
const fileInputStates = ['default', 'dragover', 'selected', 'error'];
/** setState() configs per state (getState() reports the rejection message shown). */
export interface FileInputStateConfigs {
  /** No files - setting it clears the selection. */
  default: {};
  /** Files are dragged over the zone. */
  dragover: {};
  /** Files chosen. */
  selected: {
    /** placeholder entries for the list (a name, or name + size in bytes + MIME type) */
    files?: Array<string | { name: string; size?: number; type?: string }>;
  };
  /** Some files were refused. */
  error: {
    /** the message shown (getState() reports the live one) */
    message?: string;
  };
}
const inputOf = (el) => dfDollar(el).find<HTMLInputElement>('.file-drop-input').get(0);
/** Does a file match the input's accept list (".pdf", "image/*", "image/png")? */
function accepts(input, file) {
  const list = (input.accept || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
  if (!list.length) return true;
  const name = file.name.toLowerCase();
  const type = (file.type || '').toLowerCase();
  return list.some((a) => (a.startsWith('.') ? name.endsWith(a) : a.endsWith('/*') ? type.startsWith(a.slice(0, -1)) : type === a));
}
const lang = (el) => textLocale(el);
/** 1536 → "1.5 kB" (Intl unit formatting, the locale's own). */
function formatSize(el, bytes) {
  const units = ['byte', 'kilobyte', 'megabyte', 'gigabyte'];
  let i = 0;
  let n = bytes;
  while (n >= 1000 && i < units.length - 1) {
    n /= 1000;
    i++;
  }
  return new Intl.NumberFormat(lang(el), { style: 'unit', unit: units[i], unitDisplay: 'short', maximumFractionDigits: i ? 1 : 0 }).format(n);
}
const ICON_X = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
/** Rebuild input.files from a File[] (DataTransfer is the only way to set it). */
function setFiles(input, files) {
  const dt = new DataTransfer();
  for (const f of files) dt.items.add(f);
  input.files = dt.files;
}
function renderList(el) {
  const input = inputOf(el);
  const list = dfDollar(el).find('.file-drop-list').get(0);
  if (!list) return;
  (el._urls || []).forEach((u) => URL.revokeObjectURL(u));
  el._urls = [];
  // the list is rebuilt per change: emptied and refilled through df$
  dfDollar(list).empty().append(
    [...input.files].map((file, i) => {
      const li = document.createElement('li');
      li.className = 'file-drop-item';
      const thumb = document.createElement('span');
      thumb.className = 'file-drop-thumb';
      thumb.setAttribute('aria-hidden', 'true');
      if (file.type.startsWith('image/')) {
        const img = document.createElement('img');
        img.alt = '';
        img.src = URL.createObjectURL(file);
        el._urls.push(img.src);
        thumb.append(img);
      } else {
        thumb.textContent = (file.name.split('.').pop() || 'file').slice(0, 4);
      }
      const name = document.createElement('div');
      name.className = 'file-drop-name';
      name.textContent = file.name;
      const meta = document.createElement('span');
      meta.className = 'file-drop-meta';
      meta.textContent = formatSize(el, file.size);
      name.append(meta);
      const remove = document.createElement('button');
      remove.type = 'button';
      remove.className = 'file-drop-remove';
      dfDollar(remove).html(ICON_X);
      remove.setAttribute('aria-label', `Remove ${file.name}`);
      remove.addEventListener('click', () => {
        setFiles(input, [...input.files].filter((_, k) => k !== i));
        el._kept = [...input.files];
        apply(el, [], true);
        // tell form listeners - flagged, so our own change handler skips it
        el._removing = true;
        input.dispatchEvent(new Event('change', { bubbles: true }));
        el._removing = false;
      });
      li.append(thumb, name, remove);
      return li;
    }),
  );
}
/** Show the rejections (or clear them) and settle the state name. */
function apply(el, rejected, quiet = false) {
  const input = inputOf(el);
  const err = dfDollar(el).find('.file-drop-error').get(0);
  const message = rejected.length ? `Not added: ${rejected.map((r) => `${r.file.name} (${r.why})`).join(', ')}` : '';
  if (err) {
    err.textContent = message;
    err.setAttribute('role', 'alert');
  }
  el.dataset.stateName = rejected.length ? 'error' : input.files.length ? 'selected' : 'default';
  renderList(el);
  // Fires when chosen or dropped files are refused (type, size, count) - the files and the message shown.
  if (!quiet && rejected.length) el.dispatchEvent(new CustomEvent<FileDropRejectedDetail>('file-drop:rejected', { bubbles: true, detail: { files: rejected.map((r) => r.file), message } }));
}
/** Merge the new pick into the kept files, filter, write input.files back. */
function onPick(el) {
  const input = inputOf(el);
  const incoming = [...input.files];
  const kept = input.multiple ? el._kept || [] : [];
  const maxSize = parseFloat(el.dataset.maxSize || 'Infinity');
  const maxFiles = input.multiple ? parseFloat(el.dataset.maxFiles || 'Infinity') : 1;
  const key = (f) => `${f.name}|${f.size}|${f.lastModified}`;
  const seen = new Set(kept.map(key));
  const out = [...kept];
  const rejected = [];
  for (const file of incoming) {
    if (seen.has(key(file))) continue;
    if (!accepts(input, file)) rejected.push({ file, why: 'type' });
    else if (file.size > maxSize) rejected.push({ file, why: `over ${formatSize(el, maxSize)}` });
    else if (out.length >= maxFiles) rejected.push({ file, why: `max ${maxFiles}` });
    else {
      out.push(file);
      seen.add(key(file));
    }
  }
  setFiles(input, out);
  el._kept = out;
  apply(el, rejected);
}
/**
 * The markup of a state, for render(), on a detached copy of the authored
 * markup: the error line - the message in 'error', empty otherwise - as
 * apply() / the error state write it. The file list is built from the
 * input's FileList (blob-URL thumbnails, sizes): runtime-owned.
 */
function applyMarkup(el, stateName, config) {
  const err = dfDollar(el).find('.file-drop-error').first();
  if (!err.get(0)) return;
  err.attr('role', 'alert');
  if (stateName === 'error') err.text(config?.message || 'Not added: archive.zip (type)');
  else if (stateName !== 'dragover') err.text('');
}
function triggerStateChange(el, stateName, config) {
  const input = inputOf(el);
  switch (stateName) {
    case 'default':
      setFiles(input, []);
      el._kept = [];
      apply(el, [], true);
      break;
    case 'dragover':
      el.dataset.stateName = 'dragover';
      break;
    case 'selected': {
      // config.files: [{ name, size, type }] - placeholder files (demos, tests)
      // { files: [{ name, size, type }] } - or getState()'s plain names
      const files = (config?.files || [{ name: 'report.pdf', size: 248000, type: 'application/pdf' }]).map((f) => (typeof f === 'string' ? { name: f } : f)).map(
        // real bytes (capped at 5 MB) so size, type and name read true
        (f) => new File([new Uint8Array(Math.min(f.size ?? 0, 5e6))], f.name, { type: f.type || '' }),
      );
      setFiles(input, files);
      el._kept = [...input.files];
      apply(el, [], true);
      break;
    }
    case 'error': {
      const err = dfDollar(el).find('.file-drop-error').get(0);
      if (err) err.textContent = config?.message || 'Not added: archive.zip (type)';
      el.dataset.stateName = 'error';
      break;
    }
  }
}
/** Registry-level API; pass the .file-drop explicitly. Unknown names throw. */
export const fileInputApi = componentState({
  component: 'file-input',
  states: fileInputStates,
  apply: (el, state) => triggerStateChange(el, state.name, state.config),
  read: (el, state) => {
    const input = inputOf(el);
    return {
      name: el.dataset.stateName || 'default',
      config: {
        ...state.config,
        count: input.files.length,
        files: [...input.files].map((f) => f.name),
        // the live rejection message, so setState('error', getState().config) keeps it
        ...(el.dataset.stateName === 'error' ? { message: dfDollar(el).find('.file-drop-error').text() } : {}),
      },
    };
  },
  markup: (el, state) => applyMarkup(el, state.name, state.config),
});
df$.fileInputApi = fileInputApi;
df$.fileInputStates = fileInputStates;
function init() {
  dfDollar('.file-drop:not([data-init])').toArray().forEach((el) => {
    const input = inputOf(el);
    if (!input) return;
    el.dataset.init = '';
    el.dataset.stateName = 'default';
    el._kept = [];
    // the error line is a live region from the start (not only once a
    // rejection has been written)
    dfDollar(el).find('.file-drop-error').attr('role', 'alert');
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(el, fileInputApi);
    const zone = dfDollar(el).find('.file-drop-zone').get(0) || el;
    let depth = 0; // dragenter/leave fire for every child - count them
    zone.addEventListener('dragenter', (e) => {
      if (!e.dataTransfer?.types.includes('Files') || input.disabled) return;
      depth++;
      el._before = el.dataset.stateName === 'dragover' ? el._before : el.dataset.stateName;
      el.dataset.stateName = 'dragover';
    });
    zone.addEventListener('dragleave', () => {
      depth = Math.max(0, depth - 1);
      if (!depth && el.dataset.stateName === 'dragover') el.dataset.stateName = el._before || 'default';
    });
    zone.addEventListener('drop', () => {
      depth = 0;
      if (el.dataset.stateName === 'dragover') el.dataset.stateName = el._before || 'default';
    });
    input.addEventListener('change', () => {
      // our own re-dispatch after a remove carries no new pick
      if (!el._removing) onPick(el);
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub