[CLAUDE] FE: content polish — typography + PageHeader + Button/Input/Table
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m54s
All checks were successful
Deploy SOLUTION_ERP / build-deploy (push) Successful in 2m54s
Typography base (both apps): - 14px base, line-height 1.55, letter-spacing -0.003em — Be Vietnam Pro render chắc hơn, dấu tiếng Việt rõ hơn - h1/h2 tracking tighter (-0.018em), h1 weight 700, h2 weight 600 - Tabular numbers trong <table> (tự động align cột số) PageHeader: - Title text-[22px] thay vì text-xl — hierarchy mạnh hơn - Border-bottom + pb-5 thay flat layout — content-area rõ vùng - Description leading-relaxed + slate-500 — dễ đọc hơn Button: - shadow-sm + color-tinted shadow (brand/20, red/20) cho primary/ danger — có chiều sâu - active:translate-y-[0.5px] micro-press feedback - Ring offset 2 (thay 1) + offset-white — focus ring tách rõ Input/Select/Textarea: - h-9 thay h-10 — phù hợp dense table layouts - shadow-[inset_0_1px_0_...] — inset highlight tinh tế - Focus: border-brand-500 + ring-brand-500/20 — 2 lớp chỉ báo - Disabled: bg-slate-50 + opacity-70 — rõ disabled state DataTable: - rounded-xl + shadow-sm + border-200/80 — card feel nhẹ nhàng hơn - Header: UPPERCASE text-[11px] tracking-wider — ERP enterprise look - Row hover: bg-brand-50/40 (thay slate-50) — brand-tinted hover - Padding tăng từ px-3 py-2 → px-4 py-2.5 — breathing room Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -35,15 +35,15 @@ export function DataTable<T>({
|
||||
onRowClick,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="overflow-auto rounded-md border border-slate-200 bg-white">
|
||||
<div className="overflow-auto rounded-xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-700">
|
||||
<tr>
|
||||
<thead className="bg-slate-50/60 text-slate-600">
|
||||
<tr className="border-b border-slate-200/80">
|
||||
{columns.map(c => (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'px-3 py-2 font-medium',
|
||||
'px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider',
|
||||
c.align === 'right' && 'text-right',
|
||||
c.align === 'center' && 'text-center',
|
||||
c.align !== 'right' && c.align !== 'center' && 'text-left',
|
||||
@ -70,7 +70,7 @@ export function DataTable<T>({
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={`sk-${i}`} className="border-t border-slate-100">
|
||||
{columns.map(c => (
|
||||
<td key={c.key} className="px-3 py-3">
|
||||
<td key={c.key} className="px-4 py-3">
|
||||
<div className="h-3 animate-pulse rounded bg-slate-100" />
|
||||
</td>
|
||||
))}
|
||||
@ -78,7 +78,7 @@ export function DataTable<T>({
|
||||
))}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-10 text-center text-sm text-slate-400">
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{empty ?? 'Không có dữ liệu'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -88,8 +88,8 @@ export function DataTable<T>({
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
className={cn(
|
||||
'border-t border-slate-100 transition',
|
||||
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
||||
'border-t border-slate-100 text-slate-700 transition-colors',
|
||||
onRowClick && 'cursor-pointer hover:bg-brand-50/40',
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
@ -97,7 +97,7 @@ export function DataTable<T>({
|
||||
<td
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'px-3 py-2',
|
||||
'px-4 py-2.5',
|
||||
c.align === 'right' && 'text-right',
|
||||
c.align === 'center' && 'text-center',
|
||||
)}
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-slate-600">{description}</p>}
|
||||
<div className="mb-6 flex items-start justify-between gap-6 border-b border-slate-200/70 pb-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[22px] font-bold leading-tight text-slate-900">{title}</h1>
|
||||
{description && <p className="mt-1.5 text-[13px] leading-relaxed text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,19 +3,19 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-brand-500',
|
||||
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:ring-brand-500 active:translate-y-[0.5px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand-600 text-white hover:bg-brand-700',
|
||||
secondary: 'bg-slate-200 text-slate-900 hover:bg-slate-300',
|
||||
outline: 'border border-slate-300 bg-white hover:bg-slate-50',
|
||||
ghost: 'hover:bg-slate-100',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
primary: 'bg-brand-600 text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700',
|
||||
secondary: 'bg-slate-100 text-slate-800 hover:bg-slate-200',
|
||||
outline: 'border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50 hover:border-slate-400',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
danger: 'bg-red-600 text-white shadow-sm shadow-red-600/20 hover:bg-red-700',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3',
|
||||
md: 'h-10 px-4',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
},
|
||||
},
|
||||
|
||||
@ -7,7 +7,10 @@ export const Input = forwardRef<HTMLInputElement, Props>(({ className, ...props
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
'h-9 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900',
|
||||
'shadow-[inset_0_1px_0_rgba(15,23,42,0.02)] placeholder:text-slate-400',
|
||||
'transition-[border-color,box-shadow] focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -7,7 +7,10 @@ export const Select = forwardRef<HTMLSelectElement, Props>(({ className, childre
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
'h-9 w-full rounded-md border border-slate-300 bg-white px-3 pr-8 text-sm text-slate-900',
|
||||
'shadow-[inset_0_1px_0_rgba(15,23,42,0.02)]',
|
||||
'transition-[border-color,box-shadow] focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -7,7 +7,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, Props>(({ className, ...
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 leading-relaxed',
|
||||
'shadow-[inset_0_1px_0_rgba(15,23,42,0.02)] placeholder:text-slate-400',
|
||||
'transition-[border-color,box-shadow] focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -32,12 +32,32 @@ body {
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "cv11", "ss01", "ss03";
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.003em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Heading tightening + better hierarchy with Be Vietnam Pro */
|
||||
h1, h2, h3, h4 {
|
||||
letter-spacing: -0.018em;
|
||||
color: #0f172a;
|
||||
}
|
||||
h1 { font-weight: 700; }
|
||||
h2 { font-weight: 600; }
|
||||
|
||||
/* Tabular numbers in tables + stat cards for better alignment */
|
||||
table, .tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Smoother form focus */
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Subtle scrollbar that fits the brand */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@ -35,15 +35,15 @@ export function DataTable<T>({
|
||||
onRowClick,
|
||||
}: Props<T>) {
|
||||
return (
|
||||
<div className="overflow-auto rounded-md border border-slate-200 bg-white">
|
||||
<div className="overflow-auto rounded-xl border border-slate-200/80 bg-white shadow-sm">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 text-slate-700">
|
||||
<tr>
|
||||
<thead className="bg-slate-50/60 text-slate-600">
|
||||
<tr className="border-b border-slate-200/80">
|
||||
{columns.map(c => (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'px-3 py-2 font-medium',
|
||||
'px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider',
|
||||
c.align === 'right' && 'text-right',
|
||||
c.align === 'center' && 'text-center',
|
||||
c.align !== 'right' && c.align !== 'center' && 'text-left',
|
||||
@ -70,7 +70,7 @@ export function DataTable<T>({
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<tr key={`sk-${i}`} className="border-t border-slate-100">
|
||||
{columns.map(c => (
|
||||
<td key={c.key} className="px-3 py-3">
|
||||
<td key={c.key} className="px-4 py-3">
|
||||
<div className="h-3 animate-pulse rounded bg-slate-100" />
|
||||
</td>
|
||||
))}
|
||||
@ -78,7 +78,7 @@ export function DataTable<T>({
|
||||
))}
|
||||
{!isLoading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="px-3 py-10 text-center text-sm text-slate-400">
|
||||
<td colSpan={columns.length} className="px-4 py-12 text-center text-sm text-slate-400">
|
||||
{empty ?? 'Không có dữ liệu'}
|
||||
</td>
|
||||
</tr>
|
||||
@ -88,8 +88,8 @@ export function DataTable<T>({
|
||||
<tr
|
||||
key={getRowKey(row)}
|
||||
className={cn(
|
||||
'border-t border-slate-100 transition',
|
||||
onRowClick && 'cursor-pointer hover:bg-slate-50',
|
||||
'border-t border-slate-100 text-slate-700 transition-colors',
|
||||
onRowClick && 'cursor-pointer hover:bg-brand-50/40',
|
||||
)}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
@ -97,7 +97,7 @@ export function DataTable<T>({
|
||||
<td
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'px-3 py-2',
|
||||
'px-4 py-2.5',
|
||||
c.align === 'right' && 'text-right',
|
||||
c.align === 'center' && 'text-center',
|
||||
)}
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export function PageHeader({ title, description, actions }: { title: ReactNode; description?: ReactNode; actions?: ReactNode }) {
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode
|
||||
description?: ReactNode
|
||||
actions?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-5 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-slate-900">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-slate-600">{description}</p>}
|
||||
<div className="mb-6 flex items-start justify-between gap-6 border-b border-slate-200/70 pb-5">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[22px] font-bold leading-tight text-slate-900">{title}</h1>
|
||||
{description && <p className="mt-1.5 text-[13px] leading-relaxed text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,19 +3,19 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-brand-500',
|
||||
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:ring-brand-500 active:translate-y-[0.5px]',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'bg-brand-600 text-white hover:bg-brand-700',
|
||||
secondary: 'bg-slate-200 text-slate-900 hover:bg-slate-300',
|
||||
outline: 'border border-slate-300 bg-white hover:bg-slate-50',
|
||||
ghost: 'hover:bg-slate-100',
|
||||
danger: 'bg-red-600 text-white hover:bg-red-700',
|
||||
primary: 'bg-brand-600 text-white shadow-sm shadow-brand-600/20 hover:bg-brand-700',
|
||||
secondary: 'bg-slate-100 text-slate-800 hover:bg-slate-200',
|
||||
outline: 'border border-slate-300 bg-white text-slate-700 shadow-sm hover:bg-slate-50 hover:border-slate-400',
|
||||
ghost: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900',
|
||||
danger: 'bg-red-600 text-white shadow-sm shadow-red-600/20 hover:bg-red-700',
|
||||
},
|
||||
size: {
|
||||
sm: 'h-8 px-3',
|
||||
md: 'h-10 px-4',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
md: 'h-9 px-4',
|
||||
lg: 'h-11 px-6 text-base',
|
||||
},
|
||||
},
|
||||
|
||||
@ -7,7 +7,10 @@ export const Input = forwardRef<HTMLInputElement, Props>(({ className, ...props
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
'h-9 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900',
|
||||
'shadow-[inset_0_1px_0_rgba(15,23,42,0.02)] placeholder:text-slate-400',
|
||||
'transition-[border-color,box-shadow] focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -7,7 +7,10 @@ export const Select = forwardRef<HTMLSelectElement, Props>(({ className, childre
|
||||
<select
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
'h-9 w-full rounded-md border border-slate-300 bg-white px-3 pr-8 text-sm text-slate-900',
|
||||
'shadow-[inset_0_1px_0_rgba(15,23,42,0.02)]',
|
||||
'transition-[border-color,box-shadow] focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -7,7 +7,10 @@ export const Textarea = forwardRef<HTMLTextAreaElement, Props>(({ className, ...
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:opacity-50',
|
||||
'w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 leading-relaxed',
|
||||
'shadow-[inset_0_1px_0_rgba(15,23,42,0.02)] placeholder:text-slate-400',
|
||||
'transition-[border-color,box-shadow] focus-visible:border-brand-500 focus-visible:ring-2 focus-visible:ring-brand-500/20',
|
||||
'disabled:cursor-not-allowed disabled:bg-slate-50 disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -32,12 +32,29 @@ body {
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: "cv11", "ss01", "ss03";
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
letter-spacing: -0.003em;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
letter-spacing: -0.018em;
|
||||
color: #0f172a;
|
||||
}
|
||||
h1 { font-weight: 700; }
|
||||
h2 { font-weight: 600; }
|
||||
|
||||
table, .tabular-nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input:focus-visible, textarea:focus-visible, select:focus-visible, button:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
Reference in New Issue
Block a user