Skip to main content
astrocraft-ui/ components · 101

File Upload

Free · MIT

Headless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.

Install

Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).

Unstyled — behavior only, you write the CSS

Install command
npx astrocraft-ui add forms/file-upload

Plain-CSS theme — no build step

Install command
npx astrocraft-ui add forms/file-upload --theme css

Tailwind theme — needs Tailwind v4

Install command
npx astrocraft-ui add forms/file-upload --theme tailwind

Plain-CSS theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/file-upload --theme css --bridge lumos

Tailwind theme, re-pointed onto Lumos tokens

Install command
npx astrocraft-ui add forms/file-upload --theme tailwind --bridge lumos

Live demo

FileUpload

Drop files on the zone or use the button — either way they land in the real<input type="file">, so the form submits them normally. Drop something that is not an image and it is refused: accept filters the picker and nothing else, so the check has to happen here. The list is a live region, because a drop moves no focus and would otherwise be silent.

Drop images or PDFs here

    No files chosen

    Attribute surface

    With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.

    ComponentSlotsVariantsRuntime stateNative state
    `FileDropzone.astro``file-dropzone`———
    `FileItem.astro``file-item` `file-item-name` `file-item-remove` `file-item-size`———
    `FileItemProgress.astro``file-item-progress`———
    `FileList.astro``file-item-template` `file-list` `file-list-empty` `file-list-region`———
    `FileTrigger.astro``file-trigger``data-variant`: `primary` · `secondary` · `outline` · `ghost`
    `data-size`: `sm` · `md` · `lg`
    ——
    `FileUpload.astro``file-upload` `file-upload-error` `file-upload-input``data-size`: `sm` · `md` · `lg`
    `data-state`: `default` · `error` · `success`
    `data-name` `data-dragging``[hidden]`

    Source

    What the command copies — 8 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.

    src/components/ui/forms/file-upload/FileDropzone.astro
    ---
    // src/components/ui/forms/file-upload/FileDropzone.astro — FileUpload compound part (see ../../README.md).
    // The drop target. FileUpload's script does the one thing that makes a dropzone work at all:
    // `preventDefault()` on `dragover`. Without it the browser's own default wins and dropping a file
    // NAVIGATES AWAY to open it — losing the page, the form and everything typed into it.
    //
    // While a drag is over it the script sets `data-dragging="true"` (a data attribute, never a class),
    // which is the hook a theme uses to show the target is live.
    //
    // A drop target cannot be operated without a pointer, so FileTrigger is the route for everyone
    // else — put one inside this and say so in the text. It carries no role of its own precisely BECAUSE
    // it usually wraps that button: `aria-hidden` on a container is inherited by everything in it, and
    // would hide the one control that makes the component keyboard-accessible.
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"div">;
    
    const { class: className, ...rest } = Astro.props;
    ---
    
    <div data-slot="file-dropzone" class={className} {...rest}>
      <slot />
    </div>
    
    src/components/ui/forms/file-upload/FileItem.astro
    ---
    // src/components/ui/forms/file-upload/FileItem.astro — FileUpload compound part (see ../../README.md).
    // One row of the file list: name, size, whatever you slot in (a FileItemProgress, a thumbnail), and
    // a remove button.
    //
    // FileList renders ONE of these inside a <template> and FileUpload's script clones it per file. That
    // is how this library generates a list without generating markup: the shape of a row is written here,
    // in Astro, where `pnpm slots` can see the hooks it emits and a theme can style them — rather than
    // in a template literal inside a script, which is the usual way this component goes wrong.
    //
    // The remove button's accessible name is completed at runtime with the file's own name, because a
    // list of five buttons all announcing "Remove" tells a screen reader user nothing about which one
    // they are on.
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"li"> & {
      name?: string;
      size?: string;
      /** The first half of the remove button's name; the file's name is appended at runtime. */
      removeLabel?: string;
    };
    
    const { name = "", size = "", removeLabel = "Remove", class: className, ...rest } = Astro.props;
    ---
    
    <li data-slot="file-item" data-name={name} class={className} {...rest}>
      <span data-slot="file-item-name">{name}</span>
      <span data-slot="file-item-size">{size}</span>
      <slot />
      <button
        type="button"
        data-slot="file-item-remove"
        data-label={removeLabel}
        aria-label={name ? `${removeLabel} ${name}` : removeLabel}
      >
        <slot name="remove"
          ><svg
            xmlns="http://www.w3.org/2000/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"></path>
          </svg></slot
        >
      </button>
    </li>
    
    src/components/ui/forms/file-upload/FileItemProgress.astro
    ---
    // src/components/ui/forms/file-upload/FileItemProgress.astro — FileUpload compound part (see ../../README.md).
    // A native <progress> for one file's upload. Native because it already announces as a progress bar,
    // already reports its value to assistive tech, and already has an indeterminate state — which is
    // what you get by leaving `value` off, and what an upload looks like before the first byte lands.
    //
    // The library does not upload anything, so nothing here sets the value: that is the consumer's fetch
    // or XHR, writing `progress.value = event.loaded / event.total`. This file exists so the element is
    // in the right place with the right label when they do.
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"progress"> & {
      /** 0–1. Leave it off for the indeterminate bar an upload starts as. */
      value?: number;
      label?: string;
    };
    
    const { value, label = "Upload progress", class: className, ...rest } = Astro.props;
    ---
    
    <progress
      max="1"
      value={value}
      aria-label={label}
      data-slot="file-item-progress"
      class={className}
      {...rest}></progress>
    
    src/components/ui/forms/file-upload/FileList.astro
    ---
    // src/components/ui/forms/file-upload/FileList.astro — FileUpload compound part (see ../../README.md).
    // The list of chosen files, plus the <template> its rows are cloned from and the empty state.
    //
    // IT IS A LIVE REGION, and that is the part that earns the file. Dropping three files on a dropzone
    // changes the page with no keyboard event and no focus move: without `aria-live` a screen reader
    // user gets no confirmation that anything happened at all, and no way to discover it short of
    // hunting. Polite, so it waits its turn.
    //
    // The template holds one FileItem — see that file on why the row's markup lives in Astro rather than
    // in a string inside the script. Pass your own row instead by slotting it into `item`.
    import type { HTMLAttributes } from "astro/types";
    
    import FileItem from "./FileItem.astro";
    
    type Props = HTMLAttributes<"div"> & {
      label?: string;
      emptyText?: string;
      removeLabel?: string;
    };
    
    const {
      label = "Chosen files",
      emptyText = "No files chosen",
      removeLabel = "Remove",
      class: className,
      ...rest
    } = Astro.props;
    ---
    
    <div
      role="region"
      aria-live="polite"
      aria-label={label}
      data-slot="file-list-region"
      class={className}
      {...rest}
    >
      <ul data-slot="file-list"></ul>
      <p data-slot="file-list-empty">{emptyText}</p>
      <template data-slot="file-item-template">
        <slot name="item"><FileItem removeLabel={removeLabel} /></slot>
      </template>
    </div>
    
    src/components/ui/forms/file-upload/FileTrigger.astro
    ---
    // src/components/ui/forms/file-upload/FileTrigger.astro — FileUpload compound part (see ../../README.md).
    // The button that opens the file picker. FileUpload's script points it at the input inside the same
    // root, so it needs no `for` and no id to be minted anywhere.
    //
    // A <button> rather than a <label for>, deliberately. A label does open the picker on click — but
    // the keyboard route to it is focusing the INPUT, and every design that uses this component hides
    // that input. A button is focusable, is announced as a button, and works the same for both.
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"button"> & {
      variant?: "primary" | "secondary" | "outline" | "ghost";
      size?: "sm" | "md" | "lg";
    };
    
    const { variant = "outline", size = "md", class: className, ...rest } = Astro.props;
    ---
    
    <button
      type="button"
      data-slot="file-trigger"
      data-variant={variant}
      data-size={size}
      class={className}
      {...rest}
    >
      <slot>Choose files</slot>
    </button>
    
    src/components/ui/forms/file-upload/FileUpload.astro
    ---
    // src/components/ui/forms/file-upload/FileUpload.astro — headless primitive (see ../../README.md).
    // Drag-and-drop file input, built so that the real <input type="file"> stays the source of truth:
    // dropped files are written back into `input.files` through a DataTransfer, so the form submits them
    // normally, `required` still applies, and a server sees an ordinary multipart upload. Nothing here
    // uploads anything — that is the consumer's fetch, and this is the field it reads from.
    //
    // TWO THINGS THE BROWSER GETS WRONG ON ITS OWN, and they are the reason this file exists:
    //   1. Drop a file on a page and the browser NAVIGATES to it, discarding the form. The only fix is
    //      `preventDefault()` on `dragover`, on the dropzone, before the drop ever happens.
    //   2. `accept` filters the picker and nothing else. A dropped file has never been past it, so the
    //      check has to be done here — `matchesAccept` in `file-list.ts`, checked by its test.
    //
    // Compose the parts you want inside it:
    //
    //   <FileUpload name="attachments" accept="image/*" multiple>
    //     <FileDropzone>Drop images here, or <FileTrigger /></FileDropzone>
    //     <FileList />
    //   </FileUpload>
    //
    // ponytail: the list is REBUILT whenever the file set changes, so a FileItemProgress you were
    // driving is replaced along with its row. For an uploader that shows live progress, start the upload
    // from the `change` event and re-find the row by its `data-name` — or upload one file at a time,
    // which is what most consumers of this actually do.
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"input"> & {
      name?: string;
      accept?: string;
      multiple?: boolean;
      /** Names the input itself; use a Label instead when there is one on screen. */
      label?: string;
      /** BCP-47 tag for the file sizes. Defaults to the browser's. */
      locale?: string;
      /** Shown when a chosen or dropped file does not match `accept`. */
      rejectedText?: string;
      size?: "sm" | "md" | "lg";
      state?: "default" | "error" | "success";
    };
    
    const {
      name,
      accept,
      multiple = false,
      label = "Choose files",
      locale,
      rejectedText = "Some files were not of an accepted type.",
      size = "md",
      state = "default",
      class: className,
      ...rest
    } = Astro.props;
    ---
    
    <div data-slot="file-upload" data-locale={locale}>
      <input
        type="file"
        name={name}
        accept={accept}
        multiple={multiple}
        aria-label={label}
        data-slot="file-upload-input"
        data-size={size}
        data-state={state}
        class={className}
        {...rest}
      />
      <slot />
      <p data-slot="file-upload-error" role="alert" hidden>{rejectedText}</p>
    </div>
    
    <script>
      import { onReadyOnce } from "../../_once";
      import { formatSize, matchesAccept } from "./file-list";
    
      function wire(root: HTMLElement) {
        const input = root.querySelector<HTMLInputElement>('[data-slot="file-upload-input"]');
        if (!input) return;
        const list = root.querySelector<HTMLElement>('[data-slot="file-list"]');
        const empty = root.querySelector<HTMLElement>('[data-slot="file-list-empty"]');
        const template = root.querySelector<HTMLTemplateElement>('[data-slot="file-item-template"]');
        const error = root.querySelector<HTMLElement>('[data-slot="file-upload-error"]');
        const dropzone = root.querySelector<HTMLElement>('[data-slot="file-dropzone"]');
        const locale = root.dataset.locale || undefined;
    
        const current = () => [...(input.files ?? [])];
    
        // Our own `commit()` re-dispatches `change`, so the handler below has to tell a person's action
        // from the library's own write: only a person's raises or clears the rejection message.
        let committing = false;
    
        /** Write a file set back into the real input — the one place `input.files` is ever assigned. */
        const commit = (files: File[]) => {
          const data = new DataTransfer();
          for (const file of files) data.items.add(file);
          input.files = data.files;
          committing = true;
          input.dispatchEvent(new Event("change", { bubbles: true }));
          committing = false;
        };
    
        /** Drop what `accept` does not allow. */
        const sift = (files: File[]) => files.filter((file) => matchesAccept(file, input.accept));
    
        /** Show the rejection message for THIS action, or clear it because this one was clean. */
        const report = (rejected: number) => {
          if (error) error.hidden = rejected === 0;
        };
    
        const render = () => {
          const files = current();
          if (empty) empty.hidden = files.length > 0;
          if (!list || !template) return;
          const row = template.content.querySelector('[data-slot="file-item"]');
          if (!row) return;
          list.replaceChildren();
          for (const file of files) {
            const item = row.cloneNode(true) as HTMLElement;
            item.dataset.name = file.name;
            const nameEl = item.querySelector('[data-slot="file-item-name"]');
            const sizeEl = item.querySelector('[data-slot="file-item-size"]');
            const remove = item.querySelector('[data-slot="file-item-remove"]');
            if (nameEl) nameEl.textContent = file.name;
            if (sizeEl) sizeEl.textContent = formatSize(file.size, locale);
            // "Remove" five times over tells a screen reader user nothing about which row they are on,
            // so the file's own name completes the button's name.
            remove?.setAttribute(
              "aria-label",
              `${remove.getAttribute("data-label") ?? "Remove"} ${file.name}`,
            );
            list.append(item);
          }
        };
    
        input.addEventListener("change", () => {
          const files = current();
          const kept = sift(files);
          if (!committing) report(files.length - kept.length);
          // A user can switch the picker's filter to "All files", so the check applies to chosen files
          // too. Re-committing fires `change` again, and the second pass has nothing left to drop.
          if (kept.length !== files.length) commit(kept);
          else render();
        });
    
        root.addEventListener("click", (event) => {
          const target = event.target;
          if (!(target instanceof Element)) return;
          if (target.closest('[data-slot="file-trigger"]')) input.click();
          const remove = target.closest<HTMLElement>('[data-slot="file-item-remove"]');
          if (!remove) return;
          const name = remove.closest<HTMLElement>('[data-slot="file-item"]')?.dataset.name;
          commit(current().filter((file) => file.name !== name));
          input.focus(); // the row that had focus is gone; land somewhere predictable
        });
    
        if (dropzone) {
          // THE `preventDefault` THAT MAKES A DROPZONE A DROPZONE. Without it the browser opens the
          // dropped file as a page of its own and the form is gone.
          dropzone.addEventListener("dragover", (event) => {
            event.preventDefault();
            dropzone.dataset.dragging = "true";
          });
          dropzone.addEventListener("dragleave", () => delete dropzone.dataset.dragging);
          dropzone.addEventListener("drop", (event) => {
            event.preventDefault();
            delete dropzone.dataset.dragging;
            const files = [...(event.dataTransfer?.files ?? [])];
            const kept = sift(files);
            report(files.length - kept.length);
            if (kept.length === 0) return;
            commit(input.multiple ? [...current(), ...kept] : kept.slice(0, 1));
          });
        }
    
        render();
      }
    
      onReadyOnce('[data-slot="file-upload"]', wire);
    </script>
    
    src/components/ui/forms/file-upload/file-list.ts
    // src/components/ui/forms/file-upload/file-list.ts — the two rules FileUpload needs that a browser does
    // not hand it: does this file match the `accept` attribute, and what do you call its size. Both are
    // pure, so they are checkable without a DOM (see file-list.test.ts).
    //
    // WHY `matchesAccept` HAS TO EXIST. The `accept` attribute filters the file PICKER and nothing else:
    // a file arriving by drag-and-drop has never been through it, so a dropzone that trusts `accept` to
    // have done the work happily takes a .exe into an avatar field. The browser applies no check on drop
    // at all, and the server should not be the first thing to notice.
    
    /** The shape this module needs from a `File` — so the rule can be checked without one. */
    export interface FileLike {
      readonly name: string;
      readonly type: string;
    }
    
    /**
     * Whether `file` satisfies an `accept` attribute — the same three forms the picker understands:
     * an extension (`.png`), an exact type (`image/png`), or a wildcard (`image/*`). An empty `accept`
     * accepts everything, exactly as it does natively.
     *
     * @example matchesAccept({ name: "a.png", type: "image/png" }, "image/*,.pdf") // => true
     */
    export function matchesAccept(file: FileLike, accept?: string | null): boolean {
      if (!accept?.trim()) return true;
      const type = file.type.toLowerCase();
      const name = file.name.toLowerCase();
      return accept
        .split(",")
        .map((rule) => rule.trim().toLowerCase())
        .filter(Boolean)
        .some((rule) => {
          if (rule.startsWith(".")) return name.endsWith(rule);
          if (rule.endsWith("/*")) return type.startsWith(rule.slice(0, -1));
          return type === rule;
        });
    }
    
    const UNITS = ["byte", "kilobyte", "megabyte", "gigabyte", "terabyte"] as const;
    
    /**
     * A file size in words: `1.4 MB`. Localised through `Intl.NumberFormat`'s unit style, so the
     * separator and the unit name follow the user's locale rather than a hard-coded "MB".
     *
     * Decimal thousands, not binary 1024s: it is what every operating system's file manager shows, and
     * agreeing with the number beside the file on the user's own desktop matters more than being right
     * about kibibytes.
     *
     * @example formatSize(1_400_000, "en-GB") // => "1.4 MB"
     */
    export function formatSize(bytes: number, locale?: string): string {
      const size = Math.max(0, bytes);
      const step = size === 0 ? 0 : Math.min(UNITS.length - 1, Math.floor(Math.log10(size) / 3));
      const value = size / 1000 ** step;
      return new Intl.NumberFormat(locale, {
        style: "unit",
        unit: UNITS[step],
        unitDisplay: "short",
        maximumFractionDigits: step === 0 ? 0 : 1,
      }).format(value);
    }
    
    src/components/ui/forms/file-upload/index.ts
    import FileDropzone from "./FileDropzone.astro";
    import FileItem from "./FileItem.astro";
    import FileItemProgress from "./FileItemProgress.astro";
    import FileList from "./FileList.astro";
    import FileTrigger from "./FileTrigger.astro";
    import FileUpload from "./FileUpload.astro";
    
    export { FileDropzone, FileItem, FileItemProgress, FileList, FileTrigger, FileUpload };
    export default FileUpload;
    

    What you get

    The component source, copied into your project by npx astrocraft-ui add forms/file-upload — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.