Skip to content

DOL Design System - Data Entry Patterns

active v0.4Updated

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.


Extends CORE.md §3 Typography + §4 Spacing + §9.5 Input. Playground refs: Input.tsx, Select.tsx, Card.tsx.

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:

  1. Fields ≥6 trong cùng fieldset (enough để split justifies)
  2. 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.

ContextColumn strategy
Sign-up, login, reset passwordSingle column
Billing address, profile editSingle 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)

Spacing cho form follows CORE §4.1 atomic levels:

RelationshipHierarchySpacingTailwind
Label ↔ InputB-molecule (tight)8pxspace-y-2 / gap-2
Field ↔ Field (inside fieldset)C-organism24pxspace-y-6 / gap-6
Fieldset ↔ FieldsetD-section40pxspace-y-10 / gap-10
Fieldset ↔ Submit buttonD-section40pxmt-10

Rhythm prevents R11 slop-04 monotonous spacing (everything gap-4). Form phải visually grouped theo semantic.

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-500 below label
  • Không dùng text-xl heading cho fieldset (competes với page title)
  • Uppercase ở label ≤3 words OK (per DIRECTION §10 - không dùng cho body)

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" />
ElementPatternNotes
Label texttext-sm font-medium text-slate-900Sentence 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-xsOnly khi mix required + optional; skip nếu tất cả optional
Helper textBelow input, text-xs text-slate-500 mt-1.5Format hint, example, constraint
Error textBelow input, text-xs text-red-600 mt-1.5Replace helper on error (§3.3)

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

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:

LayerPaddingBackgroundWhy
Outer wrapperp-0bg-slate-50 (subtle)Rounded boundary + bg shows through ONLY in supplementary area
Primary inputown px-3 py-2.5bg-whiteCovers outer bg at top - reads as standalone primary control
Supplementary cardown p-2inherits subtle bgVisible padding inset → “attached card” feel

Use cases:

InputSupplementary content
Layer/field name inputAuto-suggestion chips (#word, #title, etc.)
TextareaKeyboard-shortcut hint (Press Shift+Enter to add line)
Search inputRecent queries dropdown
Tag inputSuggested tags from history
Email inputDomain 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:

SemanticTailwindDS-token CSS var
Subtle outer bgbg-slate-50var(--fill-neutral-subtle)
Control radiusrounded-lgvar(--radius-control-lg-sub)
Outer padding/gapp-0 (zero)var(--spacing-space-0)
Inner paddingp-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).


Extends CORE.md §9.5 Input recipe. Base classes = CORE canonical; this section documents deltas + additional states.

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

StateDelta from baseWhenARIA
Default(base)Normal, no interaction-
Focushandled by base (focus:border-blue-500 focus:ring-2)User clicks/tabs inBrowser native
Hoveradd hover:border-slate-300Mouse over (optional, subtle)-
Errorborder-red-500 bg-red-50 text-red-900 focus:ring-red-500/20Validation failedaria-invalid="true" + aria-describedby="<error-id>"
Disabledopacity-60 cursor-not-allowed bg-slate-50 text-slate-500Prerequisite not met (role, plan, dependency)aria-disabled="true" + disabled attr
Readonlyborder-transparent bg-transparent px-0 plain text lookValue valid + locked (display-only)readOnly attr (không disabled)
Loading(base) + spinner trong rightIcon slotAsync op (validation, auto-save)aria-busy="true"

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?“.

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.

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”

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-11 cho right, both dùng pl-11 pr-11
  • Icon color: text-slate-400 default; 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.


Extends CORE.md §9.5 (Input error state) + §9.7 (Alert) + §2.3 (Status Colors). Below = strategy + copy + anti-patterns.

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.

TriggerWhenExample
Blur (field-level)User tabs/clicks outEmail format, password strength, date range
Submit (form-level)User clicks SubmitRequired fields check, cross-field constraints
Async debounceServer check needed (800ms sau last keystroke)Uniqueness (username available?), format (invoice ID exists?)
Keystroke-Never

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ì)

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

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)

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

🛑 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❌ PATTERNVì sao✅ REWRITE
01Validate on keystroke (every char)Angry red flash mỗi ký tự; feels hostile, wrong DOL education toneBlur-only (§3.1)
02Required marker * without a11y labelScreen reader user không nghe “bắt buộc”; accessibility fail* + <span class="sr-only">bắt buộc</span> (§1.4)
03Error as modal / toastModal pulls user out of flow; toast disappears before user fixesInline below field (§3.3)
04Generic “Invalid input” / “Error” copyKhô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
05English error messagesMixed-language UI vi phạm DIRECTION §6 typographyVN only cho user-facing error/label; English only trong code/ARIA technical
06Red border alone, no textColor-only state (CVD fails); user không biết fix gìRed border + text message (§3.3)
07Block typing past max lengthPaste users punished; user agency bị overrideAllow overflow, show count red (§3.6), trim/flag on blur

</dol_anti_pattern>


Extends CORE.md §4 (Spacing) + §9.2-9.3 (Button) + §10 (Motion). Below = step decomposition strategy + save-draft contract + anti-patterns.

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.

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:block on label spans)

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 = text variant, 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”

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-only live 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)

Next button validates current step only - không block theo cross-step dependencies cho đến Submit cuối.

EventAction
Click Next, step invalidFocus 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 validAdvance, scroll to top of next step, focus first input
Click BackNo validation (let user review without friction)
Click step indicator số cũJump back, không validate forward steps
Submit cuốiValidate TẤT CẢ steps; summary (§3.4) linking to step + field

🛑 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❌ PATTERNVì 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 workValidate step hiện tại on Next-click (§4.5)
02Step indicator ẩn steps aheadUser 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)
03Autosave toast mỗi 1500ms”Đã lưu” flashing mỗi khi gõ → noise, desensitizes userSilent indicator ở header (§4.4), toast chỉ khi resume session
04Progress bar % không map với steps”82% complete” nhưng user không biết bước nào = vagueNumbered steps + current highlight (§4.2); % optional phụ trợ
05Wizard animation slide-between-steps (transform transition)Motion distracts từ form filling; mobile choppyInstant swap + animate-fade-in 150ms on new step content
06Block Back button after completing stepPrevents correction; hostile UXAlways allow Back; validation only forward (§4.5)

</dol_anti_pattern>


Extends CORE.md §9.5 (Input container/focus) + §6 (Surface elevation) + §8 (Borders). Below = content-creation surfaces (editor, upload, preview) + anti-patterns.

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-h trước khi scroll kicks in = prevents runaway editor pushing Submit off-screen
  • Content area inherits prose prose-sm for formatted output (Tailwind Typography plugin)

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.”

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

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 (truncate trên name, min-w-0 trên parent - classic flex truncation)

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)

🛑 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❌ PATTERNVì sao✅ REWRITE
01Rich editor toolbar floats trên content (không shared border)White-on-white (vi phạm CORE §8.2); visual disconnectSingle container với toolbar bg-slate-50 + shared border (§5.1)
02Dropzone không show format + size limit trước uploadUser upload rồi mới biết fail - wasted time + frustrationEmpty state hiển thị “PNG, JPG · max 5MB” upfront (§5.2)
03Remove button dưới thumbnail (not overlay)Crowds grid, cần row phụ; inconsistent với mental model× top-right overlay on hover (§5.2)
04Progress bar bên dưới row (tạo vertical shift)Layout nhảy khi upload complete → jitterProgress bar trong name/size slot (§5.4)
05Markdown preview mở modal / new tabUser mất context, re-position cursorInline tab-switch hoặc split view (§5.5)
06Editor height fixed tall (~600px) luônWaste space khi content short, giant empty voidmin-h + max-h range (§5.1), grows with content
07Toolbar icon-tile-above label patternEach icon-tile-above = Impeccable slop-06; toolbar quá phức tạpFlat icon-only buttons với tooltip on hover (§5.1)

</dol_anti_pattern>


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.

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.

<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-3 mặc định; py-2 nếu compact mode (> 30 rows visible simultaneously)
  • Divide-y between rows, NOT border-y - single-line dividers at slate-100 avoid 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
Content typeAlignmentNotes
Text (name, label)LeftDefault
Numeric (count, price, %)Right + tabular-numsDigits align by decimal visually
Status badgeLeftPattern signature leads
DateLeftFormat VN: dd/mm/yyyy
Actions (icon buttons)RightKebab 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)

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”

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
Use caseChoice
Admin CRUD / management listsPagination - users reference rows by position (“row 23 on page 2”)
Activity feeds / logs / social-styleInfinite scroll - chronological, user consumes sequentially
Exports / reports viewPagination + jump-to-page input (large datasets)
Search results trong flowPagination (back-button returns to same page)

Default cho DOL admin surfaces = pagination. Page size: 25 (desktop dense) / 10 (mobile).

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

🛑 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❌ PATTERNVì sao✅ REWRITE
01Bordered cells (full grid)Spreadsheet feel; visual noise scans slowerdivide-y divide-slate-100 only (§6.2)
02Numeric cells left-alignedDigits misalign → hard compare rowstext-right tabular-nums (§6.3)
03Select-all checkbox = “all rows everywhere”User delete 1,247 hidden rows by accidentSelect-visible + explicit “Chọn tất cả N” link (§6.4)
04Inline edit commits silently on Enter (no visual feedback)User không biết save thành côngFlash subtle bg-green-50 cell 400ms sau commit (§6.5 + CORE §10 motion)
05Empty state = blank tbody + “No data” textDead end; user không biết next actionIllustrated + primary CTA (§6.7)
06Loading = centered spinner replacing tableLayout shift → jittery experienceSkeleton rows trong tbody (§6.7)
07Page size > 50 on mobileEndless scroll inside bounded container = trapped10 rows mobile, 25 desktop (§6.6)
08Row click = navigate AND checkbox = selectDouble gesture, ambiguous intentCheckbox = select only; row hover reveals explicit “Xem chi tiết” action

</dol_anti_pattern>