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, shown without its comments; the files you install keep them, with the reasoning behind every rule.

    src/components/ui/forms/file-upload/FileDropzone.astro
    ---
    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
    ---
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"li"> & {
      name?: string;
      size?: string;
      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
    ---
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"progress"> & {
      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
    ---
    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
    ---
    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
    ---
    import type { HTMLAttributes } from "astro/types";
    
    type Props = HTMLAttributes<"input"> & {
      name?: string;
      accept?: string;
      multiple?: boolean;
      label?: string;
      locale?: string;
      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 ?? [])];
    
        let committing = false;
    
        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;
        };
    
        const sift = (files: File[]) => files.filter((file) => matchesAccept(file, input.accept));
    
        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?.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);
          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();
        });
    
        if (dropzone) {
          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
    export interface FileLike {
      readonly name: string;
      readonly type: string;
    }
    
    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;
    
    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.