← alle entries

upload-card

FC-0130Formulierenupload-cardblockv1.0.0

Kaart om bestanden aan te leveren: icoon, titel en toelichting bovenin, daaronder het neerzetvak met de gekozen bestanden (file-dropzone), en een voet met telling, een tweede actie (slot) en de hoofdknop die pas actief wordt zodra er iets gekozen is. onSubmit krijgt de files; uploaden is een projectzaak. Componeert file-dropzone en button.

zelfde component — andere tokens
Documenten aanleveren

Zet je bestanden neer of kies ze; PDF, DOCX of afbeeldingen tot 10 MB.

Sleep bestanden hierheen of klik om te kiezen.pdf, .docx, image/* · Max. 10.0 MB

De hoofdknop wordt actief zodra er een bestand ligt.

blocks/upload-card/upload-card.tsx
/**
 * UploadCard — een kaart om bestanden aan te leveren: icoon, titel en
 * toelichting bovenin, daaronder het neerzetvak met de gekozen bestanden
 * (file-dropzone), en een voet met een tweede actie (slot) en de hoofdknop
 * die pas actief wordt zodra er iets gekozen is. onSubmit krijgt de files;
 * uploaden is een projectzaak.
 *
 * Geoogst uit 21st.dev FileUpload (arunjdass): de eigen dropzone met
 * useState/useRef en lucide Upload/File/X → file-dropzone (FC-0042) en
 * button (FC-0003); text-gray-500, border-gray-300, bg-gray-50 → tokens;
 * 'Upload your files' → props.
 */

"use client";

import { useState } from "react";
import type * as React from "react";
import { Button } from "../../primitives/ui/button";
import { FileDropzone, type FileDropzoneLabels } from "../../primitives/ui/file-dropzone";

export interface UploadCardProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSubmit" | "title"> {
  title: React.ReactNode;
  description?: React.ReactNode;
  icon?: React.ReactNode;
  accept?: string;
  multiple?: boolean;
  maxSize?: number;
  onFilesChange?: (files: File[]) => void;
  onSubmit?: (files: File[]) => void | Promise<void>;
  submitLabel?: string;
  secondaryAction?: React.ReactNode;
  labels?: Partial<FileDropzoneLabels>;
  /** Telling in de voet; standaard NL. */
  countLabel?: (n: number) => React.ReactNode;
}

export function UploadCard({ title, description, icon, accept, multiple = true, maxSize, onFilesChange, onSubmit, submitLabel = "Uploaden", secondaryAction, labels, countLabel = (n) => `${n} ${n === 1 ? "bestand" : "bestanden"}`, className, ...rest }: UploadCardProps) {
  const [files, setFiles] = useState<File[]>([]);
  const [bezig, setBezig] = useState(false);
  const verstuur = async () => {
    if (files.length === 0 || !onSubmit) return;
    setBezig(true);
    try { await onSubmit(files); } finally { setBezig(false); }
  };
  return (
    <div {...rest} className={["fp-upload", className].filter(Boolean).join(" ")}>
      <div className="fp-upload__kop">
        {icon && <span className="fp-upload__icoon" aria-hidden="true">{icon}</span>}
        <div>
          <div className="fp-upload__titel">{title}</div>
          {description && <p className="fp-upload__sub">{description}</p>}
        </div>
      </div>
      <FileDropzone accept={accept} multiple={multiple} maxSize={maxSize} labels={labels} onFilesChange={(f) => { setFiles(f); onFilesChange?.(f); }} />
      {(onSubmit || secondaryAction) && (
        <div className="fp-upload__voet">
          <span className="fp-upload__telling">{files.length > 0 ? countLabel(files.length) : ""}</span>
          <div className="fp-upload__acties">
            {secondaryAction}
            {onSubmit && <Button variant="primary" disabled={files.length === 0 || bezig} onPress={verstuur}>{submitLabel}</Button>}
          </div>
        </div>
      )}
    </div>
  );
}
blocks/upload-card/upload-card.css
/** UploadCard — importeren in de app-root (registry-site: app/globals.css). */

.fp-upload { display: flex; flex-direction: column; gap: 1rem; width: 100%; max-width: 30rem; padding: 1.25rem; border-radius: calc(var(--radius) * 1.5); border: 1px solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); }
.fp-upload__kop { display: flex; align-items: flex-start; gap: 0.75rem; }
.fp-upload__icoon { display: inline-flex; align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; flex: 0 0 auto; border-radius: var(--radius); background: color-mix(in srgb, var(--primary) 12%, transparent); color: var(--primary); }
.fp-upload__titel { font: inherit; font-size: 1rem; font-weight: 600; line-height: 1.3; }
.fp-upload__sub { margin: 0.125rem 0 0; font: inherit; font-size: 0.8125rem; line-height: 1.5; color: var(--muted-foreground); }
.fp-upload__voet { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); }
.fp-upload__telling { font: inherit; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-upload__acties { display: flex; align-items: center; gap: 0.5rem; }