DOL Design System - Data Entry Patterns
Data Entry Patterns
Section titled “Data Entry Patterns”Extends CORE.md. Dành cho form nhập liệu, content authoring, Practice data entry. Đọc CORE.md trước - file này chỉ chứa patterns đặc thù cho Data Entry.
§1 Form Layout status:done
Section titled “§1 Form Layout status:done”Extends CORE.md §3 Typography + §4 Spacing + §9.5 Input. Playground refs:
Input.tsx,Select.tsx,Card.tsx.
1.1 Canonical Column Strategy
Section titled “1.1 Canonical Column Strategy”One column, one focus - single-column layout là default cho mọi form DOL.
Default: single-column, full-width fields. Because form = sequential completion, single column giữ user trong 1 scanning axis, reduce cognitive load, work universally responsive (no breakpoint gotchas).
Multi-column chỉ dùng khi BOTH conditions met:
- Fields ≥6 trong cùng fieldset (enough để split justifies)
- Fields semantically paired (city + zip, firstName + lastName, startDate + endDate)
Không split chỉ vì form “dài” - user vẫn phải đọc sequential regardless, multi-column làm eye bounce trái-phải.
| Context | Column strategy |
|---|---|
| Sign-up, login, reset password | Single column |
| Billing address, profile edit | Single column + paired multi-col rows for city/zip |
| Content authoring (exercise data) | Single column (editor width ≤ 640px) |
| Dashboard filters (inline) | Multi-column OK (scan mode, not sequential) |
1.2 Field Group Hierarchy
Section titled “1.2 Field Group Hierarchy”Spacing cho form follows CORE §4.1 atomic levels:
| Relationship | Hierarchy | Spacing | Tailwind |
|---|---|---|---|
| Label ↔ Input | B-molecule (tight) | 8px | space-y-2 / gap-2 |
| Field ↔ Field (inside fieldset) | C-organism | 24px | space-y-6 / gap-6 |
| Fieldset ↔ Fieldset | D-section | 40px | space-y-10 / gap-10 |
| Fieldset ↔ Submit button | D-section | 40px | mt-10 |
Rhythm prevents R11 slop-04 monotonous spacing (everything gap-4). Form phải visually grouped theo semantic.
1.3 Fieldset / Section Header
Section titled “1.3 Fieldset / Section Header”Khi form có ≥2 logical groups, prefix each group với section header.
<fieldset className="space-y-6"> <legend className="text-xs uppercase tracking-wide font-semibold text-slate-600 mb-4"> Thông tin liên hệ </legend> <p className="text-sm text-slate-500 mb-4">Dùng để gửi xác nhận đặt hàng.</p> {/* fields */}</fieldset>- Label class:
text-xs uppercase tracking-wide font-semibold text-slate-600 - Optional helper:
text-sm text-slate-500below label - Không dùng
text-xlheading cho fieldset (competes với page title) - Uppercase ở label ≤3 words OK (per DIRECTION §10 - không dùng cho body)
1.4 Label Anatomy
Section titled “1.4 Label Anatomy”Position: label always above input. Không floating label, không side label. Because DOL user đa phần không phải power user - predictable scanning matters.
<label htmlFor="email" className="block text-sm font-medium text-slate-900 mb-2"> Email <span className="text-red-500 ml-0.5" aria-hidden="true">*</span> <span className="sr-only">bắt buộc</span></label><input id="email" aria-required="true" />| Element | Pattern | Notes |
|---|---|---|
| Label text | text-sm font-medium text-slate-900 | Sentence case, VN, max 3 words ideal |
| Required marker | * in text-red-500 ml-0.5 + sr-only "bắt buộc" | A11y: screen reader reads “bắt buộc”; visual: red asterisk |
| Optional marker | (tùy chọn) in text-slate-500 ml-1 text-xs | Only khi mix required + optional; skip nếu tất cả optional |
| Helper text | Below input, text-xs text-slate-500 mt-1.5 | Format hint, example, constraint |
| Error text | Below input, text-xs text-red-600 mt-1.5 | Replace helper on error (§3.3) |
1.5 Responsive Form Layout
Section titled “1.5 Responsive Form Layout”Paired fields: collapse to single column < md breakpoint.
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <Input name="city" label="Thành phố" /> <Input name="zip" label="Mã bưu điện" /></div>- Single-field rows: full-width always, no grid needed
- Paired rows:
grid-cols-1 md:grid-cols-2(collapse mobile) - Không ≥3 columns cho form (breaks reading flow)
- Form container max-width:
max-w-md(448px) short forms,max-w-2xl(672px) longer
1.6 Input + Attached Card
Section titled “1.6 Input + Attached Card”Pattern dành cho input có supplementary content (suggestion chips, keyboard hint, helper, recent queries) cần feel “card attached underneath” thay vì floating loose dưới input.
Cấu trúc cốt lõi: outer container = NO padding / NO gap; primary input giữ chrome riêng (border + bg) phủ kín top section của container; supplementary content phía dưới TỰ thêm padding để bg subtle của outer container “show through” - tạo cảm giác card nhỏ inset attached dưới input.
{/* Outer wrapper: subtle bg, rounded, NO padding/gap */}<div className="bg-slate-50 rounded-lg flex flex-col"> {/* Primary input: own white bg + border, sits flush at top */} <input className="w-full bg-white border border-slate-200 rounded-lg px-3 py-2.5 text-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" placeholder="..." /> {/* Supplementary card: own padding — outer bg shows through here */} <div className="p-2 flex flex-wrap gap-1.5"> <button className="text-xs px-2 py-1 rounded bg-white border border-slate-200">#word</button> <button className="text-xs px-2 py-1 rounded bg-white border border-slate-200">#title</button> <button className="text-xs px-2 py-1 rounded bg-white border border-slate-200">#caption</button> </div></div>Hierarchy contract:
| Layer | Padding | Background | Why |
|---|---|---|---|
| Outer wrapper | p-0 | bg-slate-50 (subtle) | Rounded boundary + bg shows through ONLY in supplementary area |
| Primary input | own px-3 py-2.5 | bg-white | Covers outer bg at top - reads as standalone primary control |
| Supplementary card | own p-2 | inherits subtle bg | Visible padding inset → “attached card” feel |
Use cases:
| Input | Supplementary content |
|---|---|
| Layer/field name input | Auto-suggestion chips (#word, #title, etc.) |
| Textarea | Keyboard-shortcut hint (Press Shift+Enter to add line) |
| Search input | Recent queries dropdown |
| Tag input | Suggested tags from history |
| Email input | Domain auto-completion suggestions |
Anti-pattern: padding TRÊN outer wrapper - biến pattern thành “everything wrapped in a uniform box”, mất visual distinction giữa primary input và supplementary card. Outer container phải là transparent wrapper providing bg + radius, KHÔNG phải padded card itself.
{/* ❌ BAD — outer padding kills the "attached" feel */}<div className="bg-slate-50 rounded-lg p-3 flex flex-col gap-2"> <input className="..." /> <div className="flex flex-wrap gap-1.5">{chips}</div></div>
{/* ✅ GOOD — outer bare, children carry padding */}<div className="bg-slate-50 rounded-lg flex flex-col"> <input className="..." /> <div className="p-2 flex flex-wrap gap-1.5">{chips}</div></div>Cross-platform note: pattern works identically across Tailwind (web) và vanilla CSS (Figma plugins / widgets) vì cả hai consume cùng DS-token semantic layer:
| Semantic | Tailwind | DS-token CSS var |
|---|---|---|
| Subtle outer bg | bg-slate-50 | var(--fill-neutral-subtle) |
| Control radius | rounded-lg | var(--radius-control-lg-sub) |
| Outer padding/gap | p-0 (zero) | var(--spacing-space-0) |
| Inner padding | p-2 (8px) | var(--spacing-space-2) |
Reference implementations (Apps workspace plugins): Fill Texts to Layers - .pattern-box (layer pattern input + chips) và .content-box (textarea + keyboard hint).
§2 Input States status:done
Section titled “§2 Input States status:done”Extends CORE.md §9.5 Input recipe. Base classes = CORE canonical; this section documents deltas + additional states.
2.1 State Matrix
Section titled “2.1 State Matrix”Base input (CORE §9.5): w-full border border-slate-200 bg-white text-slate-900 placeholder:text-slate-500 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-lg px-3 py-2.5 text-sm
| State | Delta from base | When | ARIA |
|---|---|---|---|
| Default | (base) | Normal, no interaction | - |
| Focus | handled by base (focus:border-blue-500 focus:ring-2) | User clicks/tabs in | Browser native |
| Hover | add hover:border-slate-300 | Mouse over (optional, subtle) | - |
| Error | border-red-500 bg-red-50 text-red-900 focus:ring-red-500/20 | Validation failed | aria-invalid="true" + aria-describedby="<error-id>" |
| Disabled | opacity-60 cursor-not-allowed bg-slate-50 text-slate-500 | Prerequisite not met (role, plan, dependency) | aria-disabled="true" + disabled attr |
| Readonly | border-transparent bg-transparent px-0 plain text look | Value valid + locked (display-only) | readOnly attr (không disabled) |
| Loading | (base) + spinner trong rightIcon slot | Async op (validation, auto-save) | aria-busy="true" |
2.2 Disabled
Section titled “2.2 Disabled”Disabled = “cannot interact because prerequisite not met” (billing info required, feature gated by role).
<Input disabled aria-disabled="true" value="..." className="opacity-60 cursor-not-allowed bg-slate-50 text-slate-500" />Rule: luôn pair opacity + cursor + bg change. Disabling without visual = user confusion “tại sao không gõ được?“.
2.3 Readonly
Section titled “2.3 Readonly”Readonly = “value displayed, not editable”. Different from disabled: value IS valid, just locked.
Use case: show email sau sign-up (không đổi được trên profile), display-only computed values.
<Input readOnly value="user@dol.vn" className="border-transparent bg-transparent px-0 font-medium text-slate-900" />Visually: feels like plain text, not chrome’d input. Removes input “affordance” (border, bg) vì user không act on it.
2.4 Loading
Section titled “2.4 Loading”Async validation / auto-save / autocomplete: input giữ shape intact + spinner in rightIcon slot.
<Input value={email} rightIcon={isValidating ? <Spinner className="animate-spin text-slate-400" /> : null} aria-busy={isValidating}/>- Không replace input với spinner (loses context of what’s loading)
- Không block input (user có thể continue typing; debounce handles rapid changes)
- Reference CORE §11 anti-pattern “Loading state trắng trơn”
2.5 Icon Slots
Section titled “2.5 Icon Slots”Playground pattern (Input.tsx / Select.tsx): absolute-positioned icon với padding offset.
<div className="relative"> <Mail className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400" size={18} /> <input className="pl-11 pr-3 ..." /></div>- Left icon: context cue (domain indicator - mail, phone, calendar, lock)
- Right icon: action / status (clear button, loading spinner, dropdown chevron, validation check)
- Padding offset:
pl-11(44px) cho left icon,pr-11cho right, both dùngpl-11 pr-11 - Icon color:
text-slate-400default; inherit state color (error →text-red-500)
🔗 R13 promotion candidate: icon-slot pattern xuất hiện ở cả
Input.tsx+Select.tsx. Nếu ≥2 more surfaces adopt without divergence → promote sub-recipe vào CORE §9.5.
§3 Validation status:done
Section titled “§3 Validation status:done”Extends CORE.md §9.5 (Input error state) + §9.7 (Alert) + §2.3 (Status Colors). Below = strategy + copy + anti-patterns.
3.1 Philosophy
Section titled “3.1 Philosophy”Validate on blur, never on keystroke - user đang gõ = intent chưa commit; validate tại blur = user finished with field, feedback welcome.
Validate-on-keystroke = angry red flashing mỗi ký tự, feels hostile. DOL = calm education tone (DIRECTION §1) - validation phải helpful, không accusatory.
3.2 Validation Trigger Matrix
Section titled “3.2 Validation Trigger Matrix”| Trigger | When | Example |
|---|---|---|
| Blur (field-level) | User tabs/clicks out | Email format, password strength, date range |
| Submit (form-level) | User clicks Submit | Required fields check, cross-field constraints |
| Async debounce | Server check needed (800ms sau last keystroke) | Uniqueness (username available?), format (invoice ID exists?) |
| ❌ Keystroke | - | Never |
3.3 Error Message Placement
Section titled “3.3 Error Message Placement”Inline, below input, tied via aria-describedby:
<Input id="email" aria-invalid={!!error} aria-describedby={error ? "email-error" : undefined} />{error && ( <p id="email-error" className="text-xs text-red-600 mt-1.5"> {error} </p>)}- Position: immediately below input (không tooltip, không sidebar)
- Replaces helper text khi error active (same slot, different color)
- Reference CORE §9.5 error state classes - không restate
- Không dùng red border alone - text message REQUIRED (user cần biết PHẢI sửa gì)
3.4 Form-level Error Summary
Section titled “3.4 Form-level Error Summary”Submit với ≥3 errors → render summary alert above form, link to each field.
<Alert variant="error"> <AlertTitle>Vui lòng kiểm tra {errors.length} lỗi:</AlertTitle> <ul className="list-disc pl-5 mt-2 space-y-1 text-sm"> {errors.map(e => ( <li key={e.fieldId}> <a href={`#${e.fieldId}`} className="underline">{e.label}</a>: {e.message} </li> ))} </ul></Alert>- Base: CORE §9.7 Alert
bg-red-50 border border-red-200 text-red-700 - Summary appears on Submit, không on blur (avoid scroll jarring mid-flow)
- Clicking anchor scrolls + focuses field
- Không duplicate inline error - summary links to overview, inline shows detail
3.5 Success Indicator
Section titled “3.5 Success Indicator”Successful async validation: show text-green-600 check icon trong rightIcon slot.
<Input value={email} rightIcon={isValid ? <Check className="text-green-600" size={18} /> : null} aria-describedby={isValid ? "email-valid" : undefined} /><span id="email-valid" className="sr-only">Email hợp lệ</span>- Không full green border by default (reserved cho payment / confirmation pages nơi “every step counts”)
- Check icon only - minimal affordance
- Không “Email đúng định dạng!” text (redundant, adds noise)
- Reference CORE §2.3 Status colors (
text-green-600= success text)
3.6 Character Count / Limit
Section titled “3.6 Character Count / Limit”Field có max length (bio, description, message):
<div className="flex justify-end text-xs tabular-nums mt-1.5"> <span className={cn( "text-slate-500", length >= max * 0.9 && "text-amber-600", length >= max && "text-red-500" )}> {length}/{max} </span></div>- Position: below input, right-aligned
- Font:
tabular-nums(prevents width shift khi digits change) - Color progression: slate → amber @ 90% → red @ 100%
- Không block typing past max (let user paste overflow, flag + trim on blur) - respect user agency
3.7 Anti-patterns
Section titled “3.7 Anti-patterns”🛑 Match-and-refuse (per DIRECTION §10): nếu bạn sắp generate code/copy khớp một row dưới, STOP - rewrite approach từ đầu, không chỉ swap value lẻ.
<dol_anti_pattern scope=“data-entry-3.7-validation”>
| ID | ❌ PATTERN | Vì sao | ✅ REWRITE |
|---|---|---|---|
| 01 | Validate on keystroke (every char) | Angry red flash mỗi ký tự; feels hostile, wrong DOL education tone | Blur-only (§3.1) |
| 02 | Required marker * without a11y label | Screen reader user không nghe “bắt buộc”; accessibility fail | * + <span class="sr-only">bắt buộc</span> (§1.4) |
| 03 | Error as modal / toast | Modal pulls user out of flow; toast disappears before user fixes | Inline below field (§3.3) |
| 04 | Generic “Invalid input” / “Error” copy | Không giúp fix; user phải đoán sai cái gì | VN + actionable + example: “Email chưa đúng định dạng. Ví dụ: ten@dol.vn” |
| 05 | English error messages | Mixed-language UI vi phạm DIRECTION §6 typography | VN only cho user-facing error/label; English only trong code/ARIA technical |
| 06 | Red border alone, no text | Color-only state (CVD fails); user không biết fix gì | Red border + text message (§3.3) |
| 07 | Block typing past max length | Paste users punished; user agency bị override | Allow overflow, show count red (§3.6), trim/flag on blur |
</dol_anti_pattern>
§4 Multi-step Flow status:done
Section titled “§4 Multi-step Flow status:done”Extends CORE.md §4 (Spacing) + §9.2-9.3 (Button) + §10 (Motion). Below = step decomposition strategy + save-draft contract + anti-patterns.
4.1 Philosophy
Section titled “4.1 Philosophy”Show the whole map, highlight the current stop - user phải thấy “tôi đang ở đâu trong quá trình này” suốt cả flow, không chỉ ở màn hiện tại.
Multi-step tồn tại khi (a) form dài ≥ 8 fields, (b) fields chia được theo chủ đề (account info / billing / preferences), (c) user cần mental break giữa các nhóm. Short forms (≤ 6 fields) → single-page ngay - đừng “chia bước” để trông chuyên nghiệp.
4.2 Step Indicator
Section titled “4.2 Step Indicator”Horizontal, numbered, trên đầu form. 3-6 steps optimum; > 6 → reconsider scope hoặc dùng vertical sidebar.
<ol className="flex items-center gap-2 text-sm"> {steps.map((s, i) => ( <li key={s.id} className="flex items-center gap-2"> <span className={cn( "w-7 h-7 rounded-full grid place-items-center text-xs font-semibold", i < current && "bg-brand-600 text-on-inverse-primary", i === current && "bg-brand-50 border-2 border-brand-600 text-brand-700", i > current && "bg-slate-100 text-slate-500" )}> {i < current ? <Check size={14} /> : i + 1} </span> <span className={cn( i === current ? "text-slate-900 font-medium" : "text-slate-500" )}>{s.label}</span> {i < steps.length - 1 && <ChevronRight size={14} className="text-slate-300" />} </li> ))}</ol>- Done state: filled brand circle + checkmark (NOT text “Done” - wastes width)
- Current state: outlined ring + bold label - clear focal without shout
- Pending: muted slate, no emphasis
- Mobile: ẩn labels, chỉ số + current label (
md:blockon label spans)
4.3 Navigation Footer
Section titled “4.3 Navigation Footer”Bottom of form panel, sticky nếu form scroll dài. Back left / Next right, following reading direction.
<div className="flex items-center justify-between pt-6 border-t border-slate-100"> {current > 0 ? ( <Button variant="ghost" onClick={prev}> <ArrowLeft size={16} /> Quay lại </Button> ) : <span /> /* keep layout */}
<div className="flex items-center gap-3"> {canSkip && <Button variant="text" onClick={skip}>Bỏ qua bước này</Button>} <Button variant="primary" onClick={next} disabled={!stepValid}> {isLast ? 'Hoàn tất' : 'Tiếp tục'} <ArrowRight size={16} /> </Button> </div></div>- Back =
ghost(de-emphasized - forward progress is the goal) - Next =
primary(CORE §9.2) - one clear call to action per step - Skip =
textvariant, chỉ hiện khi step thực sự optional (NOT “skip to escape validation”) - Last step: Next label → “Hoàn tất” (VN), not “Submit”/“Finish”
4.4 Save Draft Contract
Section titled “4.4 Save Draft Contract”Autosave debounce 1500ms after last field change. Silent (không toast) - chỉ hiện indicator ở header:
<span className="text-xs text-slate-500 flex items-center gap-1.5"> {savingState === 'saving' && (<><Loader2 size={12} className="animate-spin" /> Đang lưu…</>)} {savingState === 'saved' && (<><Check size={12} className="text-green-600" /> Đã lưu nháp</>)} {savingState === 'error' && (<><AlertCircle size={12} className="text-red-500" /> Chưa lưu được, thử lại sau</>)}</span>sr-onlylive region cập nhật khi state chuyển → screen reader hears “Đã lưu nháp”- Draft persists cross-session (localStorage + server per auth state)
- Khi user Back: preload input values từ draft, không empty re-render
- Toast chỉ hiện khi resume after session gap - “Đã khôi phục bản nháp từ [thời điểm]” (trust signal, không noise)
4.5 Step Validation Gate
Section titled “4.5 Step Validation Gate”Next button validates current step only - không block theo cross-step dependencies cho đến Submit cuối.
| Event | Action |
|---|---|
| Click Next, step invalid | Focus first invalid field + inline error (§3.3) + ARIA live “Vui lòng sửa {n} lỗi trước khi tiếp tục” |
| Click Next, step valid | Advance, scroll to top of next step, focus first input |
| Click Back | No validation (let user review without friction) |
| Click step indicator số cũ | Jump back, không validate forward steps |
| Submit cuối | Validate TẤT CẢ steps; summary (§3.4) linking to step + field |
4.6 Anti-patterns
Section titled “4.6 Anti-patterns”🛑 Match-and-refuse (per DIRECTION §10): nếu bạn sắp generate code khớp một row dưới, STOP - rewrite approach từ đầu, không chỉ swap value lẻ.
<dol_anti_pattern scope=“data-entry-4.6-multistep-flow”>
| ID | ❌ PATTERN | Vì sao | ✅ REWRITE |
|---|---|---|---|
| 01 | ”Next” button hoạt động nhưng step invalid (validate chỉ ở submit) | User tiến 4 bước mới biết bước 1 sai; lost work | Validate step hiện tại on Next-click (§4.5) |
| 02 | Step indicator ẩn steps ahead | User mù định thông tin - “còn bao nhiêu bước nữa?” | Show TẤT CẢ steps với current highlighted (§4.2) |
| 03 | Autosave toast mỗi 1500ms | ”Đã lưu” flashing mỗi khi gõ → noise, desensitizes user | Silent indicator ở header (§4.4), toast chỉ khi resume session |
| 04 | Progress bar % không map với steps | ”82% complete” nhưng user không biết bước nào = vague | Numbered steps + current highlight (§4.2); % optional phụ trợ |
| 05 | Wizard animation slide-between-steps (transform transition) | Motion distracts từ form filling; mobile choppy | Instant swap + animate-fade-in 150ms on new step content |
| 06 | Block Back button after completing step | Prevents correction; hostile UX | Always allow Back; validation only forward (§4.5) |
</dol_anti_pattern>
§5 Rich Content Input status:done
Section titled “§5 Rich Content Input status:done”Extends CORE.md §9.5 (Input container/focus) + §6 (Surface elevation) + §8 (Borders). Below = content-creation surfaces (editor, upload, preview) + anti-patterns.
5.1 Rich Text Editor
Section titled “5.1 Rich Text Editor”Editor container = single cohesive block: toolbar on top, content area below, shared border.
<div className="rounded-xl border border-slate-200 bg-white overflow-hidden focus-within:border-brand-500 focus-within:ring-4 focus-within:ring-brand-100"> {/* Toolbar */} <div className="flex items-center gap-1 px-2 py-1.5 border-b border-slate-100 bg-slate-50"> <button type="button" className="p-1.5 rounded hover:bg-slate-200 text-slate-600" aria-label="Bold"> <Bold size={16} /> </button> {/* Italic, Link, List, Code, … */} <span className="w-px h-5 bg-slate-200 mx-1" /> {/* divider */} {/* Group 2: H1/H2, Quote */} </div>
{/* Content */} <div contentEditable className="min-h-[180px] max-h-[480px] overflow-y-auto px-4 py-3 text-sm text-slate-900 outline-none prose prose-slate prose-sm" /></div>- Toolbar
bg-slate-50(NOT white-on-white - §8.2 invisible border rule) - Toolbar button: square 28px, icon
size={16}- consistent với CORE §9.3 icon-button hitbox - Focus travels on container (not individual content area) - entire block signals active
- Height:
min-h-[180px]default;max-htrước khi scroll kicks in = prevents runaway editor pushing Submit off-screen - Content area inherits
prose prose-smfor formatted output (Tailwind Typography plugin)
5.2 Image Upload (Dropzone)
Section titled “5.2 Image Upload (Dropzone)”Drag-drop zone with fallback click-to-browse. Empty state vs populated state have different layouts.
{files.length === 0 ? ( <label className="block border-2 border-dashed border-slate-200 rounded-xl p-8 text-center cursor-pointer bg-slate-50 hover:border-brand-400 hover:bg-brand-50/50 transition-colors"> <ImagePlus size={32} className="mx-auto text-slate-400" /> <p className="mt-2 text-sm text-slate-600"> Kéo ảnh vào đây, hoặc <span className="text-brand-700 underline">chọn file</span> </p> <p className="mt-1 text-xs text-slate-500">PNG, JPG, WEBP · tối đa 5MB</p> <input type="file" accept="image/*" multiple className="sr-only" onChange={handle} /> </label>) : ( <div className="grid grid-cols-3 md:grid-cols-4 gap-3"> {files.map(f => <ImageThumb key={f.id} file={f} onRemove={remove} />)} <label className="aspect-square border-2 border-dashed border-slate-200 rounded-xl grid place-items-center cursor-pointer hover:border-brand-400"> <Plus size={20} className="text-slate-400" /> </label> </div>)}- Empty state: dashed border + large icon + constraints text (format, size) upfront - user không upload rồi mới biết bị reject
- Drag-over state (via drag events):
border-brand-500 bg-brand-50- affordance engaged - Populated state: grid of thumbnails (aspect-square +
object-cover) + “add more” tile cùng shape - Thumbnail hover:
×remove button top-right (NOT button dưới thumbnail - crowds rồi) - Reject handling: inline text below zone,
text-red-600 text-xs- “File {name} quá lớn. Giới hạn 5MB.”
5.3 Audio Upload
Section titled “5.3 Audio Upload”Upload zone tương tự §5.2 nhưng preview = player row, không thumbnail:
<div className="flex items-center gap-3 p-3 rounded-lg border border-slate-200 bg-white"> <button className="w-9 h-9 rounded-full bg-brand-600 text-on-inverse-primary grid place-items-center"> <Play size={16} /> </button> <div className="flex-1 min-w-0"> <p className="text-sm text-slate-900 truncate">{file.name}</p> <p className="text-xs text-slate-500 tabular-nums">{duration} · {sizeMB}MB</p> </div> <button aria-label="Xoá file" className="text-slate-400 hover:text-red-600"> <Trash2 size={16} /> </button></div>- Row height consistent với input height (CORE §9.5) - stacks nicely in form
- Tabular-nums prevents duration jitter
- Reference practice.md §6.1 audio player for playback UX - file này chỉ cover upload container
5.4 File Attachment List
Section titled “5.4 File Attachment List”Multi-file attachment (docs, PDFs, misc): row list, không grid.
<ul className="divide-y divide-slate-100 border border-slate-200 rounded-xl overflow-hidden"> {files.map(f => ( <li key={f.id} className="flex items-center gap-3 px-3 py-2.5"> <FileIcon type={f.type} className="text-slate-500" /> <div className="flex-1 min-w-0"> <p className="text-sm text-slate-900 truncate">{f.name}</p> {f.uploading ? <ProgressBar value={f.progress} className="mt-1" /> : <p className="text-xs text-slate-500">{f.sizeLabel}</p>} </div> <button aria-label={`Xoá ${f.name}`} className="text-slate-400 hover:text-red-600"> <X size={16} /> </button> </li> ))}</ul>- Divide-y (CORE §8) between rows - single list container, not individual cards
- Progress bar replaces size text while uploading - same vertical slot, no layout shift
- Truncate long filenames (
truncatetrên name,min-w-0trên parent - classic flex truncation)
5.5 Markdown Input with Preview
Section titled “5.5 Markdown Input with Preview”Decision rule: tab switch (Write / Preview) cho mobile + medium forms. Split view (side-by-side) cho desktop + content-heavy (blog post, article).
{/* Tab-switch variant */}<div className="rounded-xl border border-slate-200 bg-white"> <div role="tablist" className="flex border-b border-slate-100"> <button role="tab" aria-selected={mode === 'write'} className={cn( "px-4 py-2 text-sm", mode === 'write' ? "text-brand-700 border-b-2 border-brand-600" : "text-slate-500 hover:text-slate-700" )}>Viết</button> <button role="tab" aria-selected={mode === 'preview'} …>Xem trước</button> </div> <div className="p-4"> {mode === 'write' ? <textarea className="w-full min-h-[240px] text-sm font-mono outline-none resize-none" /> : <div className="prose prose-sm max-w-none" dangerouslySetInnerHTML={{ __html: html }} />} </div></div>- Textarea font
font-mono- signals “code-like / markdown syntax” context - Preview uses
prose- same typography rules as published output (parity signal) - Tab switching = instant swap (không animation between modes - user expects direct)
5.6 Anti-patterns
Section titled “5.6 Anti-patterns”🛑 Match-and-refuse (per DIRECTION §10): nếu bạn sắp generate code khớp một row dưới, STOP - rewrite approach từ đầu, không chỉ swap value lẻ.
<dol_anti_pattern scope=“data-entry-5.6-rich-content”>
| ID | ❌ PATTERN | Vì sao | ✅ REWRITE |
|---|---|---|---|
| 01 | Rich editor toolbar floats trên content (không shared border) | White-on-white (vi phạm CORE §8.2); visual disconnect | Single container với toolbar bg-slate-50 + shared border (§5.1) |
| 02 | Dropzone không show format + size limit trước upload | User upload rồi mới biết fail - wasted time + frustration | Empty state hiển thị “PNG, JPG · max 5MB” upfront (§5.2) |
| 03 | Remove button dưới thumbnail (not overlay) | Crowds grid, cần row phụ; inconsistent với mental model | × top-right overlay on hover (§5.2) |
| 04 | Progress bar bên dưới row (tạo vertical shift) | Layout nhảy khi upload complete → jitter | Progress bar trong name/size slot (§5.4) |
| 05 | Markdown preview mở modal / new tab | User mất context, re-position cursor | Inline tab-switch hoặc split view (§5.5) |
| 06 | Editor height fixed tall (~600px) luôn | Waste space khi content short, giant empty void | min-h + max-h range (§5.1), grows with content |
| 07 | Toolbar icon-tile-above label pattern | Each icon-tile-above = Impeccable slop-06; toolbar quá phức tạp | Flat icon-only buttons với tooltip on hover (§5.1) |
</dol_anti_pattern>
§6 Data Tables status:done
Section titled “§6 Data Tables status:done”Extends CORE.md §8 (Borders/Dividers) + §4 (Spacing) + §3 (Typography) + §9.5 (Input cho inline edit). Below = data-density rules + sort/select/edit patterns + anti-patterns.
6.1 Philosophy
Section titled “6.1 Philosophy”Tables are for scanning, not for reading - user quét hàng loạt rows tìm một signal cụ thể (số lớn nhất, status đỏ, tên trùng). Mọi decision về typography / spacing / color phải serve scanning speed.
Dùng table khi (a) data có structured columns, (b) user so sánh giữa rows, (c) > 5 rows có ý nghĩa. Dưới ngưỡng → card list / definition list dễ đọc hơn.
6.2 Table Layout
Section titled “6.2 Table Layout”<div className="rounded-xl border border-slate-200 bg-white overflow-hidden"> <table className="w-full text-sm"> <thead className="bg-slate-50 text-slate-600 text-xs uppercase tracking-wide"> <tr> <th className="px-4 py-3 text-left font-semibold">Tên</th> <th className="px-4 py-3 text-left font-semibold">Trạng thái</th> <th className="px-4 py-3 text-right font-semibold tabular-nums">Số lượng</th> <th className="px-4 py-3 text-right font-semibold">Thao tác</th> </tr> </thead> <tbody className="divide-y divide-slate-100"> {rows.map(r => ( <tr key={r.id} className="hover:bg-slate-50 transition-colors"> <td className="px-4 py-3 text-slate-900">{r.name}</td> <td className="px-4 py-3"><StatusBadge value={r.status} /></td> <td className="px-4 py-3 text-right tabular-nums text-slate-700">{r.count}</td> <td className="px-4 py-3 text-right">{/* actions */}</td> </tr> ))} </tbody> </table></div>- Header:
bg-slate-50(consistent với CORE tab container tone - NOT slate-100) + uppercase small caps - Row:
px-4 py-3mặc định;py-2nếu compact mode (> 30 rows visible simultaneously) - Divide-y between rows, NOT border-y - single-line dividers at
slate-100avoid grid-heavy feel (anti-pattern: bordered cells = spreadsheet feel, wrong for read-oriented UI) - Row hover:
bg-slate-50(same tone as header - row being inspected tạm thời leveled với header) - Container border + rounded → table feels like cohesive object, không floating rows
6.3 Column Alignment + Sort
Section titled “6.3 Column Alignment + Sort”| Content type | Alignment | Notes |
|---|---|---|
| Text (name, label) | Left | Default |
| Numeric (count, price, %) | Right + tabular-nums | Digits align by decimal visually |
| Status badge | Left | Pattern signature leads |
| Date | Left | Format VN: dd/mm/yyyy |
| Actions (icon buttons) | Right | Kebab menu / inline icons end-of-row |
Sortable column = header is <button>, chevron icon right:
<th className="px-4 py-3 text-left"> <button className="inline-flex items-center gap-1 font-semibold hover:text-slate-900" onClick={() => toggleSort('name')}> Tên {sortKey === 'name' ? ( sortDir === 'asc' ? <ChevronUp size={14} /> : <ChevronDown size={14} /> ) : <ChevronsUpDown size={14} className="text-slate-400" />} </button></th>- Unsorted column: double-chevron in muted slate - affords sortability without shout
- Active sort: single chevron in current sort direction - state unambiguous
- Only ONE active sort at a time (multi-sort adds complexity without scanning benefit in most DOL contexts)
6.4 Row Selection
Section titled “6.4 Row Selection”Checkbox column far left, bulk action bar appears above table on any selection:
{selectedCount > 0 && ( <div className="flex items-center justify-between px-4 py-2.5 bg-brand-50 border-b border-brand-100"> <span className="text-sm text-brand-800">Đã chọn {selectedCount} mục</span> <div className="flex items-center gap-2"> <Button size="sm" variant="ghost" onClick={clear}>Bỏ chọn</Button> <Button size="sm" variant="danger" onClick={bulkDelete}>Xoá đã chọn</Button> </div> </div>)}- Header checkbox = select-all-visible (NOT select-all-across-pages - dangerous; require explicit “Chọn tất cả N mục” link inside bulk bar)
- Bar tone =
bg-brand-50(tint, not full brand) - presence affordance without visual pressure - Destructive bulk actions MUST ask confirm (modal) - CORE §11 “Destructive without confirm”
6.5 Inline Editing Cells
Section titled “6.5 Inline Editing Cells”Trigger: double-click cell (keyboard: Enter when focused). Commit on blur + Enter; cancel on Escape.
<td className="px-4 py-3"> {editing === row.id ? ( <input autoFocus defaultValue={row.name} onBlur={e => commit(row.id, e.target.value)} onKeyDown={e => { if (e.key === 'Enter') commit(row.id, e.currentTarget.value); if (e.key === 'Escape') cancel(); }} className="w-full px-2 py-1 -mx-2 -my-1 border border-brand-500 rounded text-sm bg-white outline-none ring-4 ring-brand-100" /> ) : ( <button className="text-left text-slate-900 hover:underline decoration-dotted" onDoubleClick={() => startEdit(row.id)}>{row.name}</button> )}</td>- Edit state reuses CORE §9.5 focus ring (smaller) - consistent affordance language
- Negative margins cancel cell padding so input fills cell without layout shift
- Underline-on-hover (decoration-dotted) = subtle affordance; user không cần đoán cell editable
- Escape discards; Enter/blur commits - standard spreadsheet contract, match user expectation
6.6 Pagination vs Infinite Scroll
Section titled “6.6 Pagination vs Infinite Scroll”| Use case | Choice |
|---|---|
| Admin CRUD / management lists | Pagination - users reference rows by position (“row 23 on page 2”) |
| Activity feeds / logs / social-style | Infinite scroll - chronological, user consumes sequentially |
| Exports / reports view | Pagination + jump-to-page input (large datasets) |
| Search results trong flow | Pagination (back-button returns to same page) |
Default cho DOL admin surfaces = pagination. Page size: 25 (desktop dense) / 10 (mobile).
6.7 Empty State + Loading
Section titled “6.7 Empty State + Loading”Empty (no data yet): illustrated placeholder + concrete next action, NOT just “No data”.
<div className="py-16 text-center"> <InboxIcon className="mx-auto text-slate-300" size={48} /> <h3 className="mt-3 text-base font-semibold text-slate-700">Chưa có bản ghi nào</h3> <p className="mt-1 text-sm text-slate-500">Thêm mục đầu tiên để bắt đầu theo dõi.</p> <Button variant="primary" size="sm" className="mt-4" onClick={openCreate}> <Plus size={16} /> Thêm mới </Button></div>Loading: skeleton rows (NOT spinner) - preserves layout, reduces perceived wait.
<tr><td colSpan={4} className="px-4 py-3"> <div className="h-4 bg-slate-100 rounded animate-pulse" /></td></tr>- Render 5-10 skeleton rows matching expected row count (short table → short skeleton)
- Reference CORE §11 anti-pattern “Loading state blank” - never empty tbody while loading
6.8 Anti-patterns
Section titled “6.8 Anti-patterns”🛑 Match-and-refuse (per DIRECTION §10 +
data-tables-scanning-philosophy.md): nếu bạn sắp generate table code khớp một row dưới, STOP - rewrite approach từ đầu, không chỉ swap value lẻ. Tables-for-scanning philosophy = orthogonal layer over universal slop.
<dol_anti_pattern scope=“data-entry-6.8-data-tables”>
| ID | ❌ PATTERN | Vì sao | ✅ REWRITE |
|---|---|---|---|
| 01 | Bordered cells (full grid) | Spreadsheet feel; visual noise scans slower | divide-y divide-slate-100 only (§6.2) |
| 02 | Numeric cells left-aligned | Digits misalign → hard compare rows | text-right tabular-nums (§6.3) |
| 03 | Select-all checkbox = “all rows everywhere” | User delete 1,247 hidden rows by accident | Select-visible + explicit “Chọn tất cả N” link (§6.4) |
| 04 | Inline edit commits silently on Enter (no visual feedback) | User không biết save thành công | Flash subtle bg-green-50 cell 400ms sau commit (§6.5 + CORE §10 motion) |
| 05 | Empty state = blank tbody + “No data” text | Dead end; user không biết next action | Illustrated + primary CTA (§6.7) |
| 06 | Loading = centered spinner replacing table | Layout shift → jittery experience | Skeleton rows trong tbody (§6.7) |
| 07 | Page size > 50 on mobile | Endless scroll inside bounded container = trapped | 10 rows mobile, 25 desktop (§6.6) |
| 08 | Row click = navigate AND checkbox = select | Double gesture, ambiguous intent | Checkbox = select only; row hover reveals explicit “Xem chi tiết” action |
</dol_anti_pattern>