Xây dựng thành phần thanh tải

Thông tin tổng quan cơ bản về cách tạo thanh tải thích ứng và dễ tiếp cận màu bằng phần tử <progress>.

Trong bài đăng này, tôi muốn chia sẻ cách tạo thanh tải thích ứng và dễ tiếp cận màu bằng phần tử <progress>. Hãy thử bản minh hoạxem nguồn!

Bản minh hoạ sáng và tối, không xác định, tăng dần và hoàn tất trên Chrome.

Nếu bạn thích xem video hơn, sau đây là phiên bản của bài đăng này trên YouTube:

Tổng quan

Phần tử <progress> cung cấp phản hồi bằng hình ảnh và âm thanh cho người dùng khi hoàn tất. Phản hồi trực quan này có giá trị cho các tình huống như: tiến trình thông qua một biểu mẫu, hiển thị thông tin tải xuống hoặc tải lên hay thậm chí cho thấy số lượng tiến trình không xác định nhưng công việc vẫn đang hoạt động.

Thử thách GUI này đã kết hợp với phần tử HTML <progress> hiện có để tiết kiệm công sức hỗ trợ tiếp cận. Màu sắc và bố cục giúp đẩy mạnh các giới hạn tuỳ chỉnh cho phần tử tích hợp, nhằm hiện đại hoá thành phần và làm cho thành phần phù hợp hơn trong hệ thống thiết kế.

Các thẻ sáng và tối trong mỗi trình duyệt cung cấp thông tin tổng quan về biểu tượng thích ứng từ trên xuống dưới: Safari, Firefox, Chrome.
Bản minh hoạ hiển thị trên Firefox, Safari, iOS Safari, Chrome và Android Chrome ở giao diện sáng và tối.

Markup (note: đây là tên ứng dụng)

Tôi đã chọn gói phần tử <progress> trong <label> để có thể bỏ qua các thuộc tính mối quan hệ rõ ràng và thay vào đó là mối quan hệ ngầm. Tôi cũng đã gắn nhãn một phần tử mẹ bị ảnh hưởng bởi trạng thái tải để các công nghệ trình đọc màn hình có thể chuyển tiếp thông tin đó trở lại cho người dùng.

<progress></progress>

Nếu không có value, thì tiến trình của phần tử sẽ là không xác định. Thuộc tính max mặc định là 1, vì vậy tiến trình sẽ nằm trong khoảng từ 0 đến 1. Ví dụ: đặt max thành 100 sẽ đặt phạm vi thành 0-100. Tôi đã chọn ở trong giới hạn 0 và 1, chuyển giá trị tiến trình thành 0,5 hoặc 50%.

Tiến trình gói nhãn

Trong mối quan hệ ngầm ẩn, phần tử tiến trình được bao bọc bởi một nhãn như sau:

<label>Loading progress<progress></progress></label>

Trong bản minh hoạ của mình, tôi đã chọn đưa nhãn vào chỉ trình đọc màn hình. Bạn có thể thực hiện việc này bằng cách gói văn bản nhãn trong <span> và áp dụng một số kiểu cho văn bản đó để nó nằm ngoài màn hình một cách hiệu quả:

<label>
  <span class="sr-only">Loading progress</span>
  <progress></progress>
</label>

Với CSS đi kèm sau đây từ WebAIM:

.sr-only {
  clip: rect(1px, 1px, 1px, 1px);
  clip-path: inset(50%);
  height: 1px;
  width: 1px;
  margin: -1px;
  overflow: hidden;
  padding: 0;
  position: absolute;
}

Ảnh chụp màn hình công cụ cho nhà phát triển cho thấy phần tử Chỉ màn hình sẵn sàng.

Khu vực bị ảnh hưởng bởi tiến trình tải

Nếu bạn có thị lực khoẻ mạnh, việc liên kết chỉ báo tiến trình với các phần tử và khu vực trang có liên quan có thể dễ dàng. Tuy nhiên, đối với người dùng khiếm thị, chỉ báo này không rõ ràng. Hãy cải thiện điều này bằng cách chỉ định thuộc tính aria-busy cho phần tử trên cùng. Phần tử này sẽ thay đổi khi quá trình tải hoàn tất. Hơn nữa, hãy chỉ ra mối quan hệ giữa tiến trình và vùng tải bằng aria-describedby.

<main id="loading-zone" aria-busy="true">
  …
  <progress aria-describedby="loading-zone"></progress>
</main>

Từ JavaScript, hãy chuyển đổi aria-busy thành true khi bắt đầu tác vụ và thành false sau khi hoàn tất.

Bổ sung thuộc tính Aria

Mặc dù vai trò ngầm ẩn của phần tử <progress>progressbar, nhưng tôi đã chỉ rõ đối với các trình duyệt thiếu vai trò ngầm ẩn đó. Tôi cũng đã thêm thuộc tính indeterminate để đặt phần tử ở trạng thái không xác định một cách rõ ràng, rõ ràng hơn so với việc quan sát phần tử không có value nào được đặt.

<label>
  Loading 
  <progress 
    indeterminate 
    role="progressbar" 
    aria-describedby="loading-zone"
    tabindex="-1"
  >unknown</progress>
</label>

Sử dụng tabindex="-1" để đặt phần tử tiến trình có thể làm tâm điểm từ JavaScript. Điều này rất quan trọng đối với công nghệ trình đọc màn hình, vì việc đặt tiêu điểm tiến trình khi tiến trình thay đổi sẽ thông báo cho người dùng về tiến trình đã cập nhật.

Kiểu

Phần tử tiến trình hơi phức tạp khi định kiểu. Các phần tử HTML tích hợp sẵn có các phần ẩn đặc biệt có thể khó chọn và thường chỉ cung cấp một tập hợp thuộc tính giới hạn để đặt.

Bố cục

Kiểu bố cục nhằm cho phép một số tính linh hoạt về kích thước và vị trí nhãn của phần tử tiến trình. Trạng thái hoàn thành đặc biệt được thêm vào có thể là tín hiệu hình ảnh bổ sung hữu ích nhưng không bắt buộc.

Bố cục <progress>

Chiều rộng của phần tử tiến trình được giữ nguyên để có thể thu nhỏ và mở rộng theo không gian cần thiết trong thiết kế. Các kiểu tích hợp sẵn sẽ bị loại bỏ bằng cách đặt appearanceborder thành none. Việc này được thực hiện để có thể chuẩn hoá phần tử này trên nhiều trình duyệt, vì mỗi trình duyệt có các kiểu riêng cho phần tử của chúng.

progress {
  --_track-size: min(10px, 1ex);
  --_radius: 1e3px;

  /*  reset  */
  appearance: none;
  border: none;

  position: relative;
  height: var(--_track-size);
  border-radius: var(--_radius);
  overflow: hidden;
}

Giá trị của 1e3px cho _radius sử dụng ký hiệu số khoa học để biểu thị một số lớn sao cho border-radius luôn được làm tròn. Tương đương với 1000px. Tôi thích sử dụng thuộc tính này vì mục đích của tôi là sử dụng một giá trị đủ lớn để tôi có thể thiết lập và quên giá trị đó (và ngắn hơn để viết so với 1000px). Ngoài ra, bạn cũng có thể dễ dàng làm cho giá trị này lớn hơn nữa nếu cần: chỉ cần thay đổi giá trị 3 thành 4, sau đó 1e4px tương đương với 10000px.

overflow: hidden được sử dụng và là một kiểu gây tranh cãi. Cách này giúp bạn dễ dàng thực hiện một số thao tác, chẳng hạn như không cần truyền các giá trị border-radius xuống kênh và theo dõi các phần tử lấp đầy; nhưng điều đó cũng có nghĩa là không phần tử con của tiến trình nào có thể hoạt động bên ngoài phần tử đó. Bạn có thể thực hiện một lần lặp lại khác trên phần tử tiến trình tuỳ chỉnh này mà không cần overflow: hidden. Điều này có thể mở ra một số cơ hội cho ảnh động hoặc trạng thái hoàn thành tốt hơn.

Tiến trình đã hoàn tất

Bộ chọn CSS thực hiện công việc khó khăn ở đây bằng cách so sánh giá trị tối đa với giá trị và nếu chúng khớp với nhau thì tiến trình sẽ hoàn tất. Khi hoàn tất, một phần tử giả sẽ được tạo và thêm vào cuối phần tử tiến trình, cung cấp thêm tín hiệu hình ảnh đẹp mắt cho phần hoàn tất.

progress:not([max])[value="1"]::before,
progress[max="100"][value="100"]::before {
  content: "✓";
  
  position: absolute;
  inset-block: 0;
  inset-inline: auto 0;
  display: flex;
  align-items: center;
  padding-inline-end: max(calc(var(--_track-size) / 4), 3px);

  color: white;
  font-size: calc(var(--_track-size) / 1.25);
}

Ảnh chụp màn hình thanh tải ở mức 100% và hiển thị dấu kiểm ở cuối.

Màu

Trình duyệt mang đến màu riêng cho phần tử tiến trình cũng như thích ứng với sáng và tối chỉ với một thuộc tính CSS. Bạn có thể xây dựng tính năng này bằng một số bộ chọn đặc biệt dành riêng cho trình duyệt.

Kiểu trình duyệt sáng và tối

Để chọn sử dụng phần tử <progress> thích ứng tối và sáng cho trang web của bạn, bạn chỉ cần có color-scheme.

progress {
  color-scheme: light dark;
}

Màu tô tiến trình thuộc tính duy nhất

Để phủ màu một phần tử <progress>, hãy sử dụng accent-color.

progress {
  accent-color: rebeccapurple;
}

Lưu ý rằng màu nền của kênh sẽ thay đổi từ sáng sang tối tuỳ thuộc vào accent-color. Trình duyệt đang đảm bảo độ tương phản phù hợp: khá gọn gàng.

Màu sáng và tối tùy chỉnh hoàn toàn

Đặt hai thuộc tính tuỳ chỉnh trên phần tử <progress>, một thuộc tính cho màu của kênh và một thuộc tính cho màu tiến trình của kênh. Bên trong truy vấn nội dung nghe nhìn prefers-color-scheme, hãy cung cấp các giá trị màu mới cho kênh và theo dõi tiến trình.

progress {
  --_track: hsl(228 100% 90%);
  --_progress: hsl(228 100% 50%);
}

@media (prefers-color-scheme: dark) {
  progress {
    --_track: hsl(228 20% 30%);
    --_progress: hsl(228 100% 75%);
  }
}

Kiểu lấy tiêu điểm

Trước đó, chúng ta đã cung cấp cho phần tử một chỉ mục thẻ âm để phần tử này có thể được lấy làm tâm điểm theo phương thức lập trình. Sử dụng :focus-visible để tuỳ chỉnh tiêu điểm nhằm chọn sử dụng kiểu vòng lấy nét thông minh hơn. Với cách này, thao tác nhấp chuột và lấy tiêu điểm sẽ không hiển thị vòng lấy nét nhưng thao tác nhấp bằng bàn phím sẽ hiển thị. Video trên YouTube sẽ đi sâu hơn vào vấn đề này và đáng xem xét.

progress:focus-visible {
  outline-color: var(--_progress);
  outline-offset: 5px;
}

Ảnh chụp màn hình thanh tải có vòng lấy nét xung quanh. Tất cả màu đều khớp.

Kiểu tùy chỉnh trên các trình duyệt

Tuỳ chỉnh kiểu bằng cách chọn các phần của phần tử <progress> mà mỗi trình duyệt hiển thị. Phần tử tiến trình là một thẻ duy nhất nhưng thẻ này được tạo thành từ một vài phần tử con được hiển thị thông qua bộ chọn giả CSS. Công cụ của Chrome cho nhà phát triển sẽ hiển thị các phần tử này cho bạn nếu bạn bật chế độ cài đặt này:

  1. Nhấp chuột phải vào trang của bạn rồi chọn Kiểm tra phần tử để hiển thị Công cụ cho nhà phát triển.
  2. Nhấp vào bánh răng Cài đặt ở góc trên cùng bên phải của cửa sổ Công cụ cho nhà phát triển.
  3. Trong tiêu đề Phần tử, hãy tìm và bật hộp đánh dấu Hiển thị lớp bóng của tác nhân người dùng.

Ảnh chụp màn hình về vị trí trong Công cụ cho nhà phát triển cho phép hiển thị DOM bóng của tác nhân người dùng.

Kiểu Safari và Chromium

Các trình duyệt dựa trên WebKit như Safari và Chromium sẽ hiển thị ::-webkit-progress-bar::-webkit-progress-value, cho phép sử dụng một tập hợp con CSS. Bây giờ, hãy đặt background-color bằng các thuộc tính tuỳ chỉnh đã tạo trước đó. Các thuộc tính này thích ứng với chế độ sáng và tối.

/*  Safari/Chromium  */
progress[value]::-webkit-progress-bar {
  background-color: var(--_track);
}

progress[value]::-webkit-progress-value {
  background-color: var(--_progress);
}

Ảnh chụp màn hình cho thấy các phần tử bên trong của phần tử tiến trình.

Kiểu Firefox

Firefox chỉ hiển thị bộ chọn giả ::-moz-progress-bar trên phần tử <progress>. Điều này cũng có nghĩa là chúng ta không thể phủ màu trực tiếp cho bản nhạc.

/*  Firefox  */
progress[value]::-moz-progress-bar {
  background-color: var(--_progress);
}

Ảnh chụp màn hình Firefox và nơi tìm các phần của phần tử tiến trình.

Ảnh chụp màn hình của Góc gỡ lỗi, trong đó Safari, iOS Safari, Firefox, Chrome và Chrome trên Android đều có thanh tải đang hoạt động.

Hãy lưu ý rằng Firefox có tập hợp màu của kênh là accent-color trong khi iOS Safari có một vệt màu xanh dương nhạt. Điều này cũng tương tự ở chế độ tối: Firefox có vệt tối nhưng không phải màu tuỳ chỉnh chúng ta đã thiết lập và hoạt động trong các trình duyệt dựa trên Webkit.

Hoạt ảnh

Khi làm việc với bộ chọn giả được tích hợp sẵn trong trình duyệt, bạn thường phải làm việc với một tập hợp hạn chế các thuộc tính CSS được phép.

Tạo ảnh động cho quá trình lấp đầy bản nhạc

Việc thêm hiệu ứng chuyển đổi vào inline-size của phần tử tiến trình hoạt động trên Chromium nhưng không hoạt động trên Safari. Firefox cũng không sử dụng thuộc tính chuyển đổi trên ::-moz-progress-bar.

/*  Chromium Only 😢  */
progress[value]::-webkit-progress-value {
  background-color: var(--_progress);
  transition: inline-size .25s ease-out;
}

Tạo ảnh động cho trạng thái :indeterminate

Ở đây, tôi có thể sáng tạo hơn một chút để có thể cung cấp ảnh động. Một phần tử giả cho Chromium sẽ được tạo và một hiệu ứng chuyển màu (gradient) được áp dụng cho cả ba trình duyệt.

Thuộc tính tuỳ chỉnh

Thuộc tính tuỳ chỉnh rất phù hợp với nhiều thứ, nhưng một trong những mục yêu thích của tôi chỉ đơn giản là đặt tên cho một giá trị CSS trông kỳ diệu khác. Sau đây là một linear-gradient khá phức tạp, nhưng có một cái tên rất hay. Mục đích và trường hợp sử dụng của trình cung cấp này có thể được hiểu rõ.

progress {
  --_indeterminate-track: linear-gradient(to right,
    var(--_track) 45%,
    var(--_progress) 0%,
    var(--_progress) 55%,
    var(--_track) 0%
  );
  --_indeterminate-track-size: 225% 100%;
  --_indeterminate-track-animation: progress-loading 2s infinite ease;
}

Các thuộc tính tuỳ chỉnh cũng sẽ giúp mã ở chế độ DRY vì một lần nữa, chúng tôi không thể nhóm các bộ chọn dành riêng cho trình duyệt này với nhau.

Khung hình chính

Mục tiêu là một ảnh động vô hạn chạy qua lại. Khung hình chính bắt đầu và kết thúc sẽ được đặt trong CSS. Bạn chỉ cần một khung hình chính ở giữa khung hình chính tại 50% để tạo một ảnh động quay lại vị trí bắt đầu của nội dung đó nhiều lần!

@keyframes progress-loading {
  50% {
    background-position: left; 
  }
}

Nhắm mục tiêu theo từng trình duyệt

Không phải trình duyệt nào cũng cho phép tạo các phần tử giả trên chính phần tử <progress> hoặc cho phép tạo ảnh động cho thanh tiến trình. Nhiều trình duyệt hỗ trợ tạo ảnh động cho kênh hơn so với phần tử giả, vì vậy tôi nâng cấp từ các phần tử giả làm cơ sở và thành các thanh tạo ảnh động.

Phần tử giả Chromium

Chromium cho phép phần tử giả: ::after được sử dụng với một vị trí để che phần tử. Các thuộc tính tuỳ chỉnh không xác định được sử dụng, đồng thời ảnh động phía sau và phía sau hoạt động rất tốt.

progress:indeterminate::after {
  content: "";
  inset: 0;
  position: absolute;
  background: var(--_indeterminate-track);
  background-size: var(--_indeterminate-track-size);
  background-position: right; 
  animation: var(--_indeterminate-track-animation);
}
Thanh tiến trình Safari

Đối với Safari, các thuộc tính tuỳ chỉnh và ảnh động được áp dụng cho thanh tiến trình của phần tử giả:

progress:indeterminate::-webkit-progress-bar {
  background: var(--_indeterminate-track);
  background-size: var(--_indeterminate-track-size);
  background-position: right; 
  animation: var(--_indeterminate-track-animation);
}
Thanh tiến trình của Firefox

Đối với Firefox, các thuộc tính tuỳ chỉnh và ảnh động cũng được áp dụng cho thanh tiến trình của phần tử giả:

progress:indeterminate::-moz-progress-bar {
  background: var(--_indeterminate-track);
  background-size: var(--_indeterminate-track-size);
  background-position: right; 
  animation: var(--_indeterminate-track-animation);
}

JavaScript

JavaScript đóng vai trò quan trọng trong phần tử <progress>. Thuộc tính này kiểm soát giá trị được gửi đến phần tử và đảm bảo có đủ thông tin trong tài liệu cho trình đọc màn hình.

const state = {
  val: null
}

Bản minh hoạ cung cấp các nút để kiểm soát tiến trình; các nút này cập nhật state.val rồi gọi một hàm để cập nhật DOM.

document.querySelector('#complete').addEventListener('click', e => {
  state.val = 1
  setProgress()
})

setProgress()

Hàm này là nơi diễn ra hoạt động điều phối giao diện/trải nghiệm người dùng. Hãy bắt đầu bằng cách tạo hàm setProgress(). Không cần tham số vì thành phần này có quyền truy cập vào đối tượng state, phần tử tiến trình và vùng <main>.

const setProgress = () => {
  
}

Đặt trạng thái tải trên vùng <main>

Tuỳ thuộc vào tiến trình đã hoàn tất hay chưa, bạn cần cập nhật thuộc tính aria-busy cho phần tử <main> liên quan:

const setProgress = () => {
  zone.setAttribute('aria-busy', state.val < 1)
}

Xoá thuộc tính nếu không xác định được thời lượng tải

Nếu giá trị không xác định hoặc chưa được đặt, null trong cách sử dụng này, hãy xoá các thuộc tính valuearia-valuenow. Thao tác này sẽ biến <progress> thành không xác định.

const setProgress = () => {
  zone.setAttribute('aria-busy', state.val < 1)

  if (state.val === null) {
    progress.removeAttribute('aria-valuenow')
    progress.removeAttribute('value')
    progress.focus()
    return
  }
}

Khắc phục vấn đề về toán học thập phân trong JavaScript

Vì tôi chọn sử dụng giá trị mặc định tối đa là 1 cho tiến trình, nên các hàm tăng và giảm minh hoạ sử dụng toán học thập phân. JavaScript và các ngôn ngữ khác không phải lúc nào cũng hiệu quả. Dưới đây là hàm roundDecimals() sẽ loại bỏ phần thừa khỏi kết quả toán học:

const roundDecimals = (val, places) =>
  +(Math.round(val + "e+" + places)  + "e-" + places)

Làm tròn giá trị để giá trị đó có thể hiển thị và dễ đọc:

const setProgress = () => {
  zone.setAttribute('aria-busy', state.val < 1)

  if (state.val === null) {
    progress.removeAttribute('aria-valuenow')
    progress.removeAttribute('value')
    progress.focus()
    return
  }

  const val = roundDecimals(state.val, 2)
  const valPercent = val * 100 + "%"
}

Đặt giá trị cho trình đọc màn hình và trạng thái trình duyệt

Giá trị được sử dụng tại ba vị trí trong DOM:

  1. Thuộc tính value của phần tử <progress>.
  2. Thuộc tính aria-valuenow.
  3. Nội dung văn bản bên trong <progress>.
const setProgress = () => {
  zone.setAttribute('aria-busy', state.val < 1)

  if (state.val === null) {
    progress.removeAttribute('aria-valuenow')
    progress.removeAttribute('value')
    progress.focus()
    return
  }

  const val = roundDecimals(state.val, 2)
  const valPercent = val * 100 + "%"

  progress.value = val
  progress.setAttribute('aria-valuenow', valPercent)
  progress.innerText = valPercent
}

Tập trung vào tiến độ

Sau khi các giá trị được cập nhật, người dùng nhìn thấy sẽ thấy tiến trình thay đổi, nhưng người dùng trình đọc màn hình chưa được thông báo thay đổi. Hãy tập trung vào phần tử <progress> và trình duyệt sẽ thông báo nội dung cập nhật!

const setProgress = () => {
  zone.setAttribute('aria-busy', state.val < 1)

  if (state.val === null) {
    progress.removeAttribute('aria-valuenow')
    progress.removeAttribute('value')
    progress.focus()
    return
  }

  const val = roundDecimals(state.val, 2)
  const valPercent = val * 100 + "%"

  progress.value = val
  progress.setAttribute('aria-valuenow', valPercent)
  progress.innerText = valPercent

  progress.focus()
}

Ảnh chụp màn hình ứng dụng Voice Over của Mac OS cho người dùng đọc tiến trình trên thanh tải.

Kết luận

Giờ bạn đã biết cách tôi thực hiện điều đó, bạn sẽ làm cách nào‽ 🙂

Chắc chắn sẽ có một vài thay đổi tôi muốn thực hiện nếu có cơ hội khác. Tôi nghĩ vẫn còn chỗ để dọn dẹp thành phần hiện tại, cũng như có thể thử tạo một thành phần mà không bị hạn chế về kiểu giả lớp của phần tử <progress>. Rất đáng để khám phá!

Hãy đa dạng hoá phương pháp tiếp cận và tìm hiểu tất cả các cách xây dựng ứng dụng trên web.

Tạo một bản minh hoạ, tweet cho tôi các đường liên kết và tôi sẽ thêm vào phần bản phối lại của cộng đồng ở bên dưới!

Bản phối lại của cộng đồng