Build a Web Revision Hub
Build a responsive local dashboard with accessible topic filters.
Introduction
30 Second Summary
Những điều bạn đã học dễ bị rải rác giữa ghi chú, tab trình duyệt và những ví dụ cũ. Đến lúc ôn lại, việc tìm đúng chủ đề thường mất nhiều thời gian hơn chính phần ôn tập.
Trong dự án này, bạn sẽ xây Web Foundations Revision Hub để ôn tập HTML, CSS và JavaScript. Dashboard chạy trực tiếp trên máy với một lưới chủ đề phản hồi theo bộ lọc bạn chọn.
What You'll Build
Sau khi bạn chọn một lĩnh vực trên dashboard cục bộ, lưới chủ đề phản hồi ngay với số kết quả rõ ràng.
By the end of this project, you'll have:
- Một dashboard cục bộ có thiết kế responsive tự chuyển từ một cột sang nhiều cột theo độ rộng cửa sổ.
- Một bộ lọc lĩnh vực hiển thị đúng các thẻ HTML, CSS hoặc JavaScript. Khi chọn Accessibility, bạn thấy trạng thái trống rõ ràng.
- Một luồng bàn phím cho phép bạn điều khiển bộ lọc bằng Tab và Enter với viền focus rõ ràng.
- Secret Mission: Kết hợp bộ lọc lĩnh vực với bộ lọc mức độ để mỗi thẻ phải thỏa cả hai điều kiện.
Có yêu cầu đầu vào nào không?
Bạn cần một máy Mac có Visual Studio Code cùng Safari hoặc Google Chrome. Kiến thức cơ bản về HTML, CSS và JavaScript là đủ cho dự án này.
Before We Start
Before you create any files, take a moment to commit to the local revision hub you are building. This gives your HTML, CSS, and JavaScript practice a clear purpose across the same three-file codebase.
Set Up the Local Website
Bạn đã xác định sẽ biến kiến thức web nền tảng thành một Revision Hub chạy cục bộ. Trước khi xây dashboard, bạn cần một cấu trúc dự án ổn định.
Một website tĩnh bằng HTML, CSS và JavaScript có thể mở trực tiếp mà không cần runtime hoặc công cụ build.
Visual Studio Code sẽ giữ ba file trong một thư mục chung. Safari hoặc Google Chrome sẽ hiển thị kết quả ngay trên máy của bạn.
In this step, get ready to:
- Tạo thư mục web-foundations-revision-hub trên máy Mac.
- Thêm ba file nền tảng vào thư mục dự án.
- Mở trang cục bộ để xác nhận tiêu đề hiển thị trong trình duyệt.
Tạo workspace cục bộ
Một workspace là thư mục mà Visual Studio Code dùng làm ranh giới dự án. Mở cả thư mục giúp khu vực Explorer hiển thị đúng mọi file bạn tạo.
- Nhấn Cmd+Space để mở Spotlight.
- Nhập Visual Studio Code vào trường tìm kiếm.
- Nhấn Return để mở ứng dụng.
Bạn sẽ thấy cửa sổ Visual Studio Code. Bây giờ bạn có thể tạo thư mục dành riêng cho Revision Hub.
- Chọn File trên thanh menu.
- Chọn Open Folder....
- Chuyển đến thư mục màn hình nền trong hộp thoại.
- Chọn New Folder.
- Nhập web-foundations-revision-hub làm tên thư mục.
- Chọn Open để mở thư mục.
- Chọn Yes, I trust the authors nếu Visual Studio Code hiển thị hộp thoại Workspace Trust.
Phần khó tìm vị trí dự án đã xong. Explorer bên trái giờ hiển thị web-foundations-revision-hub làm thư mục cấp cao nhất.
Không thấy thư mục trong Explorer?
Chọn lại File rồi chọn Open Folder.... Chọn thư mục web-foundations-revision-hub trên màn hình nền.
Nếu Explorer vẫn không hiển thị thư mục, hãy nhờ AI kiểm tra cách mở workspace.
Tạo ba file nền tảng
Ba file tách nội dung, giao diện và tương tác thành các phần độc lập. Cấu trúc này giúp những thay đổi sau dễ tìm hơn.
- Chọn nút New File... ở đầu khu vực Explorer.
- Nhập index.html vào trường tên file.
- Nhấn Return để tạo file.
Bạn sẽ thấy index.html xuất hiện ngay dưới thư mục web-foundations-revision-hub.
- Chọn lại nút New File... trong Explorer.
- Nhập styles.css vào trường tên file.
- Nhấn Return để tạo file.
Bạn sẽ thấy styles.css được liệt kê cùng cấp với index.html.
- Chọn lại nút New File... trong Explorer.
- Nhập app.js vào trường tên file.
- Nhấn Return để tạo file.
Cấu trúc ba file đã sẵn sàng. Explorer giờ hiển thị app.js, index.html và styles.css trong cùng thư mục.
Mỗi file làm gì?
- File index.html chứa cấu trúc và nội dung của trang.
- File styles.css sẽ định dạng bố cục và giao diện.
- File app.js sẽ điều khiển bộ lọc tương tác.
Tên file không khớp?
Kiểm tra từng tên trong Explorer. Hãy bảo đảm mỗi file có đúng phần mở rộng .html, .css hoặc .js.
Nếu các file không nằm cùng cấp, hãy nhờ AI kiểm tra cấu trúc thư mục.
Viết và mở trang HTML đầu tiên
Trang đầu tiên chỉ cần một tài liệu HTML hợp lệ với tiêu đề có thể nhìn thấy. Liên kết stylesheet và script chuẩn bị sẵn nền móng cho giao diện cùng bộ lọc ở các bước sau.
- Chọn index.html trong Explorer.
- Thay toàn bộ nội dung file bằng đoạn mã sau:
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Web Foundations Revision Hub</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<h1>Web Foundations Revision Hub</h1>
</body>
</html>
Đoạn mã này làm gì?
- Dòng <!doctype html> xác định đây là tài liệu HTML.
- Thuộc tính lang="vi" xác định tiếng Việt là ngôn ngữ chính của trang.
- Giá trị width=device-width giúp viewport khớp với chiều rộng thiết bị.
- Phần tử link kết nối trang với styles.css.
- Thuộc tính defer chờ tài liệu được phân tích xong trước khi chạy app.js.
- Phần tử h1 tạo tiêu đề dùng để kiểm tra trang.
- Nhấn Cmd+S để lưu index.html.
- Control-click index.html trong Explorer.
- Chọn Reveal in Finder để hiển thị file trong Finder.
Trước khi mở trang, bạn nghĩ tiêu đề nào sẽ xuất hiện trong cửa sổ trình duyệt?
- Control-click index.html trong Finder.
- Chọn Open With.
- Chọn Safari hoặc Google Chrome để mở trang.
Bạn sẽ thấy tiêu đề Web Foundations Revision Hub trên trang. Tab trình duyệt cũng hiển thị cùng tên.
Bộ khung đã hoạt động. Revision Hub của bạn đang chạy trực tiếp từ ba file cục bộ.
Không thấy tiêu đề?
Kiểm tra xem index.html đã được lưu trong Visual Studio Code chưa. Sau đó, làm mới trang trong trình duyệt.
Nếu trình duyệt hiển thị mã dưới dạng văn bản, hãy xác nhận tên file kết thúc chính xác bằng .html.
Nếu trang vẫn trống, hãy nhờ AI kiểm tra tài liệu HTML.
✔️ Awesome, I've got everything!
Tuyệt. Hãy bảo đảm cả ba file đã được lưu trong web-foundations-revision-hub.
ⓧ I'd like to double check the full code
So sánh index.html với trạng thái hoàn chỉnh của bước này.
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Web Foundations Revision Hub</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<h1>Web Foundations Revision Hub</h1>
</body>
</html>
File styles.css phải hoàn toàn trống ở cuối bước này.
File app.js cũng phải hoàn toàn trống ở cuối bước này.
Website cục bộ của bạn đã sẵn sàng. Tiếp theo, bạn sẽ biến trang tiêu đề này thành một dashboard semantic với các nút lọc và sáu thẻ ôn tập.
Build the Semantic Dashboard
Your local page already opens in the browser with the project title. You now have a working three-file foundation for the revision hub.
A useful dashboard needs meaningful content structure before visual polish. In this step, you will build the page with Semantic HTML before testing how far static controls can take the experience.
In this step, get ready to:
- Structure the dashboard with semantic landmarks plus six topic articles.
- Add five category controls that expose their values through custom data attributes.
- Create a responsive card layout with CSS Grid.
Build the page landmarks
The page header introduces the revision hub. Its container also provides a shared width boundary that the stylesheet can reuse.
- In the index.html tab from earlier, find the existing <h1> element.
- Replace that element with the page header by copying this markup:
<header class="site-header">
<div class="container">
<p class="eyebrow">Một codebase, nhiều lớp kỹ năng</p>
<h1>Web Foundations Revision Hub</h1>
<p class="intro">Lọc nhanh các chủ đề HTML, CSS và JavaScript để chọn nội dung cần ôn tiếp theo.</p>
</div>
</header>
What Does This Markup Do?
- The header landmark groups the page identity plus its introduction.
- The container class gives the header a reusable layout hook.
- The introductory text tells learners what they can review in the dashboard.
- Save index.html.
- Refresh the browser tab from earlier.
You should see the project title with the Vietnamese eyebrow plus the new introductory sentence beneath it.
Still Seeing Only the Old Heading?
Check that the original standalone <h1> was replaced inside <body>. Make sure you saved index.html before refreshing.
Help me check the header markup in my local dashboard.
A custom data attribute stores project-specific information directly on an HTML element. Each filter button uses data-filter to identify the category it represents.
- Place your cursor after the closing </header> tag.
- Add the main filter panel by copying this markup:
<main class="container">
<section class="filter-panel" aria-labelledby="filter-heading">
<div>
<p class="eyebrow">Bộ lọc chủ đề</p>
<h2 id="filter-heading">Bạn muốn ôn phần nào?</h2>
</div>
<div class="filter-controls">
<div class="filter-block">
<h3 class="filter-label">Lĩnh vực</h3>
<div class="filter-group">
<button class="filter-button is-active" type="button" data-filter="all">Tất cả</button>
<button class="filter-button" type="button" data-filter="html">HTML</button>
<button class="filter-button" type="button" data-filter="css">CSS</button>
<button class="filter-button" type="button" data-filter="javascript">JavaScript</button>
<button class="filter-button" type="button" data-filter="accessibility">Accessibility</button>
</div>
</div>
</div>
<p id="result-summary" class="result-summary">6 chủ đề thuộc tất cả lĩnh vực đang hiển thị.</p>
</section>
</main>
How Is the Filter Panel Structured?
- The section groups the controls with their result summary.
- The aria-labelledby attribute connects the section to its visible heading.
- Each data-filter value identifies the category represented by its button.
- The result-summary element provides a dedicated place for the visible result count.
- Save index.html.
- Refresh the browser tab.
You should see five category buttons beneath the filter heading. A summary below them should report that six topics are displayed.
Missing a Filter Button?
Check that every button sits inside the filter-group container. Confirm that each opening tag has a matching closing tag.
Help me find the missing filter button in my HTML.
Add the topic library
Each topic is a self-contained article with its own heading plus description. The data-category value gives every card the category that future filtering logic can read.
- In index.html, find the closing </main> tag.
- Replace that line with the topic library plus footer by copying this markup:
<section class="topics-section" aria-labelledby="topics-heading">
<div class="topics-header">
<p class="eyebrow">Thư viện ôn tập</p>
<h2 id="topics-heading">Chủ đề hiện có</h2>
</div>
<div class="topic-grid">
<article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Cấu trúc tài liệu semantic</h3><p>Dùng đúng phần tử để trình bày header, nội dung chính, section và footer.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Biểu mẫu có nhãn rõ ràng</h3><p>Ghép label với control và nhóm các trường liên quan thành một luồng dễ hiểu.</p><p class="topic-level">Luyện tập</p></article>
<article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>Cascade và specificity</h3><p>Dự đoán quy tắc nào thắng trước khi thêm selector mạnh hơn hoặc dùng ghi đè.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>CSS Grid responsive</h3><p>Tạo lưới tự co giãn và chỉ thêm breakpoint khi nội dung thực sự cần đổi bố cục.</p><p class="topic-level">Luyện tập</p></article>
<article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Sự kiện và DOM</h3><p>Kết nối hành động của người dùng với phần tử giao diện bằng event listener.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Logic lọc dữ liệu</h3><p>Chuyển điều kiện lọc thành trạng thái hiển thị nhất quán trên toàn bộ danh sách.</p><p class="topic-level">Luyện tập</p></article>
</div>
<p id="empty-state" class="empty-state" hidden>Chưa có chủ đề trong nhóm này. Hãy chọn một bộ lọc khác.</p>
</section>
</main>
<footer class="site-footer"><div class="container"><p>Phiên bản cục bộ để luyện tập web nền tảng.</p></div></footer>
How Does the Topic Library Work?
- The six topic-card articles separate the revision subjects into independent content units.
- The data-category values divide the cards into HTML, CSS plus JavaScript groups.
- The empty-state message starts with hidden because the initial view contains six results.
- The footer closes the page with a short description of its local purpose.
- Save index.html.
- Refresh the browser tab.
You should see five filter buttons plus six topic cards. The empty-state message should remain hidden.
Before you select a filter, do you think the HTML button can hide the CSS plus JavaScript cards on its own?
- Select the HTML filter button.
All six topic cards remain visible. This is the intended shortfall because app.js is still empty.
The category data now exists in the document. No script reads that data or changes the cards yet.
Cards or Footer Missing?
Check that the topic section appears before the closing </main> tag. Confirm that the footer appears after </main> but before </body>.
Help me repair the topic library structure in index.html.
✔️ Awesome, I've got everything!
Great. Save index.html before moving on to the dashboard styling.
ⓧ I'd like to double check the full code
Compare your complete index.html file with this reference.
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Web Foundations Revision Hub</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<header class="site-header">
<div class="container">
<p class="eyebrow">Một codebase, nhiều lớp kỹ năng</p>
<h1>Web Foundations Revision Hub</h1>
<p class="intro">Lọc nhanh các chủ đề HTML, CSS và JavaScript để chọn nội dung cần ôn tiếp theo.</p>
</div>
</header>
<main class="container">
<section class="filter-panel" aria-labelledby="filter-heading">
<div>
<p class="eyebrow">Bộ lọc chủ đề</p>
<h2 id="filter-heading">Bạn muốn ôn phần nào?</h2>
</div>
<div class="filter-controls">
<div class="filter-block">
<h3 class="filter-label">Lĩnh vực</h3>
<div class="filter-group">
<button class="filter-button is-active" type="button" data-filter="all">Tất cả</button>
<button class="filter-button" type="button" data-filter="html">HTML</button>
<button class="filter-button" type="button" data-filter="css">CSS</button>
<button class="filter-button" type="button" data-filter="javascript">JavaScript</button>
<button class="filter-button" type="button" data-filter="accessibility">Accessibility</button>
</div>
</div>
</div>
<p id="result-summary" class="result-summary">6 chủ đề thuộc tất cả lĩnh vực đang hiển thị.</p>
</section>
<section class="topics-section" aria-labelledby="topics-heading">
<div class="topics-header">
<p class="eyebrow">Thư viện ôn tập</p>
<h2 id="topics-heading">Chủ đề hiện có</h2>
</div>
<div class="topic-grid">
<article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Cấu trúc tài liệu semantic</h3><p>Dùng đúng phần tử để trình bày header, nội dung chính, section và footer.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Biểu mẫu có nhãn rõ ràng</h3><p>Ghép label với control và nhóm các trường liên quan thành một luồng dễ hiểu.</p><p class="topic-level">Luyện tập</p></article>
<article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>Cascade và specificity</h3><p>Dự đoán quy tắc nào thắng trước khi thêm selector mạnh hơn hoặc dùng ghi đè.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>CSS Grid responsive</h3><p>Tạo lưới tự co giãn và chỉ thêm breakpoint khi nội dung thực sự cần đổi bố cục.</p><p class="topic-level">Luyện tập</p></article>
<article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Sự kiện và DOM</h3><p>Kết nối hành động của người dùng với phần tử giao diện bằng event listener.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Logic lọc dữ liệu</h3><p>Chuyển điều kiện lọc thành trạng thái hiển thị nhất quán trên toàn bộ danh sách.</p><p class="topic-level">Luyện tập</p></article>
</div>
<p id="empty-state" class="empty-state" hidden>Chưa có chủ đề trong nhóm này. Hãy chọn một bộ lọc khác.</p>
</section>
</main>
<footer class="site-footer"><div class="container"><p>Phiên bản cục bộ để luyện tập web nền tảng.</p></div></footer>
</body>
</html>
How to Compare This File
Check the nesting from header through footer first. Then compare the five data-filter values plus the six data-category values.
Style the responsive dashboard
The semantic source order is now complete. CSS Grid can turn the topic library into a responsive layout without rearranging that source.
- Switch back to the empty styles.css file from earlier.
- Add the page foundation plus header styles by copying this CSS:
:root {
color-scheme: light;
--page: #f4f6fb;
--surface: #ffffff;
--ink: #17213d;
--muted: #5d6680;
--line: #d9dfeb;
--primary: #3157d5;
--primary-dark: #203b9e;
--accent: #dce5ff;
--empty: #fff4d6;
--shadow: 0 1rem 2.5rem rgb(23 33 61 / 0.1);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; }
button { font: inherit; }
[hidden] { display: none !important; }
.container { width: calc(100% - 2rem); max-width: 72rem; margin-inline: auto; }
.site-header { padding: 4.5rem 0 4rem; background: #17213d; color: #ffffff; }
.eyebrow { margin: 0 0 0.5rem; color: #8ea8ff; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.site-header h1 { max-width: 12ch; margin: 0; font-size: clamp(2.4rem, 8vw, 5rem); line-height: 0.98; }
.intro { max-width: 42rem; margin: 1.5rem 0 0; color: #dce5ff; font-size: 1.1rem; }
What Does the Foundation Control?
- The custom properties keep the dashboard colors plus shadow values in one place.
- The universal box-sizing rule keeps element dimensions predictable.
- The container rule adds side spacing while limiting the maximum content width.
- The header rules create the dark introduction area plus responsive title size.
- Save styles.css.
- Refresh the browser tab.
You should see a dark header with a large title. The page content should sit inside a centered width boundary.
Header Still Looks Plain?
Confirm that index.html still links styles.css in the document head. Check that the stylesheet starts with the complete :root block.
Help me find why my dashboard styles are not loading.
- Add the main layout plus filter panel styles below the .intro rule by copying this CSS:
main { padding-block: 2rem 4rem; }
.filter-panel { display: grid; gap: 1.5rem; margin-top: -3rem; padding: 1.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1.25rem; box-shadow: var(--shadow); }
.filter-panel h2, .topics-header h2 { margin: 0; line-height: 1.2; }
.filter-controls, .filter-group { display: flex; flex-wrap: wrap; }
.filter-controls { gap: 1.25rem; }
.filter-label { margin: 0 0 0.6rem; font-size: 0.9rem; }
.filter-group { gap: 0.65rem; }
.filter-button { padding: 0.65rem 0.9rem; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--ink); cursor: pointer; }
.filter-button:hover { border-color: var(--primary); }
.filter-button.is-active { border-color: var(--primary); background: var(--primary); color: #ffffff; }
.result-summary { margin: 0; color: var(--muted); font-weight: 700; }
How Does the Filter Panel Change?
- The panel uses grid layout plus a negative top margin to overlap the header edge.
- The filter groups wrap their buttons when the available width becomes tight.
- The is-active class gives the default filter its blue selected appearance.
- Save styles.css.
- Refresh the browser tab.
You should see a raised white filter panel overlapping the header. The Tất cả button should have a blue background.
Filter Panel Not Overlapping the Header?
Check that the panel class is spelled filter-panel in both files. Confirm that its margin-top value includes the minus sign.
Help me debug the filter panel layout in my dashboard.
- Add the topic card grid plus wide-screen layout below the .result-summary rule by copying this CSS:
.topics-section { padding-top: 3rem; }
.topics-header { margin-bottom: 1.25rem; }
.topic-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
.topic-card { display: flex; flex-direction: column; min-height: 16rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; }
.topic-card h3 { margin: 1rem 0 0.5rem; line-height: 1.25; }
.topic-card p { margin-top: 0; color: var(--muted); }
.topic-tag { align-self: flex-start; padding: 0.25rem 0.55rem; border-radius: 999px; background: var(--accent); color: var(--primary-dark); font-size: 0.78rem; font-weight: 800; }
.topic-level { margin-top: auto !important; padding-top: 1rem; color: var(--primary-dark) !important; font-weight: 800; }
.empty-state { margin-top: 1rem; padding: 1rem; background: var(--empty); border: 1px solid #e3c460; border-radius: 0.75rem; color: #5b4300; }
.site-footer { padding: 1.5rem 0; background: #17213d; color: #dce5ff; }
@media (min-width: 48rem) { .filter-panel { grid-template-columns: minmax(0, 1fr) auto; align-items: end; padding: 2rem; } .result-summary { grid-column: 1 / -1; } }
How Does the Responsive Layout Work?
- The topic grid creates as many columns as the available width can support.
- Each card uses a flexible column layout so its level label stays near the bottom.
- The empty state has a distinct background for future zero-result feedback.
- The 48rem media query rearranges the filter panel when the viewport has more space.
- Save styles.css.
Before the final check, how do you expect the card grid plus filter panel to react as the browser width changes?
- Refresh the browser tab.
- Narrow the browser window until the topic cards form one column.
- Expand the browser window beyond 48rem.
- Select the HTML filter button again.
You should see the cards move from one column to multiple columns as the window grows. The page should remain free from horizontal scrolling.
All six cards still remain after you select HTML. The polished controls now expose the exact interaction gap that the filtering logic needs to solve.
Grid Not Responding to the Window Width?
Check that display: grid plus grid-template-columns are both inside the .topic-grid rule. Confirm that the media query closes with two braces.
Help me debug the responsive CSS Grid layout.
✔️ Awesome, I've got everything!
Your semantic dashboard now has its complete responsive visual system. Make sure styles.css is saved.
ⓧ I'd like to double check the full code
Compare your complete styles.css file with this reference.
:root {
color-scheme: light;
--page: #f4f6fb;
--surface: #ffffff;
--ink: #17213d;
--muted: #5d6680;
--line: #d9dfeb;
--primary: #3157d5;
--primary-dark: #203b9e;
--accent: #dce5ff;
--empty: #fff4d6;
--shadow: 0 1rem 2.5rem rgb(23 33 61 / 0.1);
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; }
button { font: inherit; }
[hidden] { display: none !important; }
.container { width: calc(100% - 2rem); max-width: 72rem; margin-inline: auto; }
.site-header { padding: 4.5rem 0 4rem; background: #17213d; color: #ffffff; }
.eyebrow { margin: 0 0 0.5rem; color: #8ea8ff; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.site-header h1 { max-width: 12ch; margin: 0; font-size: clamp(2.4rem, 8vw, 5rem); line-height: 0.98; }
.intro { max-width: 42rem; margin: 1.5rem 0 0; color: #dce5ff; font-size: 1.1rem; }
main { padding-block: 2rem 4rem; }
.filter-panel { display: grid; gap: 1.5rem; margin-top: -3rem; padding: 1.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1.25rem; box-shadow: var(--shadow); }
.filter-panel h2, .topics-header h2 { margin: 0; line-height: 1.2; }
.filter-controls, .filter-group { display: flex; flex-wrap: wrap; }
.filter-controls { gap: 1.25rem; }
.filter-label { margin: 0 0 0.6rem; font-size: 0.9rem; }
.filter-group { gap: 0.65rem; }
.filter-button { padding: 0.65rem 0.9rem; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--ink); cursor: pointer; }
.filter-button:hover { border-color: var(--primary); }
.filter-button.is-active { border-color: var(--primary); background: var(--primary); color: #ffffff; }
.result-summary { margin: 0; color: var(--muted); font-weight: 700; }
.topics-section { padding-top: 3rem; }
.topics-header { margin-bottom: 1.25rem; }
.topic-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
.topic-card { display: flex; flex-direction: column; min-height: 16rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; }
.topic-card h3 { margin: 1rem 0 0.5rem; line-height: 1.25; }
.topic-card p { margin-top: 0; color: var(--muted); }
.topic-tag { align-self: flex-start; padding: 0.25rem 0.55rem; border-radius: 999px; background: var(--accent); color: var(--primary-dark); font-size: 0.78rem; font-weight: 800; }
.topic-level { margin-top: auto !important; padding-top: 1rem; color: var(--primary-dark) !important; font-weight: 800; }
.empty-state { margin-top: 1rem; padding: 1rem; background: var(--empty); border: 1px solid #e3c460; border-radius: 0.75rem; color: #5b4300; }
.site-footer { padding: 1.5rem 0; background: #17213d; color: #dce5ff; }
@media (min-width: 48rem) { .filter-panel { grid-template-columns: minmax(0, 1fr) auto; align-items: end; padding: 2rem; } .result-summary { grid-column: 1 / -1; } }
How to Compare This File
Compare the custom properties plus page foundation first. Then check the filter panel, topic grid plus final media query in order.
You now have a responsive semantic dashboard that clearly presents all six revision topics. Next, you will connect the filter controls to the category data so each selection changes what the learner sees.
Activate the Category Filter
Your responsive dashboard already presents five category controls. Those controls still leave all six cards visible because nothing connects each choice to the card data.
In this step, JavaScript connects every button to the DOM. Each click will update the visible cards, the selected button, the result count, and the empty state.
In this step, get ready to:
- Select the filter controls, topic cards, and feedback elements from the page.
- Build applyCategoryFilter(category) to match cards against their data-category values.
- Connect button clicks to the filtering function and verify every category state.
Connect JavaScript to the dashboard
The script needs references to the controls and content that already exist in index.html. A label map also gives each category a readable name for the result summary.
- Select app.js from the Explorer sidebar in Visual Studio Code.
- Connect the script to the dashboard by adding this code to the empty file:
const filterButtons = document.querySelectorAll(".filter-button");
const topicCards = document.querySelectorAll(".topic-card");
const resultSummary = document.getElementById("result-summary");
const emptyState = document.getElementById("empty-state");
const categoryLabels = {
all: "tất cả lĩnh vực",
html: "HTML",
css: "CSS",
javascript: "JavaScript",
accessibility: "Accessibility",
};
What does this code connect?
- The two querySelectorAll() calls collect the five buttons and six cards as lists that the script can loop through.
- The two getElementById() calls select the result summary and empty state by their unique IDs.
- The categoryLabels object maps stored filter values to the text shown to the learner.
- Save app.js.
- Return to the browser from earlier.
- Refresh index.html.
- Use your browser's developer tools to open its JavaScript console.
- Check the selected button collection by entering this expression:
filterButtons
What should the console show?
The console identifies filterButtons as a list containing five button elements. This proves the script can reach every category control.
Is the collection missing?
Confirm that app.js is saved inside web-foundations-revision-hub. Check that index.html still loads the file through its existing script element.
Help me diagnose the missing button collection.
Build the filtering function
Each card exposes its category through dataset.category. The filtering function compares that value with the selected category before updating the card's hidden state.
- Return to app.js.
- Add the filtering function below categoryLabels by pasting this code:
function applyCategoryFilter(category) {
let visibleCount = 0;
topicCards.forEach((card) => {
const matchesCategory =
category === "all" || card.dataset.category === category;
card.hidden = !matchesCategory;
if (matchesCategory) {
visibleCount += 1;
}
});
emptyState.hidden = visibleCount !== 0;
resultSummary.textContent = `${visibleCount} chủ đề thuộc ${categoryLabels[category]} đang hiển thị.`;
}
How does the filter work?
- The all category matches every card. Every other category must equal the card's data-category value.
- The hidden property removes nonmatching cards from the rendered page.
- The visibleCount value controls the empty state and supplies the number in the result summary.
- Save app.js.
- Return to the JavaScript console from the previous check.
- Run the default category through the new function by entering:
applyCategoryFilter("all");
What should the page show?
The page shows all six cards with the summary text 6 chủ đề thuộc tất cả lĩnh vực đang hiển thị. The empty-state message stays hidden.
Does the function fail in the console?
Check the closing braces in applyCategoryFilter(category). Confirm that categoryLabels appears above the function.
Help me fix the category filtering function.
Wire clicks to visible state
The active style gives visual feedback after a click. ARIA state gives the same selection information to assistive technology while the result summary reports each update.
- Switch to index.html.
- Replace the current filter group and result summary with this updated markup:
<div class="filter-group">
<button class="filter-button is-active" type="button" data-filter="all" aria-pressed="true">Tất cả</button>
<button class="filter-button" type="button" data-filter="html" aria-pressed="false">HTML</button>
<button class="filter-button" type="button" data-filter="css" aria-pressed="false">CSS</button>
<button class="filter-button" type="button" data-filter="javascript" aria-pressed="false">JavaScript</button>
<button class="filter-button" type="button" data-filter="accessibility" aria-pressed="false">Accessibility</button>
</div>
</div>
</div>
<p id="result-summary" class="result-summary" aria-live="polite">6 chủ đề thuộc tất cả lĩnh vực đang hiển thị.</p>
What do these states communicate?
The default button starts with aria-pressed="true". The other buttons start with aria-pressed="false".
The aria-live="polite" attribute allows result-summary updates to be announced without interrupting the current task.
- Save index.html.
- Return to the browser.
- Refresh the page.
- Use your browser's developer tools to inspect the Tất cả button.
You should find aria-pressed="true" on the selected button. The result summary should contain aria-live="polite".
Are the attributes missing?
Confirm that you replaced the filter controls inside the existing filter-group element. Make sure every attribute remains inside its opening button or paragraph tag.
Help me place the ARIA attributes correctly.
Keyboard focus also needs a visible boundary. The focus rule makes the current button easy to locate without changing the mouse experience.
- Switch to styles.css.
- Add this focus rule below .filter-button.is-active:
.filter-button:focus-visible {
outline: 3px solid #f0a500;
outline-offset: 3px;
}
What does the focus rule add?
The :focus-visible selector adds a three-pixel outline when the browser determines that focus should be shown. The offset leaves space between the button and its focus indicator.
- Save styles.css.
- Return to the browser.
- Refresh the page.
- Press Tab until focus reaches a category filter button.
You should see an orange outline around the focused button. The outline remains separated from the pill-shaped border.
Is the focus outline missing?
Confirm that the selector begins with .filter-button. Check that both declarations sit inside the same pair of braces.
Help me debug the missing focus indicator.
The final listener reads the chosen button's dataset.filter value. It then synchronizes the active class, pressed state, and filtering function.
- Switch back to app.js.
- Add the click listeners and default initialization below applyCategoryFilter(category) by pasting this code:
filterButtons.forEach((button) => {
button.addEventListener("click", () => {
const selectedCategory = button.dataset.filter;
filterButtons.forEach((candidate) => {
const isActive = candidate === button;
candidate.classList.toggle("is-active", isActive);
candidate.setAttribute("aria-pressed", isActive ? "true" : "false");
});
applyCategoryFilter(selectedCategory);
});
});
applyCategoryFilter("all");
How does each click update the page?
- The outer loop attaches one click listener to every category button.
- The inner loop uses classList.toggle() and setAttribute() to keep one button selected.
- The final call initializes the page with the all category whenever the script loads.
Before you test the completed filter, which cards do you expect each category button to leave visible?
- Save app.js.
- Return to the browser.
- Refresh index.html.
- Select CSS.
You should see the two CSS cards. The summary should report two visible CSS topics.
- Select JavaScript.
You should see the two JavaScript cards. The selected styling and pressed state should move to the JavaScript button.
- Select Accessibility.
You should see no topic cards. The empty-state message should appear while the summary reports zero Accessibility topics.
That interaction loop is working. Your dashboard now converts a category choice into synchronized card visibility, button state, result text, and empty-state feedback.
Do all six cards remain visible?
Confirm that button.dataset.filter uses filter. Confirm that card.dataset.category uses category.
Help me debug the category click flow.
✔️ Awesome, I've got everything!
Great. Save index.html, styles.css, and app.js before moving on.
ⓧ I'd like to double check the full code
Compare each file with the completed state below.
app.js
const filterButtons = document.querySelectorAll(".filter-button");
const topicCards = document.querySelectorAll(".topic-card");
const resultSummary = document.getElementById("result-summary");
const emptyState = document.getElementById("empty-state");
const categoryLabels = {
all: "tất cả lĩnh vực",
html: "HTML",
css: "CSS",
javascript: "JavaScript",
accessibility: "Accessibility",
};
function applyCategoryFilter(category) {
let visibleCount = 0;
topicCards.forEach((card) => {
const matchesCategory =
category === "all" || card.dataset.category === category;
card.hidden = !matchesCategory;
if (matchesCategory) {
visibleCount += 1;
}
});
emptyState.hidden = visibleCount !== 0;
resultSummary.textContent = `${visibleCount} chủ đề thuộc ${categoryLabels[category]} đang hiển thị.`;
}
filterButtons.forEach((button) => {
button.addEventListener("click", () => {
const selectedCategory = button.dataset.filter;
filterButtons.forEach((candidate) => {
const isActive = candidate === button;
candidate.classList.toggle("is-active", isActive);
candidate.setAttribute("aria-pressed", isActive ? "true" : "false");
});
applyCategoryFilter(selectedCategory);
});
});
applyCategoryFilter("all");
What to compare in app.js
Check the four element references, both category mappings, the complete filtering function, the listener loop, and the final initialization call.
index.html
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<title>Web Foundations Revision Hub</title>
<link rel="stylesheet" href="styles.css">
<script src="app.js" defer></script>
</head>
<body>
<header class="site-header"><div class="container"><p class="eyebrow">Một codebase, nhiều lớp kỹ năng</p><h1>Web Foundations Revision Hub</h1><p class="intro">Lọc nhanh các chủ đề HTML, CSS và JavaScript để chọn nội dung cần ôn tiếp theo.</p></div></header>
<main class="container">
<section class="filter-panel" aria-labelledby="filter-heading">
<div><p class="eyebrow">Bộ lọc chủ đề</p><h2 id="filter-heading">Bạn muốn ôn phần nào?</h2></div>
<div class="filter-controls"><div class="filter-block"><h3 class="filter-label">Lĩnh vực</h3><div class="filter-group"><button class="filter-button is-active" type="button" data-filter="all" aria-pressed="true">Tất cả</button><button class="filter-button" type="button" data-filter="html" aria-pressed="false">HTML</button><button class="filter-button" type="button" data-filter="css" aria-pressed="false">CSS</button><button class="filter-button" type="button" data-filter="javascript" aria-pressed="false">JavaScript</button><button class="filter-button" type="button" data-filter="accessibility" aria-pressed="false">Accessibility</button></div></div></div>
<p id="result-summary" class="result-summary" aria-live="polite">6 chủ đề thuộc tất cả lĩnh vực đang hiển thị.</p>
</section>
<section class="topics-section" aria-labelledby="topics-heading">
<div class="topics-header"><p class="eyebrow">Thư viện ôn tập</p><h2 id="topics-heading">Chủ đề hiện có</h2></div>
<div class="topic-grid">
<article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Cấu trúc tài liệu semantic</h3><p>Dùng đúng phần tử để trình bày header, nội dung chính, section và footer.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Biểu mẫu có nhãn rõ ràng</h3><p>Ghép label với control và nhóm các trường liên quan thành một luồng dễ hiểu.</p><p class="topic-level">Luyện tập</p></article>
<article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>Cascade và specificity</h3><p>Dự đoán quy tắc nào thắng trước khi thêm selector mạnh hơn hoặc dùng ghi đè.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>CSS Grid responsive</h3><p>Tạo lưới tự co giãn và chỉ thêm breakpoint khi nội dung thực sự cần đổi bố cục.</p><p class="topic-level">Luyện tập</p></article>
<article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Sự kiện và DOM</h3><p>Kết nối hành động của người dùng với phần tử giao diện bằng event listener.</p><p class="topic-level">Nền tảng</p></article>
<article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Logic lọc dữ liệu</h3><p>Chuyển điều kiện lọc thành trạng thái hiển thị nhất quán trên toàn bộ danh sách.</p><p class="topic-level">Luyện tập</p></article>
</div>
<p id="empty-state" class="empty-state" hidden>Chưa có chủ đề trong nhóm này. Hãy chọn một bộ lọc khác.</p>
</section>
</main>
<footer class="site-footer"><div class="container"><p>Phiên bản cục bộ để luyện tập web nền tảng.</p></div></footer>
</body>
</html>
What to compare in index.html
Check the five pressed states and the live result summary. Keep the existing category values and topic cards unchanged.
styles.css
:root { color-scheme: light; --page: #f4f6fb; --surface: #ffffff; --ink: #17213d; --muted: #5d6680; --line: #d9dfeb; --primary: #3157d5; --primary-dark: #203b9e; --accent: #dce5ff; --empty: #fff4d6; --shadow: 0 1rem 2.5rem rgb(23 33 61 / 0.1); }
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; }
button { font: inherit; }
[hidden] { display: none !important; }
.container { width: calc(100% - 2rem); max-width: 72rem; margin-inline: auto; }
.site-header { padding: 4.5rem 0 4rem; background: #17213d; color: #ffffff; }
.eyebrow { margin: 0 0 0.5rem; color: #8ea8ff; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.site-header h1 { max-width: 12ch; margin: 0; font-size: clamp(2.4rem, 8vw, 5rem); line-height: 0.98; }
.intro { max-width: 42rem; margin: 1.5rem 0 0; color: #dce5ff; font-size: 1.1rem; }
main { padding-block: 2rem 4rem; }
.filter-panel { display: grid; gap: 1.5rem; margin-top: -3rem; padding: 1.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1.25rem; box-shadow: var(--shadow); }
.filter-panel h2, .topics-header h2 { margin: 0; line-height: 1.2; }
.filter-controls, .filter-group { display: flex; flex-wrap: wrap; }
.filter-controls { gap: 1.25rem; }
.filter-label { margin: 0 0 0.6rem; font-size: 0.9rem; }
.filter-group { gap: 0.65rem; }
.filter-button { padding: 0.65rem 0.9rem; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--ink); cursor: pointer; }
.filter-button:hover { border-color: var(--primary); }
.filter-button.is-active { border-color: var(--primary); background: var(--primary); color: #ffffff; }
.filter-button:focus-visible { outline: 3px solid #f0a500; outline-offset: 3px; }
.result-summary { margin: 0; color: var(--muted); font-weight: 700; }
.topics-section { padding-top: 3rem; }
.topics-header { margin-bottom: 1.25rem; }
.topic-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
.topic-card { display: flex; flex-direction: column; min-height: 16rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; }
.topic-card h3 { margin: 1rem 0 0.5rem; line-height: 1.25; }
.topic-card p { margin-top: 0; color: var(--muted); }
.topic-tag { align-self: flex-start; padding: 0.25rem 0.55rem; border-radius: 999px; background: var(--accent); color: var(--primary-dark); font-size: 0.78rem; font-weight: 800; }
.topic-level { margin-top: auto !important; padding-top: 1rem; color: var(--primary-dark) !important; font-weight: 800; }
.empty-state { margin-top: 1rem; padding: 1rem; background: var(--empty); border: 1px solid #e3c460; border-radius: 0.75rem; color: #5b4300; }
.site-footer { padding: 1.5rem 0; background: #17213d; color: #dce5ff; }
@media (min-width: 48rem) { .filter-panel { grid-template-columns: minmax(0, 1fr) auto; align-items: end; padding: 2rem; } .result-summary { grid-column: 1 / -1; } }
What to compare in styles.css
Check that the focus-visible rule appears after the active-button rule. The rest of the responsive dashboard styles remain intact.
Your category filter now responds to every selection. Next, you'll test the complete keyboard flow and refine how the interface communicates each state change.
Improve Keyboard Feedback
Your category filter already responds to clicks. The visible cards now follow the selected category.
Visual feedback alone leaves keyboard users without a clear focus cue. Assistive technology also needs the selected state.
This step confirms the existing pressed state and visible keyboard outline. The live result summary completes the feedback loop.
In this step, get ready to:
- Confirm that every filter exposes its selected state.
- Follow the visible keyboard focus through the filter group.
- Verify each category from the keyboard.
Confirm the accessible state
Native buttons already support keyboard navigation in browsers. Their attributes communicate the current choice to assistive technology.
- In the Explorer sidebar of Visual Studio Code from earlier, select index.html.
- Find the Tất cả button.
- Confirm that the button contains aria-pressed="true".
- Confirm that each remaining filter button contains aria-pressed="false".
- Find #result-summary.
- Confirm that the result summary contains aria-live="polite".
- Select app.js in the Explorer sidebar.
- Locate candidate.setAttribute("aria-pressed", isActive ? "true" : "false") inside the click listener.
How Does Accessible Feedback Work?
ARIA uses aria-pressed to expose whether a toggle button is selected. The listener updates this value whenever the existing click event runs.
The aria-live="polite" region announces updated result text at a suitable pause. This keeps the result count connected to each filter choice.
Follow keyboard focus
The :focus-visible selector shows an outline when the browser determines that focus needs a visible indicator. Keyboard navigation makes that indicator easy to test.
- Return to the browser from earlier.
- Refresh index.html.
- Press Tab repeatedly until focus reaches a filter button.
You should see an orange outline around the focused button. The outline sits outside the button border so the selected colour remains visible.
Can't See the Focus Outline?
Your browser or operating system may limit which controls receive focus from the Tab key. Enable keyboard navigation for all controls in your browser or system preferences.
Return to the page after changing that preference. Help me troubleshoot missing keyboard focus on my filter buttons.
Test every filter with the keyboard
Each filter is a native button. Pressing Enter triggers the same click listener that handles a mouse selection.
Before you run the keyboard check, which category do you expect to show no cards?
- Refresh the browser to restore the default Tất cả selection.
- Press Tab until the HTML button has focus.
- Press Enter.
You should see two HTML cards. The HTML button becomes the active selection.
- Press Tab once to focus the CSS button.
- Press Enter.
You should see two CSS cards. The result summary updates to describe the CSS selection.
- Press Tab once to focus the JavaScript button.
- Press Enter.
You should see two JavaScript cards. The active button styling moves to JavaScript.
- Press Tab once to focus the Accessibility button.
- Press Enter.
You should see the empty-state message. The result summary reports zero visible Accessibility topics.
Before you return to Tất cả, do you expect the full card grid to return?
- Press Shift+Tab repeatedly until the Tất cả button has focus.
- Press Enter.
You should see all six cards again. The summary reads 6 chủ đề thuộc tất cả lĩnh vực đang hiển thị.
✔️ Awesome, I've got everything!
Your three files contain the pressed-state feedback, live result region, and visible keyboard focus style.
ⓧ I'd like to double check the full code
These references show the exact file contents at this checkpoint.
index.html
<!doctype html>
<html lang="vi"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Web Foundations Revision Hub</title><link rel="stylesheet" href="styles.css"><script src="app.js" defer></script></head><body><header class="site-header"><div class="container"><p class="eyebrow">Một codebase, nhiều lớp kỹ năng</p><h1>Web Foundations Revision Hub</h1><p class="intro">Lọc nhanh các chủ đề HTML, CSS và JavaScript để chọn nội dung cần ôn tiếp theo.</p></div></header><main class="container"><section class="filter-panel" aria-labelledby="filter-heading"><div><p class="eyebrow">Bộ lọc chủ đề</p><h2 id="filter-heading">Bạn muốn ôn phần nào?</h2></div><div class="filter-controls"><div class="filter-block"><h3 class="filter-label">Lĩnh vực</h3><div class="filter-group"><button class="filter-button is-active" type="button" data-filter="all" aria-pressed="true">Tất cả</button><button class="filter-button" type="button" data-filter="html" aria-pressed="false">HTML</button><button class="filter-button" type="button" data-filter="css" aria-pressed="false">CSS</button><button class="filter-button" type="button" data-filter="javascript" aria-pressed="false">JavaScript</button><button class="filter-button" type="button" data-filter="accessibility" aria-pressed="false">Accessibility</button></div></div></div><p id="result-summary" class="result-summary" aria-live="polite">6 chủ đề thuộc tất cả lĩnh vực đang hiển thị.</p></section><section class="topics-section" aria-labelledby="topics-heading"><div class="topics-header"><p class="eyebrow">Thư viện ôn tập</p><h2 id="topics-heading">Chủ đề hiện có</h2></div><div class="topic-grid"><article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Cấu trúc tài liệu semantic</h3><p>Dùng đúng phần tử để trình bày header, nội dung chính, section và footer.</p><p class="topic-level">Nền tảng</p></article><article class="topic-card" data-category="html"><span class="topic-tag">HTML</span><h3>Biểu mẫu có nhãn rõ ràng</h3><p>Ghép label với control và nhóm các trường liên quan thành một luồng dễ hiểu.</p><p class="topic-level">Luyện tập</p></article><article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>Cascade và specificity</h3><p>Dự đoán quy tắc nào thắng trước khi thêm selector mạnh hơn hoặc dùng ghi đè.</p><p class="topic-level">Nền tảng</p></article><article class="topic-card" data-category="css"><span class="topic-tag">CSS</span><h3>CSS Grid responsive</h3><p>Tạo lưới tự co giãn và chỉ thêm breakpoint khi nội dung thực sự cần đổi bố cục.</p><p class="topic-level">Luyện tập</p></article><article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Sự kiện và DOM</h3><p>Kết nối hành động của người dùng với phần tử giao diện bằng event listener.</p><p class="topic-level">Nền tảng</p></article><article class="topic-card" data-category="javascript"><span class="topic-tag">JavaScript</span><h3>Logic lọc dữ liệu</h3><p>Chuyển điều kiện lọc thành trạng thái hiển thị nhất quán trên toàn bộ danh sách.</p><p class="topic-level">Luyện tập</p></article></div><p id="empty-state" class="empty-state" hidden>Chưa có chủ đề trong nhóm này. Hãy chọn một bộ lọc khác.</p></section></main><footer class="site-footer"><div class="container"><p>Phiên bản cục bộ để luyện tập web nền tảng.</p></div></footer></body></html>
styles.css
:root { color-scheme: light; --page: #f4f6fb; --surface: #ffffff; --ink: #17213d; --muted: #5d6680; --line: #d9dfeb; --primary: #3157d5; --primary-dark: #203b9e; --accent: #dce5ff; --empty: #fff4d6; --shadow: 0 1rem 2.5rem rgb(23 33 61 / 0.1); }
* { box-sizing: border-box; }
body { margin: 0; background: var(--page); color: var(--ink); font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; }
button { font: inherit; }
[hidden] { display: none !important; }
.container { width: calc(100% - 2rem); max-width: 72rem; margin-inline: auto; }
.site-header { padding: 4.5rem 0 4rem; background: #17213d; color: #ffffff; }
.eyebrow { margin: 0 0 0.5rem; color: #8ea8ff; font-size: 0.78rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; }
.site-header h1 { max-width: 12ch; margin: 0; font-size: clamp(2.4rem, 8vw, 5rem); line-height: 0.98; }
.intro { max-width: 42rem; margin: 1.5rem 0 0; color: #dce5ff; font-size: 1.1rem; }
main { padding-block: 2rem 4rem; }
.filter-panel { display: grid; gap: 1.5rem; margin-top: -3rem; padding: 1.5rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1.25rem; box-shadow: var(--shadow); }
.filter-panel h2, .topics-header h2 { margin: 0; line-height: 1.2; }
.filter-controls, .filter-group { display: flex; flex-wrap: wrap; }
.filter-controls { gap: 1.25rem; }
.filter-label { margin: 0 0 0.6rem; font-size: 0.9rem; }
.filter-group { gap: 0.65rem; }
.filter-button { padding: 0.65rem 0.9rem; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--ink); cursor: pointer; }
.filter-button:hover { border-color: var(--primary); }
.filter-button.is-active { border-color: var(--primary); background: var(--primary); color: #ffffff; }
.filter-button:focus-visible { outline: 3px solid #f0a500; outline-offset: 3px; }
.result-summary { margin: 0; color: var(--muted); font-weight: 700; }
.topics-section { padding-top: 3rem; }
.topics-header { margin-bottom: 1.25rem; }
.topic-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1rem; }
.topic-card { display: flex; flex-direction: column; min-height: 16rem; padding: 1.25rem; background: var(--surface); border: 1px solid var(--line); border-radius: 1rem; }
.topic-card h3 { margin: 1rem 0 0.5rem; line-height: 1.25; }
.topic-card p { margin-top: 0; color: var(--muted); }
.topic-tag { align-self: flex-start; padding: 0.25rem 0.55rem; border-radius: 999px; background: var(--accent); color: var(--primary-dark); font-size: 0.78rem; font-weight: 800; }
.topic-level { margin-top: auto !important; padding-top: 1rem; color: var(--primary-dark) !important; font-weight: 800; }
.empty-state { margin-top: 1rem; padding: 1rem; background: var(--empty); border: 1px solid #e3c460; border-radius: 0.75rem; color: #5b4300; }
.site-footer { padding: 1.5rem 0; background: #17213d; color: #dce5ff; }
@media (min-width: 48rem) { .filter-panel { grid-template-columns: minmax(0, 1fr) auto; align-items: end; padding: 2rem; } .result-summary { grid-column: 1 / -1; } }
app.js
const filterButtons = document.querySelectorAll(".filter-button");
const topicCards = document.querySelectorAll(".topic-card");
const resultSummary = document.getElementById("result-summary");
const emptyState = document.getElementById("empty-state");
const categoryLabels = { all: "tất cả lĩnh vực", html: "HTML", css: "CSS", javascript: "JavaScript", accessibility: "Accessibility" };
function applyCategoryFilter(category) { let visibleCount = 0; topicCards.forEach((card) => { const matchesCategory = category === "all" || card.dataset.category === category; card.hidden = !matchesCategory; if (matchesCategory) visibleCount += 1; }); emptyState.hidden = visibleCount !== 0; resultSummary.textContent = `${visibleCount} chủ đề thuộc ${categoryLabels[category]} đang hiển thị.`; }
filterButtons.forEach((button) => { button.addEventListener("click", () => { const selectedCategory = button.dataset.filter; filterButtons.forEach((candidate) => { const isActive = candidate === button; candidate.classList.toggle("is-active", isActive); candidate.setAttribute("aria-pressed", isActive ? "true" : "false"); }); applyCategoryFilter(selectedCategory); }); });
applyCategoryFilter("all");
That completes the feedback loop. Keyboard activation now drives the same filter result as a click.
Secret mission
Kết hợp hai chiều lọc
Mở rộng Revision Hub bằng một bộ lọc mức độ cho Nền tảng và Luyện tập. Mỗi thẻ sẽ chỉ xuất hiện khi khớp đồng thời với lĩnh vực và mức độ đang chọn.
Clean Up Your Resources
Clean Up Your Resources
Dự án chạy hoàn toàn cục bộ nên không có chi phí tiếp diễn. Hãy chọn giữ nguyên thư mục, tạm dừng để quay lại sau hoặc xóa hoàn toàn.
Resources you used:
- Thư mục cục bộ web-foundations-revision-hub chứa index.html, styles.css và app.js.
Keep everything running
Không cần thực hiện bước dọn dẹp nào. Chọn phương án này nếu bạn muốn tiếp tục phát triển Revision Hub.
- Giữ thư mục web-foundations-revision-hub trên máy Mac.
- Tiếp tục chỉnh sửa index.html, styles.css và app.js khi bạn muốn mở rộng dashboard.
- Mở lại index.html trong trình duyệt để kiểm tra các thay đổi sau này.
Pause - I'll come back to this later
Khép lại phiên làm việc hiện tại để giữ màn hình gọn gàng. Toàn bộ mã nguồn vẫn nằm trong thư mục dự án khi bạn quay lại.
- Lưu mọi thay đổi trong index.html, styles.css và app.js.
- Đóng tab trình duyệt đang hiển thị Revision Hub.
- Đóng cửa sổ trình soạn thảo đang mở dự án.
- Giữ nguyên thư mục web-foundations-revision-hub để tiếp tục sau này.
Delete - I don't want to use this again
Xóa thư mục sẽ loại bỏ toàn bộ mã nguồn của Revision Hub. Thao tác này chỉ ảnh hưởng đến bản sao cục bộ trong thư mục dự án.
- Trong thanh bên tệp của Visual Studio Code, nhấp chuột phải vào thư mục web-foundations-revision-hub.
- Chọn tùy chọn hiển thị thư mục đó trong Finder.
- Trong Finder, chuyển thư mục web-foundations-revision-hub vào thùng rác.
- Làm trống thùng rác để xóa thư mục khỏi máy Mac.
- Dùng thanh tìm kiếm của Finder để tìm lại web-foundations-revision-hub.
Bạn sẽ không còn thấy thư mục dự án trong kết quả tìm kiếm.
Nice Work!
Nice Work!
Bạn đã hoàn thành rồi! Web Foundations Revision Hub của bạn chạy cục bộ với bố cục thích ứng theo kích thước cửa sổ. Dashboard giờ lọc thẻ theo hai điều kiện.
Bạn đã học cách:
- Tổ chức dashboard bằng Semantic HTML. Phần giới thiệu, bảng điều khiển, thư viện thẻ cùng footer đều có vai trò rõ ràng.
- Tạo bố cục thích ứng bằng CSS Grid. Lưới chuyển số cột theo không gian có sẵn mà không tạo thanh cuộn ngang.
- Dùng DOM API để kết nối các nút với thẻ qua event listener. Phản hồi accessibility cập nhật số kết quả, trạng thái trống, aria-pressed, aria-live cùng focus bàn phím.
- Hoàn thành Secret Mission bằng bộ lọc hai chiều. Một thẻ chỉ xuất hiện khi thỏa cả activeCategory lẫn activeLevel.
Ready to quiz yourself?