Accordion bằng HTML+CSS, JS ra chỗ khác chơi
Chào mọi người, mình là Bình, một software engineer tự xưng. Thông thường, mình sẽ chỉ đụng tay vào các dashboard phía frontend, đã phải 3 năm rồi mình chưa làm những home hay landing page. Đợt vừa rồi, không biết nhân duyên nào bắt mình phải làm nhiều dự án có home page, những trang này có đặc điểm là cần phải chạy tốt khi không có JS để tối ưu search engine. Nhiều thành phần thường thấy trong dashboard như dropdown menu, accordion, carousel phải chạy được ở home page mà không cần JS. Mình không thích phải dùng nhiều công nghệ trong cùng 1 product, vì vậy ngay bây giờ chúng ta sẽ làm 1 cái accordion trong NextJS + Tailwindcss, có thể chạy khi tắt JS (hoàn toàn có thể code lại bằng HTML/CSS, ở đây mình chỉ dùng thêm useId() của React).
Tại sao chúng ta thường phải dùng JS khi viết những component như accordion?
Đơn giản là "trạng thái" (state), sự đóng - mở của accordion chính là trạng thái, HTML và CSS không phải ngôn ngữ lập trình, nên chúng không thể lưu trạng thái, vì vậy thông thường sẽ rất khó khăn trong việc cài đặt những thứ có trạng thái chỉ với HTML và CSS.
Cái gì có thể thay thế JS trong việc lưu trữ trạng thái cho các component?
Trước khi JS được ứng dụng phổ biến, chúng ta vẫn phải nhập dữ liệu lên web, đó là một dạng trạng thái, và các thẻ input chính là nơi lưu trữ các trạng thái này:
<input type="checkbox">
: Một thẻ input checkbox có thể lưu trữ trạng thái boolean (true hoặc false).<input type="radio">
: Một nhóm các thẻ input radio có thể lưu trữ trạng thái enum.- Và các thẻ input number, text lưu trữ trạng thái số và chữ.
Như vậy, để có thể mô phỏng trạng thái đóng mở của một accordion, ta chỉ cần 1 thẻ input checkbox.
Cấu tạo của một accordion
Một accordion có 2 thành phần: heading (summary) và body (detail). Heading sẽ chứa thông tin tóm tắt và body là phần diễn giải, chúng ta có thể click lên header để ẩn/hiện body.
Cài đặt accordion
Chúng ta không thể đặt 1 checkbox để người dùng tick cho accordion xổ ra được, vì vậy chúng ta sẽ sử dụng 1 thẻ label trỏ tới input này và ẩn input đi, trong label sẽ chứa heading, như vậy khi người dùng nhấn vào heading cũng tương đương việc nhấn vào input:
<label htmlFor={inputId} className="w-full block">
<span>{heading}</span>
<input type="checkbox" id={inputId} name="switch" hidden />
</label>
Phần body của accordion sẽ được đặt trong một thẻ div liền kề với thẻ label với mặc định chiều cao bằng 0 và overflow hidden để ẩn đi:
<div className="w-full h-0 overflow-hidden">
{body}
</div>
Bọc tất cả vào trong 1 thẻ div để có thể cô lập heading và body thành 1 reusable component:
<div>
<label htmlFor={inputId} className="w-full block">
<span>{heading}</span>
<input type="checkbox" id={inputId} name="switch" hidden />
</label>
<div className="w-full h-0 overflow-hidden">
{body}
</div>
</div>
Để bắt trạng thái của input, ta dùng pseudo class :has
và :checked
của CSS:
<div>
<label htmlFor={inputId} className="w-full block">
<span>{heading}</span>
<input type="checkbox" id={inputId} name="switch" hidden />
</label>
<div className="w-full h-0 overflow-hidden [*:has([name=switch]:checked)>&]:h-auto">
{body}
</div>
</div>
Diễn giải: Nếu phần tử cha của body chứa 1 element có
name
làswitch
và có giá trịchecked
là true thì apply classh-auto
.
Tuy nhiên, nếu dùng height
thì element sẽ không thể animate từ trạng thái không có chiều cao (height: 0
) sang chiều cao tự động (height: auto
) được, thay vì height
ta sẽ dùng max-height
:
<div>
<label htmlFor={inputId} className="w-full block">
<span>{heading}</span>
<input type="checkbox" id={inputId} name="switch" hidden />
</label>
<div className="w-full max-h-0 overflow-hidden [*:has([name=switch]:checked)>&]:max-h-max">
{body}
</div>
</div>
Tuy nhiên, để có thể animate được, max-height
cần được xác định một giá trị cụ thể, vì vậy ta sẽ tiến hành thêm các bodyCls
và headingCls
để có thể tùy biến vào trong accordion linh hoạt hơn:
import clsx from "clsx";
import { ReactNode, useId } from "react";
interface IProps {
rootCls?: string;
heading: string | ReactNode;
headingCls?: string;
body: string | ReactNode | ReactNode[];
bodyCls?: string;
}
export function Accordion({
rootCls,
heading,
headingCls,
body,
bodyCls,
}: IProps) {
const id = useId();
const inputId = `accordions.${id}`;
return (
<div className={rootCls}>
<label htmlFor={inputId} className={clsx("w-full block", headingCls)}>
<span>{heading}</span>
<input type="checkbox" id={inputId} name="switch" hidden />
</label>
<div
className={clsx(
"w-full max-h-0 overflow-hidden [*:has([name=switch]:checked)>&]:max-h-max",
bodyCls,
)}
>
{body}
</div>
</div>
);
}
Và bây giờ gặp 1 vấn đề nữa, nếu như đặt điều kiện như hiện tại, khi component khác gọi tới Accordion sẽ phải đặt max-height
kèm theo cả selector checkbox dài ngoằng kia rất bất tiện, vì vậy ta sẽ đảo điều kiện lại bằng pseudo class :not
:
<div
className={clsx(
"w-full max-h-max overflow-hidden [*:not(:has([name=switch]:checked))>&]:max-h-0",
bodyCls,
)}
>
{body}
</div>
Cuối cùng Accordion sẽ được gọi bởi component khác:
import { Accordion } from "@/components/Accordion";
export default function Home() {
return (
<main>
<Accordion
rootCls="max-w-screen-sm"
heading="Click to expand"
body="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
bodyCls="transition-all duration-200 max-h-56"
/>
</main>
);
}
Và đây là kết quả:
Và tất nhiên là SEO-safe và chạy hoàn toàn không cần JavaScript ở trình duyệt.
Lời kết
Chả biết viết gì để kết thúc cả, nếu bài viết này được quan tâm thì mình có thể làm thêm về dropdown menu và carousel nữa .
All rights reserved