0

[Series 100 Bài Học HTML/CSS Thực Chiến - Bài 07]: Xây Dựng Giỏ Hàng Mini (Mini Cart Popover) Đa Trạng Thái & Tuyệt Chiêu Cắt Chữ Dấu Ba Chấm (text-overflow: ellipsis)

Chào mừng anh em đã bước sang Bài 07 trong chuỗi 100 Bài Học Làm Chủ HTML/CSS Qua Dự Án Clone Shopee!

Ở Bài 06, chúng ta đã dựng xong khung tìm kiếm và đặt chiếc xe đẩy Giỏ hàng kèm con số 3 ở góc phải vùng Header. Tuy nhiên, trên một trang Thương mại điện tử thực tế như Shopee, khi người dùng rê chuột vào chiếc xe đẩy đó, giao diện sẽ phải xử lý 2 kịch bản (UI States) hoàn toàn khác nhau:

  1. Trạng thái Giỏ hàng trống (Empty State): Khi người dùng chưa thêm món đồ nào, hộp thoại hiện ra hình minh họa túi hàng trống kèm dòng chữ "Chưa có sản phẩm".

  2. Trạng thái Giỏ hàng có sản phẩm (Filled State): Hiển thị danh sách sản phẩm vừa thêm (gồm ảnh nhỏ, tên sản phẩm, phân loại hàng, đơn giá ×\times số lượng, nút Xóa) và nút "Xem Giỏ Hàng" màu cam ở góc dưới.

Trong Bài 07 hôm nay, chúng ta sẽ học cách thiết kế kiến trúc State-Driven CSS (Điều khiển trạng thái giao diện chỉ bằng 1 class modifier .header-cart-list--no-cart), đồng thời xử lý bài toán kinh điển của mọi trang E-Commerce: Làm sao để những cái tên sản phẩm dài hàng chục chữ tự động thu gọn thành dấu ba chấm (...) mà không làm vỡ khung giá tiền bên phải?

1. Kiến trúc State-Driven CSS: Chuyển đổi "Trống / Có hàng" chỉ bằng 1 Class

Nhiều bạn mới học thường viết 2 hộp thoại HTML riêng biệt cho giỏ hàng trống và giỏ hàng có sản phẩm. Cách đó làm lặp lại code tạo khung trắng, bóng đổ (box-shadow) và mũi tên tam giác nhọn (::before).

Cách làm chuẩn của các kỹ sư Frontend là nhúng cả 2 trạng thái vào chung một hộp <div class="header-cart-list">, mặc định ẩn phần "Giỏ hàng trống" đi (display: none). Khi nào giỏ hàng thực sự trống, phía Backend hoặc JavaScript chỉ cần gắn thêm đúng 1 class modifier là .header-cart-list--no-cart vào thẻ cha:

Trạng thái thẻ cha .header-cart-list Ảnh & Chữ "Chưa có sản phẩm" Danh sách sản phẩm & Nút "Xem Giỏ Hàng"
Mặc định
(Không có class --no-cart)
display: none
(Ẩn)
display: block
(Hiện đầy đủ sản phẩm)
Khi gắn thêm
.header-cart-list--no-cart
display: block
(Hiện lên)
display: none
(Tự động ẩn toàn bộ danh sách)

2. Bài toán "Tên sản phẩm dài vô tận" & Bộ 3 thuộc tính cắt chữ (ellipsis)

Trên Shopee, người bán thường đặt tên sản phẩm rất dài để tối ưu từ khóa tìm kiếm, ví dụ: "Bàn phím cơ không dây AULA F75 Pro Mạch Xuôi Hotswap 5 Pin Đèn LED RGB 16.8 Triệu Màu Bảo Hành 12 Tháng".

Nếu bạn đặt tên sản phẩm này nằm cạnh Giá tiền (850.000₫) trong một dòng Flexbox, cái tên quá dài sẽ đẩy văng giá tiền ra khỏi hộp thoại, hoặc rớt thành 3–4 dòng làm chiều cao mỗi sản phẩm lồi lõm không đều nhau!

Hãy nhìn vào sơ đồ minh họa cơ chế cắt chữ bằng CSS dưới đây để thấy cách trình duyệt xử lý văn bản vượt quá chiều rộng khung chứa:

image.png

Để ép tên sản phẩm chỉ nằm gọn trên 1 dòng duy nhất và tự động biến phần chữ thừa ở cuối thành dấu ..., chúng ta sử dụng bộ 3 thuộc tính "bất khả phân ly":

CSS

.header-cart-item-name {
    white-space: nowrap;      /* 1. Cấm văn bản tự động xuống dòng */
    overflow: hidden;         /* 2. Giấu toàn bộ phần chữ bị tràn ra ngoài khung */
    text-overflow: ellipsis;  /* 3. Biến phần chữ vừa bị giấu ở mép cắt thành dấu "..." */
}

Lưu ý cực kỳ quan trọng khi dùng ellipsis trong Flexbox: Mặc định, một phần tử con trong Flexbox (flex: 1) có thuộc tính ngầm định là min-width: auto (nó không chịu co nhỏ hơn độ dài nội dung chữ bên trong). Do đó, nếu thẻ cha bọc tên sản phẩm không được khai báo min-width: 0 (hoặc giới hạn max-width), thuộc tính text-overflow: ellipsis sẽ hoàn toàn mất tác dụng!

3. Thực hành: Code trọn bộ Giỏ Hàng Mini Shopee

Bước 1: Cập nhật cấu trúc HTML .header-cart trong index.html

Hãy mở file index.html, tìm đến khu vực <!-- 3. KHỐI GIỎ HÀNG --> bên trong .header-with-search (đã tạo ở Bài 06) và nâng cấp thành cấu trúc đầy đủ dưới đây:

HTML

<!-- 3. KHỐI GIỎ HÀNG (MINI CART POPOVER) -->
<div class="header-cart">
    <div class="header-cart-wrap">
        <i class="header-cart-icon fa-solid fa-cart-shopping"></i>
        <span class="header-cart-notice">3</span>

        <!-- HỘP THOẠI GIỎ HÀNG MINI -->
        <!-- Mẹo: Thêm class "header-cart-list--no-cart" vào thẻ div ngay dưới đây để test trạng thái Giỏ Hàng Trống! -->
        <div class="header-cart-list">
            
            <!-- TRẠNG THÁI 1: KHI GIỎ HÀNG TRỐNG (Mặc định ẩn) -->
            <img src="https://placehold.co/260x160/ffffff/ee4d2d?text=Empty+Cart+Bag" alt="Chưa có sản phẩm" class="header-cart-no-cart-img">
            <span class="header-cart-list-no-cart-msg">Chưa có sản phẩm</span>

            <!-- TRẠNG THÁI 2: KHI GIỎ HÀNG CÓ SẢN PHẨM -->
            <h4 class="header-cart-heading">Sản phẩm mới thêm</h4>
            
            <ul class="header-cart-list-item">
                <!-- Sản phẩm 1 -->
                <li class="header-cart-item">
                    <img src="https://placehold.co/100x100/ee4d2d/ffffff?text=KeyBoard" alt="Bàn phím cơ" class="header-cart-img">
                    <div class="header-cart-item-info">
                        <div class="header-cart-item-head">
                            <h5 class="header-cart-item-name">Bàn phím cơ không dây AULA F75 Pro Mạch Xuôi Hotswap RGB</h5>
                            <div class="header-cart-item-price-wrap">
                                <span class="header-cart-item-price">850.000₫</span>
                                <span class="header-cart-item-multiply">x</span>
                                <span class="header-cart-item-qnt">1</span>
                            </div>
                        </div>
                        <div class="header-cart-item-body">
                            <span class="header-cart-item-description">Phân loại: Trắng Xanh, Reaper Switch</span>
                            <span class="header-cart-item-remove">Xóa</span>
                        </div>
                    </div>
                </li>

                <!-- Sản phẩm 2 -->
                <li class="header-cart-item">
                    <img src="https://placehold.co/100x100/00bfa5/ffffff?text=Monitor" alt="Màn hình LG" class="header-cart-img">
                    <div class="header-cart-item-info">
                        <div class="header-cart-item-head">
                            <h5 class="header-cart-item-name">Màn hình Gaming LG UltraGear 27GS60F-B 27 inch FHD IPS 180Hz</h5>
                            <div class="header-cart-item-price-wrap">
                                <span class="header-cart-item-price">3.890.000₫</span>
                                <span class="header-cart-item-multiply">x</span>
                                <span class="header-cart-item-qnt">1</span>
                            </div>
                        </div>
                        <div class="header-cart-item-body">
                            <span class="header-cart-item-description">Phân loại: Bản Quốc Tế 180Hz</span>
                            <span class="header-cart-item-remove">Xóa</span>
                        </div>
                    </div>
                </li>

                <!-- Sản phẩm 3 -->
                <li class="header-cart-item">
                    <img src="https://placehold.co/100x100/f63/ffffff?text=T-Shirt" alt="Áo thun nam" class="header-cart-img">
                    <div class="header-cart-item-info">
                        <div class="header-cart-item-head">
                            <h5 class="header-cart-item-name">Áo thun nam form rộng Cotton 100% định lượng 250gsm dày dặn</h5>
                            <div class="header-cart-item-price-wrap">
                                <span class="header-cart-item-price">159.000₫</span>
                                <span class="header-cart-item-multiply">x</span>
                                <span class="header-cart-item-qnt">2</span>
                            </div>
                        </div>
                        <div class="header-cart-item-body">
                            <span class="header-cart-item-description">Phân loại: Đen Nhám, Size XL</span>
                            <span class="header-cart-item-remove">Xóa</span>
                        </div>
                    </div>
                </li>
            </ul>

            <!-- Chân hộp giỏ hàng: Nút Xem Giỏ Hàng -->
            <div class="header-cart-footer">
                <span class="header-cart-footer-msg">3 Hàng thêm vào giỏ</span>
                <a href="#" class="btn btn--primary header-cart-view-cart">Xem Giỏ Hàng</a>
            </div>

        </div>
    </div>
</div>

Bước 2: Tạo class .btn dùng chung trong ./assets/css/base.css

Vì nút bấm màu cam ("Xem Giỏ Hàng", "Đăng Nhập", "Mua Ngay") xuất hiện ở khắp mọi nơi trên Shopee, chúng ta hãy định nghĩa một bộ class nút bấm chuẩn chỉnh (.btn và .btn--primary) ở cuối file base.css để tái sử dụng cho cả 100 bài học:

CSS

/* Bổ sung vào cuối file ./assets/css/base.css */

/* --- COMPONENT NÚT BẤM DÙNG CHUNG (BUTTON) --- */
.btn {
    min-width: 12.4rem;
    height: 3.4rem;
    text-decoration: none;
    border: none;
    border-radius: 2px;
    font-size: 1.4rem;
    padding: 0 1.2rem;
    outline: none;
    cursor: pointer;
    color: var(--text-primary);
    background-color: var(--white-color);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    line-height: 1.6rem;
    transition: opacity 0.15s ease, background-color 0.15s ease;
}

.btn.btn--primary {
    color: var(--white-color);
    background-color: var(--shopee-primary);
}

.btn.btn--primary:hover {
    background-color: #fb6445;
}

Bước 3: Viết CSS chi tiết cho Giỏ Hàng Mini trong ./assets/css/main.css

Mở file main.css, tìm đến khu vực /* 3. Khối Giỏ hàng bên phải */ ở cuối file và bổ sung toàn bộ CSS cho hộp thoại .header-cart-list:

CSS

/* Bổ sung vào cuối file ./assets/css/main.css */

/* --- HỘP THOẠI GIỎ HÀNG MINI (MINI CART POPOVER) --- */
.header-cart-list {
    position: absolute;
    top: calc(100% + 1rem);         /* Cách đáy icon xe đẩy 10px */
    right: -0.2rem;
    background-color: var(--white-color);
    width: 40rem;                   /* Rộng 400px chuẩn Shopee */
    border-radius: 2px;
    box-shadow: 0 1px 3.125rem 0 rgba(0, 0, 0, 0.2);
    cursor: default;
    z-index: 10;
    
    /* Tái sử dụng animation nở ra từ góc phải giống hộp Thông Báo ở Bài 05 */
    transform-origin: calc(100% - 2rem) top;
    animation: headerNotifyGrowth ease-in 0.2s;
    will-change: opacity, transform;
    display: none;                  /* Mặc định ẩn */
}

/* Khi rê chuột vào vùng bọc xe đẩy -> Hiện hộp thoại Giỏ hàng */
.header-cart-wrap:hover .header-cart-list {
    display: block;
}

/* Vẽ mũi tên tam giác nhọn chỉ thẳng vào xe đẩy + Cầu nối chống mất Hover */
.header-cart-list::before {
    content: "";
    position: absolute;
    right: 0.6rem;
    top: -2.6rem;
    border-width: 1.6rem 2rem;
    border-style: solid;
    border-color: transparent transparent var(--white-color) transparent;
    cursor: pointer;
}

/* Cầu nối vô hình lấp khe hở 10px */
.header-cart-list::after {
    content: "";
    position: absolute;
    right: 0;
    top: -1.4rem;
    width: 6rem;
    height: 1.6rem;
}

/* --- XỬ LÝ TRẠNG THÁI 1: GIỎ HÀNG TRỐNG (--no-cart) --- */
.header-cart-no-cart-img,
.header-cart-list-no-cart-msg {
    display: none;                  /* Mặc định ẩn khi giỏ đang có hàng */
    margin-left: auto;
    margin-right: auto;
}

.header-cart-no-cart-img {
    width: 55%;
}

.header-cart-list-no-cart-msg {
    font-size: 1.4rem;
    margin-top: 1.4rem;
    color: var(--text-primary);
    text-align: center;
}

/* KHI GẮN CLASS --no-cart VÀO: Hiện ảnh trống & Ẩn danh sách sản phẩm */
.header-cart-list--no-cart {
    padding: 2.8rem 0;
}

.header-cart-list--no-cart .header-cart-no-cart-img,
.header-cart-list--no-cart .header-cart-list-no-cart-msg {
    display: block;
}

.header-cart-list--no-cart .header-cart-heading,
.header-cart-list--no-cart .header-cart-list-item,
.header-cart-list--no-cart .header-cart-footer {
    display: none;
}

/* --- XỬ LÝ TRẠNG THÁI 2: GIỎ HÀNG CÓ SẢN PHẨM --- */
.header-cart-heading {
    text-align: left;
    margin: 1.2rem 0 0.8rem 1.2rem;
    font-size: 1.4rem;
    color: rgba(0, 0, 0, 0.26);
    font-weight: 400;
}

.header-cart-list-item {
    padding-left: 0;
    list-style: none;
    max-height: 45vh;               /* Giới hạn chiều cao tối đa 45% màn hình, nhiều hơn sẽ tự có thanh cuộn! */
    overflow-y: auto;
}

.header-cart-item {
    display: flex;
    align-items: center;
    padding: 1rem 1.2rem;
    transition: background-color 0.1s ease;
}

.header-cart-item:hover {
    background-color: #f8f8f8;
}

.header-cart-img {
    width: 4.2rem;
    height: 4.2rem;
    object-fit: cover;
    border: 1px solid var(--border-color);
    flex-shrink: 0;
}

.header-cart-item-info {
    flex: 1;
    margin-left: 1.2rem;
    min-width: 0;                   /* BẮT BUỘC để text-overflow: ellipsis bên trong hoạt động! */
}

/* Dòng 1: Tên sản phẩm (Trái) + Giá tiền x Số lượng (Phải) */
.header-cart-item-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 0.6rem;
}

.header-cart-item-name {
    font-size: 1.4rem;
    font-weight: 400;
    color: var(--text-primary);
    margin: 0;
    flex: 1;
    padding-right: 1.6rem;
    
    /* Bộ 3 thuộc tính cắt chữ dài thành dấu "..." */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.header-cart-item-price-wrap {
    flex-shrink: 0;                 /* Giữ cụm giá tiền không bao giờ bị ép rớt dòng */
}

.header-cart-item-price {
    font-size: 1.4rem;
    font-weight: 400;
    color: var(--shopee-primary);
}

.header-cart-item-multiply {
    font-size: 0.9rem;
    margin: 0 0.4rem;
    color: var(--text-secondary);
}

.header-cart-item-qnt {
    font-size: 1.2rem;
    color: var(--text-secondary);
}

/* Dòng 2: Phân loại hàng (Trái) + Nút Xóa (Phải) */
.header-cart-item-body {
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.header-cart-item-description {
    color: var(--text-secondary);
    font-size: 1.2rem;
    font-weight: 300;
}

.header-cart-item-remove {
    color: var(--text-primary);
    font-size: 1.3rem;
    font-weight: 400;
    cursor: pointer;
}

.header-cart-item-remove:hover {
    color: var(--shopee-primary);
}

/* Chân hộp giỏ hàng */
.header-cart-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1.2rem;
    background-color: #fdfdfd;
}

.header-cart-footer-msg {
    font-size: 1.2rem;
    color: var(--text-secondary);
}

image.png

4. Giải mã dòng code: 3 Kinh nghiệm xương máu từ dự án thực tế

  1. "Bùa hộ mệnh" min-width: 0 trên Flex Item (.header-cart-item-info):

    • Đây là câu hỏi phỏng vấn Frontend rất hay gặp: "Tại sao tôi đã viết đủ white-space: nowrap, overflow: hidden và text-overflow: ellipsis cho thẻ <h5> mà chữ vẫn tràn ra ngoài làm vỡ cả hộp thoại?"

    • Nguyên nhân là thẻ cha .header-cart-item-info đang mang flex: 1. Theo chuẩn W3C, một Flex Item mặc định có min-width: auto — tức là nó tự giãn to bằng đúng độ dài của dòng chữ không xuống dòng bên trong! Khi thêm min-width: 0 vào .header-cart-item-info, bạn cho phép nó co lại vừa khít với khung 400px của hộp giỏ hàng, kích hoạt dấu ... hoạt động hoàn hảo.

  2. Giới hạn chiều cao cuộn thông minh với max-height: 45vh và overflow-y: auto:

    • Nếu khách hàng thêm 20 món đồ vào giỏ hàng, chuyện gì sẽ xảy ra? Hộp thoại giỏ hàng sẽ dài lê thê vượt quá đáy màn hình máy tính, khiến khách hàng không thể bấm được nút "Xem Giỏ Hàng"!

    • Đơn vị vh (Viewport Height) đại diện cho phần trăm chiều cao màn hình đang hiển thị. Đặt max-height: 45vh giúp danh sách sản phẩm chỉ cao tối đa bằng 45% màn hình máy tính của người dùng; nếu có nhiều sản phẩm hơn, một thanh cuộn dọc (scrollbar) mượt mà sẽ tự động xuất hiện bên trong danh sách!

  3. Tái sử dụng @keyframes headerNotifyGrowth:

    • Nhờ tư duy viết code sạch từ Bài 05, chúng ta không cần viết lại @keyframes mới mà gọi thẳng animation: headerNotifyGrowth ease-in 0.2s; cho giỏ hàng. Mọi popup trên Header đều có chung một ngôn ngữ chuyển động đồng nhất!

5. Bài tập thực hành cuối bài & Cột mốc hoàn thành Chương 1!

Hãy lưu toàn bộ các file lại và rê chuột vào biểu tượng Giỏ hàng trên trình duyệt! Bạn sẽ thấy 3 món đồ công nghệ và thời trang hiện lên cực kỳ sắc nét: tên sản phẩm dài tự động thu gọn thành dấu ..., giá tiền màu cam nổi bật, nút Xóa đổi màu cam khi di chuột vào, và nút Xem Giỏ Hàng chuẩn thiết kế Shopee.

Thử thách kiểm tra dành cho bạn:

  1. Mở file index.html, tìm đến thẻ <div class="header-cart-list"> và sửa thành <div class="header-cart-list header-cart-list--no-cart">. Lưu lại và rê chuột vào giỏ hàng để tận mắt chứng kiến toàn bộ danh sách sản phẩm biến mất, nhường chỗ cho giao diện "Chưa có sản phẩm" chỉ với 1 class duy nhất!

  2. Hãy thử nhân bản (copy-paste) thêm 10 thẻ <li class="header-cart-item"> vào trong danh sách để kiểm nghiệm thanh cuộn dọc tự động (max-height: 45vh) hoạt động mượt mà như thế nào.

Chúc mừng bạn! Kết thúc Bài 07, chúng ta đã hoàn thành một cột mốc cực lớn: Xây dựng thành công gần như toàn bộ vùng Header phức tạp của Shopee! Ở Bài 08, chúng ta sẽ làm cho chiếc Header này trở nên hoàn hảo với kỹ thuật Sticky Header (position: sticky / fixed) — giúp thanh tìm kiếm luôn bám dính trên đỉnh màn hình khi người dùng cuộn chuột lướt xem hàng ngàn sản phẩm bên dưới!


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí