Prisma
Live Preview
Empty (Drop Zone)
Chọn ảnh hoặc kéo thả vào đây
Max 5MB, JPG/PNG/WebP
Filled (with preview)
product-photo.jpg · 2.4MB
Error State
Chọn ảnh khác
Max 5MB, JPG/PNG/WebP
File quá lớn. Vui lòng chọn ảnh dưới 5MB.

--- description: Hướng dẫn Agent tạo Image Upload/Picker — vùng upload click/drag-drop với preview, validation, dựa trên Fluent UI FluentInputFile + OneUI Photo Picker patterns

Image Upload Component Generation Skill

Skill này hướng dẫn bạn (AI Agent) tạo component Image Upload — vùng upload ảnh với click/drag-drop, preview, validation.

1. Mục tiêu (Objective)

Tạo Image Upload component hỗ trợ click-to-browse, drag-and-drop, preview thumbnail, file validation (type + size), và tích hợp 100% Prisma Design Tokens.

2. AI Context & Intent (Ngữ cảnh cho AI)

Khi nào dùng Image Upload?

  • Product image: Upload ảnh sản phẩm (xPOS)
  • Avatar/Profile: Upload ảnh đại diện
  • Attachment: Đính kèm hình ảnh trong form

Phân biệt với component khác

Tình huống Component đúng Lý do
Upload 1 ảnh Image Upload (single) Preview + validate
Upload nhiều ảnh Image Upload (multi) Gallery preview
Upload file bất kỳ File Input (custom) No preview needed
Chụp ảnh camera Camera API + Image Upload Capture then upload

Decision Tree cho AI

text
User cần upload file?
├─ Ảnh (JPG/PNG/WebP) → Image Upload
│   ├─ 1 ảnh → mode="single"
│   └─ Nhiều ảnh → mode="multiple"
├─ File bất kỳ → <input type="file">
└─ Camera trực tiếp → Camera API

3. Ngữ nghĩa & Phân loại (Semantics)

3.1. States

State Visual Token
Empty Dashed border, icon + CTA text --muted bg, --border dashed
Hover Border highlight --border--primary
Drag-over Blue-tinted bg + border --primary-muted bg, --primary border
Uploading Progress bar overlay progress component
Filled Thumbnail preview + remove btn Image preview, --comp-radius
Error Red border + error message --destructive border, error text
Disabled Muted, no interaction opacity: var(--disabled)

4. Token Mapping

📦 Atomic Mapping: Xem ATOMIC-MAPPING.md → mục file-upload — UI Layer: Card, Density Tier: section.

Property Token Ghi chú
Container bg (empty) --muted
Container border --border dashed 2px
Container border (hover) --primary dashed 2px
Container bg (drag-over) --primary-muted
Container radius --section-radius-default
Container padding --spacing-8 Generous padding
Container min-height 160px
Icon Lucide image-plus, 48px --muted-foreground
CTA text label-default --primary — clickable
Helper text caption --muted-foreground
Preview radius --comp-radius
Preview size 120px × 120px Thumbnail
Remove btn Solid circle overlay, Lucide x rgba(0,0,0,0.6) → neutral (reversible action)
Progress bar progress component --primary fill
Error text caption --destructive
Focus ring Standard focus ring :focus-visible
Transition --duration-fast-2 + --easing-standard Border color

5. Behavior

Feature Spec
Click Opens native file dialog (<input type="file">)
Drag & drop Desktop only — detect file drag, highlight zone
Mobile Click triggers system file picker / camera
File types accept="image/jpeg,image/png,image/webp"
Max size Default 5MB, configurable
Preview Generate URL.createObjectURL() thumbnail
Remove Clear selection, return to empty state
Replace Click on preview → opens file dialog to replace
Validation Client-side: type check + size check → show error

6. Props & API

typescript
interface ImageUploadProps {
  /** Single or multiple files */
  mode?: 'single' | 'multiple'; // default: 'single'
  /** Accepted file types */
  accept?: string; // default: 'image/jpeg,image/png,image/webp'
  /** Max file size in bytes */
  maxSize?: number; // default: 5 * 1024 * 1024 (5MB)
  /** Max files (for multiple mode) */
  maxFiles?: number; // default: 5
  /** Current value(s) */
  value?: File | File[] | string | string[];
  /** Change handler */
  onChange?: (files: File[]) => void;
  /** Error message */
  errorMessage?: string;
  /** Disabled state */
  disabled?: boolean;
  /** Helper text */
  helperText?: string; // default: "Max 5MB, JPG/PNG/WebP"
  /** Upload label */
  label?: string; // default: "Chọn ảnh hoặc kéo thả vào đây"
}

7. Accessibility (a11y)

  • Role: Container uses role="button" for click trigger
  • Label: aria-label="Upload image" on container
  • Keyboard: Enter/Space opens file dialog
  • Focus: :focus-visible outline on container
  • Screen reader: Announce "Upload area, press Enter to choose file"
  • Remove button: aria-label="Remove image"
  • Error: aria-describedby linking to error message
  • Drag area: Not keyboard accessible — click is primary, drag is enhancement

8. CSS Skeleton

css
/* ─── Container ─── */
.file-upload {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--spacing-3);
  min-height: 160px;
  padding: var(--spacing-8);
  border: 2px dashed var(--border);
  border-radius: var(--section-radius-default);
  background: var(--muted);
  cursor: pointer;
  transition: border-color var(--duration-fast-2) var(--easing-standard),
              background var(--duration-fast-2) var(--easing-standard);
  position: relative;
  text-align: center;
}

.file-upload:hover {
  border-color: var(--primary);
}

.file-upload:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

/* Drag-over */
.file-upload--dragover {
  border-color: var(--primary);
  background: var(--primary-muted);
}

/* Error */
.file-upload--error {
  border-color: var(--destructive);
}

/* Disabled */
.file-upload--disabled {
  opacity: var(--disabled);
  pointer-events: none;
}

/* ─── Icon ─── */
.file-upload__icon {
  width: 48px;
  height: 48px;
  color: var(--muted-foreground);
}

/* ─── CTA Text ─── */
.file-upload__cta {
  font-size: var(--font-size-sm);
  font-weight: var(--font-weight-medium);
  color: var(--primary);
}

/* ─── Helper Text ─── */
.file-upload__helper {
  font-size: var(--font-size-xs);
  color: var(--muted-foreground);
}

/* ─── Preview ─── */
.file-upload--filled {
  padding: var(--spacing-4);
}

.file-upload__preview {
  position: relative;
  width: 120px;
  height: 120px;
  border-radius: var(--comp-radius);
  overflow: hidden;
}

.file-upload__preview img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* ─── Remove button ─── */
/* Semantic: removing an image is REVERSIBLE → neutral overlay, NOT destructive */
.file-upload__remove {
  position: absolute;
  top: var(--spacing-1);
  right: var(--spacing-1);
  width: var(--spacing-7);
  height: var(--spacing-7);
  border-radius: var(--radius-full);
  background: rgba(0, 0, 0, 0.6);
  color: white;
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background var(--duration-fast-2) var(--easing-standard);
}

.file-upload__remove:hover {
  background: rgba(0, 0, 0, 0.8);
}

/* ─── Error message ─── */
.file-upload__error {
  font-size: var(--font-size-xs);
  color: var(--destructive);
  margin-top: var(--spacing-2);
}

/* Hidden file input */
.file-upload__input {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
  pointer-events: none;
}

@media (prefers-reduced-motion: reduce) {
  .file-upload { transition: none; }
}

9. Example Usage

jsx
{/* Basic single image upload */}
<ImageUpload
  label="Ảnh sản phẩm"
  helperText="Tối đa 5MB, JPG/PNG"
  onChange={(files) => setProductImage(files[0])}
/>

{/* With validation error */}
<ImageUpload
  errorMessage="File quá lớn. Vui lòng chọn ảnh dưới 5MB."
/>

{/* Multiple images */}
<ImageUpload
  mode="multiple"
  maxFiles={4}
  onChange={(files) => setGallery(files)}
/>