FileEditViewTerminal
IT Issue Bootcamp - Visual Studio Code
day-3-hour-2.mdx

Day 3 / Hour 2

Tailwind Components and Responsive UI

Status badges, issue list, form layout, responsive behavior, and empty states.

60 minutes
The main UI is readable and responsive.

Tailwind Components and Responsive UI

Day 3 - ชั่วโมงที่ 2: ปรับ Component และตารางด้วย Tailwind

เป้าหมายของชั่วโมงนี้

หลังจบชั่วโมงนี้ นักศึกษาจะสามารถ:

  1. ใช้ Tailwind กำหนดหน้าตาของ Component ตาม Props ได้
  2. ปรับ StatusBadge ให้แสดงสีตามสถานะ
  3. ปรับ IssueList ให้อ่านง่ายขึ้น
  4. ทำให้ตารางเลื่อนแนวนอนได้เมื่อพื้นที่ไม่พอ
  5. แสดง Empty State เมื่อไม่มีรายการ
  6. ลบ 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 State

Slide 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 เพิ่มพื้นที่ภายใน Cell
  • font-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 6
  • th ลบหลังปรับหัวตารางใน Slide 7
  • td ลบหลังปรับข้อมูลใน 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 จำกัดความกว้างของเนื้อหาให้เท่ากับหน้า Home
  • mx-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 classClass ที่เปลี่ยนตามข้อมูลหรือเงื่อนไข
Empty StateUI ที่แสดงเมื่อยังไม่มีข้อมูล
Responsive tableตารางที่ยังใช้งานได้เมื่อหน้าจอมีพื้นที่น้อย
Mobile-firstออกแบบค่าเริ่มต้นสำหรับจอเล็ก แล้วค่อยปรับเมื่อจอกว้างขึ้น