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/Spaceopens file dialog - Focus:
:focus-visibleoutline on container - Screen reader: Announce "Upload area, press Enter to choose file"
- Remove button:
aria-label="Remove image" - Error:
aria-describedbylinking 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)}
/>