Tailwind Components and Responsive UI
Day 3 - ชั่วโมงที่ 2: ปรับ Component และตารางด้วย Tailwind
เป้าหมายของชั่วโมงนี้
หลังจบชั่วโมงนี้ นักศึกษาจะสามารถ:
- ใช้ Tailwind กำหนดหน้าตาของ Component ตาม Props ได้
- ปรับ
StatusBadgeให้แสดงสีตามสถานะ - ปรับ
IssueListให้อ่านง่ายขึ้น - ทำให้ตารางเลื่อนแนวนอนได้เมื่อพื้นที่ไม่พอ
- แสดง Empty State เมื่อไม่มีรายการ
- ลบ Custom CSS เดิมที่ไม่ใช้งานแล้วได้อย่างถูกต้อง
ไฟล์ที่ใช้
components/StatusBadge.tsx
components/IssueList.tsx
app/globals.cssโครงสร้างเวลา 60 นาที
| เวลา | เนื้อหา |
|---|---|
| 0-5 นาที | ทบทวนจาก Hour 1 |
| 5-15 นาที | ปรับ StatusBadge |
| 15-40 นาที | ปรับ IssueList และตาราง |
| 40-50 นาที | เพิ่ม Empty State |
| 50-60 นาที | ลบ CSS เดิมและทดสอบ Responsive |
Slide 1: Recap จาก Hour 1
Hour 1 เราได้:
- เปิดใช้งาน Tailwind ผ่าน
app/globals.css - รู้จัก Utility Class สำหรับสี ระยะห่าง Layout และ Responsive
- เปลี่ยน Navigation, Header, Main และ Footer มาใช้ Tailwind
- เปลี่ยน
IssueFormมาใช้ Tailwind - ลบ Custom CSS เดิมของส่วนที่ย้ายเสร็จแล้ว
Hour นี้เราจะทำส่วนที่เหลือให้ครบ:
StatusBadge → IssueList → Responsive Table → Empty StateSlide 2: Component เดิมยังทำงานเหมือนเดิม
ก่อนปรับ หน้าตาของ StatusBadge และ IssueList ยังมาจาก Class ใน globals.css:
<span className={`status ${getStatusClass(status)}`}>
{status}
</span><div className="table-wrapper">
<table>...</table>
</div>เราจะไม่เปลี่ยนข้อมูลหรือการทำงานของ Component แต่จะย้าย Style มาเขียนด้วย Tailwind ใน className
Props และ Logic เดิม
↓
เปลี่ยนเฉพาะ Style
↓
หน้าเว็บทำงานเหมือนเดิม แต่อ่านง่ายและ Responsive ขึ้นSlide 3: ปรับ StatusBadge ทั้งไฟล์
แทนโค้ดเดิมทั้งไฟล์ components/StatusBadge.tsx ด้วย:
import type { IssueStatus } from "@/types/issue";
type StatusBadgeProps = {
status: IssueStatus;
};
function getStatusClass(status: IssueStatus): string {
if (status === "OPEN") {
return "bg-red-50 text-red-700 ring-red-200";
}
if (status === "IN_PROGRESS") {
return "bg-amber-50 text-amber-700 ring-amber-200";
}
return "bg-emerald-50 text-emerald-700 ring-emerald-200";
}
export function StatusBadge({ status }: StatusBadgeProps) {
return (
<span
className={`inline-flex rounded-full px-2.5 py-1 text-xs font-bold ring-1 ${getStatusClass(
status
)}`}
>
{status}
</span>
);
}บันทึกไฟล์แล้วตรวจว่า Badge ทั้งสามสถานะยังแสดงสีถูกต้อง จากนั้นเปิด app/globals.css และลบ CSS เดิมของ StatusBadge:
.status { ... }
.status-open { ... }
.status-progress { ... }
.status-done { ... }ตอนนี้ Style ทั้งหมดของ Badge อยู่ใน StatusBadge.tsx แล้ว จึงไม่ต้องเก็บ Class เดิมไว้ให้ซ้ำกัน
Slide 4: เริ่มปรับ IssueList
แก้บางส่วนใน components/IssueList.tsx
ปรับ <section>, <h2> และ <p> ด้านบนของ return หลัก:
<section
aria-labelledby="issue-list-title"
className="rounded-lg border border-slate-200 bg-white p-6"
>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2
id="issue-list-title"
className="text-xl font-bold text-slate-950"
>
รายการปัญหาล่าสุด
</h2>
<p className="mt-1 text-sm text-slate-600">
ตัวอย่างรายการปัญหาที่ถูกแจ้งเข้ามาในระบบ
</p>
</div>
<p className="text-sm font-medium text-slate-600">
ทั้งหมด {issues.length} รายการ
</p>
</div>
{/* ตารางเดิมอยู่ต่อจากส่วนนี้ */}
</section>issues.length อ่านจำนวนสมาชิกใน Array ทำให้ตัวเลขเปลี่ยนตามจำนวนรายการจริง
โค้ดตารางเดิมยังต้องอยู่ภายใน <section> โดยนำมาใส่แทน Comment {/* ตารางเดิมอยู่ต่อจากส่วนนี้ */}
Slide 5: Header ปรับตามขนาดหน้าจอ
Class ของ Header ใช้แนวคิด Mobile-first:
flex flex-col gap-2เป็นค่าเริ่มต้นบนจอเล็ก จึงวางหัวข้อและจำนวนรายการเรียงลงมา
sm:flex-row sm:items-center sm:justify-betweenเมื่อหน้าจอกว้างตั้งแต่ sm ขึ้นไป:
sm:flex-rowเปลี่ยนเป็นแนวนอนsm:items-centerจัดให้อยู่กึ่งกลางแนวตั้งsm:justify-betweenแยกหัวข้อไปซ้ายและจำนวนรายการไปขวา
เราเขียน Layout สำหรับจอเล็กก่อน แล้วใช้ Prefix sm: เปลี่ยนเฉพาะตอนที่พื้นที่เพียงพอ
Slide 6: ทำให้ตารางเลื่อนได้เมื่อพื้นที่ไม่พอ
แก้บางส่วนใน components/IssueList.tsx
แทน Wrapper และ <table> เดิม:
<div className="table-wrapper">
<table>ด้วย:
<div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-sm">overflow-x-autoให้ Wrapper แสดง Scrollbar แนวนอนเมื่อจำเป็นmin-w-[720px]ป้องกัน Column ถูกบีบจนอ่านยากw-fullให้ตารางกว้างเต็ม Wrapper เมื่อมีพื้นที่พอ
ต้องใส่ overflow-x-auto ที่ <div> ซึ่งเป็น Wrapper ไม่ใช่ที่ <table> เพื่อให้ตารางเลื่อนอยู่ภายในกล่องของตัวเอง
บันทึกไฟล์และตรวจว่าตารางยังแสดงครบ จากนั้นเปิด app/globals.css แล้วลบ CSS เดิมของ Wrapper และ Table:
.table-wrapper { ... }
table { ... }ยังไม่ลบ th, td และ th { ... } ในตอนนี้ เพราะหัวตารางและ Cell จะย้ายมาใช้ Tailwind ใน Slide ถัดไป
Slide 7: ปรับหัวตาราง
แทน <thead>...</thead> เดิมด้วย:
<thead>
<tr className="border-b border-slate-200 bg-slate-50 text-left text-xs uppercase text-slate-500">
<th className="px-3 py-3 font-semibold">รหัส</th>
<th className="px-3 py-3 font-semibold">หัวข้อ</th>
<th className="px-3 py-3 font-semibold">ผู้แจ้ง</th>
<th className="px-3 py-3 font-semibold">สถานะ</th>
<th className="px-3 py-3 font-semibold">รายละเอียด</th>
</tr>
</thead>- พื้นหลังสีอ่อนช่วยแยกหัวตารางออกจากข้อมูล
text-leftทำให้ทุกหัวข้อชิดซ้ายและอ่านตามแนวเดียวกันpx-3 py-3เพิ่มพื้นที่ภายใน Cellfont-semiboldทำให้ชื่อ Column เด่นกว่าข้อมูล
หลังตรวจหัวตารางแล้ว ให้เปิด app/globals.css และลบ Block ที่ใช้กับ th โดยเฉพาะ:
th { ... }CSS เดิมอีก Block ใช้ th และ td ร่วมกัน ให้แก้ Selector ชั่วคราวจาก:
th,
td {
...
}ให้เหลือเฉพาะ td เพราะ Cell ใน <tbody> จะปรับเป็น Tailwind ใน Slide ถัดไป:
td {
...
}Slide 8: ปรับข้อมูลแต่ละแถว
แทน <tbody>...</tbody> เดิมด้วย:
<tbody>
{issues.map((issue) => (
<tr key={issue.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-4 font-medium text-slate-700">
#{issue.id}
</td>
<td className="px-3 py-4 text-slate-950">{issue.title}</td>
<td className="px-3 py-4 text-slate-600">
{issue.reporterName}
</td>
<td className="px-3 py-4">
<StatusBadge status={issue.status} />
</td>
<td className="px-3 py-4">
<Link
href={`/issues/${issue.id}`}
className="font-semibold text-teal-700 hover:text-teal-900"
>
ดูรายละเอียด
</Link>
</td>
</tr>
))}
</tbody>ยังใช้ .map() และ StatusBadge เหมือนเดิม เราเพิ่มเพียง Tailwind Class เพื่อจัดระยะ สี และเส้นคั่นของแต่ละแถว
เมื่อข้อมูลทุก Cell มี Tailwind Class แล้ว ให้กลับไปที่ app/globals.css และลบ Block td ที่เหลือ:
td { ... }ตอนนี้ Style ของตารางทั้งส่วนอยู่ใน IssueList.tsx ครบแล้ว
Slide 9: Link ของแต่ละรายการทำงานอย่างไร
<Link href={`/issues/${issue.id}`}>
ดูรายละเอียด
</Link>Template Literal นำ issue.id มาต่อเป็น URL ของรายการนั้น:
issue.id = "001"
↓
/issues/001เมื่อใช้โค้ดนี้ภายใน .map() แต่ละแถวจึงลิงก์ไปยังหน้ารายละเอียดของตัวเอง
Class hover:text-teal-900 เปลี่ยนสีเมื่อวางเมาส์เหนือ Link เพื่อบอกผู้ใช้ว่าส่วนนี้กดได้
Slide 10: Empty State คืออะไร
ถ้า issues เป็น Array ว่าง ตารางจะไม่มีแถวข้อมูล ผู้ใช้จึงอาจไม่รู้ว่าระบบยังไม่มีรายการหรือหน้าเว็บมีปัญหา
Empty State คือ UI ที่อธิบายสถานะเมื่อยังไม่มีข้อมูล
เพิ่มโค้ดนี้ภายใน Function IssueList ก่อน return หลัก:
if (issues.length === 0) {
return (
<section className="rounded-lg border border-dashed border-slate-300 bg-white p-8 text-center">
<h2 className="text-lg font-bold text-slate-950">
ยังไม่มีรายการปัญหา
</h2>
<p className="mt-2 text-sm text-slate-600">
เมื่อมีการแจ้งปัญหา รายการจะแสดงที่นี่
</p>
</section>
);
}เมื่อไม่มีข้อมูล Function จะ return Empty State ทันที และไม่ทำงานต่อไปถึงตารางด้านล่าง
Slide 11: ทดสอบ Empty State ชั่วคราว
เปิดไฟล์ที่ส่งข้อมูลเข้า IssueList แล้วเปลี่ยน Props ชั่วคราวเป็น Array ว่าง:
<IssueList issues={[]} />หน้าเว็บควรแสดงข้อความ:
ยังไม่มีรายการปัญหา
เมื่อมีการแจ้งปัญหา รายการจะแสดงที่นี่หลังทดสอบเสร็จ ให้เปลี่ยนกลับเป็น:
<IssueList issues={issues} />การทดสอบด้วย Array ว่างช่วยให้เราเห็น State ที่ Mock Data ปกติอาจยังไม่แสดง
Slide 12: ลบ Custom CSS ที่ไม่ใช้แล้ว
เมื่อ StatusBadge และ IssueList ใช้ Tailwind ครบแล้ว ให้เปิด app/globals.css และลบ Style เดิมต่อไปนี้:
section { ... }CSS ส่วนอื่นถูกลบไปพร้อมกับ Component ที่ปรับเสร็จแล้ว:
.status*ลบหลังปรับStatusBadgeใน Slide 3.table-wrapperและtableลบหลังปรับ Responsive Table ใน Slide 6thลบหลังปรับหัวตารางใน Slide 7tdลบหลังปรับข้อมูลใน Slide 8
เมื่อ IssueForm และ IssueList มี Tailwind Class ที่กล่อง <section> ของตัวเองแล้ว จึงลบ section { ... } เดิมได้เป็นรายการสุดท้าย
เก็บ Global Style ที่ยังใช้งาน:
@import "tailwindcss";
* {
box-sizing: border-box;
}
body {
margin: 0;
}ลบ CSS หลังจากย้าย Style เสร็จแล้ว เพื่อไม่ให้หน้าเว็บเสียระหว่างทำ
Slide 13: ปรับหน้า /issues แล้วตรวจ Responsive
หลังลบ Global Style ของ main หน้า /issues อาจกว้างเต็มจอ เพราะไฟล์นี้ยังไม่ได้ใส่ Tailwind Class ให้ Layout ของตัวเอง
แทนโค้ดทั้งไฟล์ app/issues/page.tsx ด้วย:
import { IssueList } from "@/components/IssueList";
import { issues } from "@/data/issue";
export default function IssuesPage() {
return (
<main className="mx-auto grid grid-cols-1 max-w-5xl gap-6 px-6 py-8">
<h1 className="text-2xl font-bold text-slate-950">
รายการปัญหา
</h1>
<IssueList issues={issues} />
</main>
);
}max-w-5xlจำกัดความกว้างของเนื้อหาให้เท่ากับหน้า Homemx-autoจัดเนื้อหาไว้กึ่งกลางpx-6 py-8เพิ่มระยะห่างจากขอบจอgrid-cols-1ป้องกันตารางดัน Grid จนทั้งหน้าล้นแนวนอน
IssueList ไม่ได้มีปัญหา แต่ Route ที่นำ Component ไปใช้ต้องกำหนด Layout ของหน้าให้เรียบร้อยด้วย
ตรวจ Responsive บนสองหน้า
IssueList ถูกนำไปใช้ทั้งหน้า Home และหน้ารายการปัญหา จึงต้องตรวจทั้งสอง Route:
http://localhost:3000/
http://localhost:3000/issuesเปิด DevTools แล้วลดความกว้างของหน้าจอ จากนั้นตรวจว่า:
- หน้าเว็บทั้งหน้าไม่ล้นออกด้านข้าง
- เฉพาะตารางเลื่อนแนวนอนได้
- Header ของรายการลงบรรทัดใหม่บนจอเล็ก
- Badge ทั้งสามสถานะอ่านได้ชัดเจน
- Link “ดูรายละเอียด” เปิดรายการที่ถูกต้อง
- เมื่อส่ง
[]เข้าIssueListจะเห็น Empty State
ถ้าทั้งหน้ามี Scrollbar แนวนอน ให้ตรวจว่า <main> จาก Hour 1 มี grid-cols-1 และตารางอยู่ภายใน Wrapper ที่มี overflow-x-auto
Slide 14: สรุป Hour 2
ตอนนี้ UI ส่วนหลักย้ายมาใช้ Tailwind ครบแล้ว:
IssueStatus
↓
StatusBadge เลือกสี
↓
IssueList วนแสดงแต่ละรายการ
↓
Responsive Wrapper ดูแลตารางบนจอเล็กสิ่งที่ได้เรียน:
- Props สามารถกำหนดทั้งข้อมูลและหน้าตาของ Component
- Mobile-first เริ่มจากจอเล็ก แล้วค่อยเพิ่ม Prefix เช่น
sm: - ตารางที่กว้างควรเลื่อนภายใน Wrapper ไม่ควรดันทั้งหน้า
- Empty State ช่วยอธิบายว่า “ยังไม่มีข้อมูล”
- เมื่อย้ายมาใช้ Tailwind แล้ว ควรลบ Custom CSS เดิมที่ซ้ำกัน
Hour ถัดไป เราจะเพิ่ม State และ Validation เพื่อให้ Form เริ่มโต้ตอบกับผู้ใช้ได้
คำศัพท์สำคัญ
| คำศัพท์ | ความหมาย |
|---|---|
| Dynamic class | Class ที่เปลี่ยนตามข้อมูลหรือเงื่อนไข |
| Empty State | UI ที่แสดงเมื่อยังไม่มีข้อมูล |
| Responsive table | ตารางที่ยังใช้งานได้เมื่อหน้าจอมีพื้นที่น้อย |
| Mobile-first | ออกแบบค่าเริ่มต้นสำหรับจอเล็ก แล้วค่อยปรับเมื่อจอกว้างขึ้น |