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 dễ tiếp cận và thích ứng với màu sắc bằng phần tử <progress>.

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

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

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

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 về việc hoàn tất. Phản hồi trực quan này rất hữu ích cho các trường hợp như: tiến trình hoàn thành biểu mẫu, hiển thị thông tin tải xuống hoặc tải lên, hoặc thậm chí cho biết rằ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 đã sử dụng phần tử HTML <progress> hiện có để tiết kiệm công sức trong việc hỗ trợ tiếp cận. Màu sắc và bố cục giúp tăng khả năng tuỳ chỉnh cho phần tử tích hợp, giúp hiện đại hoá thành phần và phù hợp hơn với 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ạ trên Firefox, Safari, iOS Safari, Chrome và Android Chrome ở cả 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 một <label> để có thể bỏ qua các thuộc tính mối quan hệ rõ ràng để ưu tiên mối quan hệ ngầm ẩn. Tôi cũng đã gắn nhãn một phần tử mẹ chịu ảnh hưởng của trạng thái tải, nhờ đó, công nghệ trình đọc màn hình có thể chuyển tiếp thông tin đó cho người dùng.

<progress></progress>

Nếu không có value, thì tiến trình của phần tử sẽ không xác định. Thuộc tính max mặc định là 1, vì vậy, tiến trình nằm trong khoảng từ 0 đến 1. Ví dụ: nếu bạn đặt max thành 100, thì phạm vi sẽ là 0-100. Tôi chọn giữ nguyê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 được gói nhãn

Trong mối quan hệ ngầm định, một phần tử tiến trình được gói bằng một nhãn như sau:

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

Trong bản minh hoạ, tôi chọn thêm nhãn chỉ dành cho 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 một <span> và áp dụng một số kiểu cho văn bản đó để văn bản đó không xuất hiện trên màn hình:

<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ủa công cụ cho nhà phát triển cho thấy phần tử chỉ sẵn sàng trên màn hình.

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

Nếu có thị lực tốt, bạn có thể dễ dàng 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, nhưng đối với người dùng khiếm thị, điều này không rõ ràng. 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 sẽ thay đổi khi quá trình tải hoàn tất. Hơn nữa, hãy cho biết 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>

Trong JavaScript, hãy bật/tắt 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 định của phần tử <progress> là progressbar, nhưng tôi đã đặt vai trò rõ ràng cho các trình duyệt thiếu vai trò ngầm định đó. Tôi cũng đã thêm thuộc tính indeterminate để đặt rõ ràng phần tử vào trạng thái không xác định, điều này rõ ràng hơn so với việc quan sát phần tử không có tập hợp value.

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

Sử dụng tabindex="-1" để đặt tiêu điểm cho phần tử tiến trình 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 vào tiến trình khi tiến trình thay đổi sẽ thông báo cho người dùng biết tiến trình cập nhật đã đạt đến mức nào.

Kiểu

Phần tử tiến trình có một chút khó khăn khi tạo 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 nhóm thuộc tính giới hạn để đặt.

Bố cục

Các kiểu bố cục được thiết kế để 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. Một trạng thái hoàn thành đặc biệt được thêm vào có thể là một gợi ý trực quan 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à tăng lên với 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 appearance và border thành none. Điều này được thực hiện để phần tử có thể được chuẩn hoá trên các trình duyệt, vì mỗi trình duyệt 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, vì vậy, border-radius luôn được làm tròn. Cách này tương đương với 1000px. Tôi thích sử dụng giá trị này vì mục tiêu của tôi là sử dụng một giá trị đủ lớn để tôi có thể đặt và không cần quan tâm đến nó nữa (và giá trị này ngắn hơn khi viết so với 1000px). Bạn cũng có thể dễ dàng tăng giá trị này lên nếu cần: chỉ cần thay đổi 3 thành 4, thì 1e4px tương đương với 10000px.

overflow: hidden được sử dụng và là một kiểu gây tranh cãi. Điều này giúp một số việc trở nên dễ dàng, chẳng hạn như không cần truyền giá trị border-radius xuống phần tử rãnh và phần tử lấp đầy rãnh; nhưng điều này cũng có nghĩa là không có phần tử con nào của tiến trình có thể nằm ngoài phần tử này. 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 và 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 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 một gợi ý trực quan bổ sung thú vị cho quá trình 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à có dấu kiểm ở cuối.

Màu

Trình duyệt mang màu sắc riêng cho phần tử tiến trình và thích ứng với chế độ sáng và tối chỉ bằng một thuộc tính CSS. Bạn có thể xây dựng dựa trên một số bộ chọn 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 với chế độ sáng và tối cho trang web, bạn chỉ cần dùng color-scheme.

progress {
  color-scheme: light dark;
}

Màu nền của tiến trình cho một cơ sở lưu trú

Để phủ màu cho 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 bản nhạc 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 hoàn toàn tuỳ chỉnh

Đặ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 dải tiến trình và một thuộc tính cho màu của tiến trình. Bên trong truy vấn đa phương tiện prefers-color-scheme, hãy cung cấp các giá trị màu mới cho tiến trình của bản nhạc và bản nhạc.

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 tiêu điểm

Trước đó, chúng ta đã cung cấp cho phần tử một chỉ mục thẻ âm để có thể lấy tiêu đ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 kiểu vòng tròn tiêu điểm thông minh hơn. Khi đó, thao tác nhấp chuột và lấy tiêu điểm sẽ không hiện vòng tiêu điểm, nhưng thao tác nhấp bằng bàn phím thì có. Video trên YouTube này sẽ đi sâu hơn vào vấn đề này và bạn nên xem qua.

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 tiêu điểm xung quanh. Màu sắc đều khớp.

Kiểu tuỳ 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ị. Việc sử dụng phần tử tiến trình là một thẻ duy nhất, nhưng được tạo từ một vài phần tử con được hiển thị thông qua bộ chọn giả CSS. Chrome DevTools sẽ hiển thị các phần tử này cho bạn nếu bạn bật chế độ cài đặt:

  1. Nhấp chuột phải vào trang của bạn rồi chọn Kiểm tra phần tử để mở Công cụ cho nhà phát triển.
  2. Nhấp vào biểu tượng 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 đề Elements (Phần tử), hãy tìm và bật hộp đánh dấu Show user agent shadow DOM (Hiện DOM bóng của tác nhân người dùng).

Ảnh chụp màn hình cho thấy vị trí trong Công cụ cho nhà phát triển để bật tính năng 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 hiển thị ::-webkit-progress-bar và ::-webkit-progress-value, cho phép sử dụng một nhóm nhỏ CSS. Hiện tại, hãy đặt background-color bằng các thuộc tính tuỳ chỉnh đã tạo trước đó, 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ể trực tiếp phủ màu cho dải.

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

Ảnh chụp màn hình của 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, Safari trên iOS, 
  Firefox, Chrome và Chrome trên Android đều có thanh tải đang hoạt động.

Lưu ý rằng Firefox có một màu dải được đặt từ accent-color trong khi iOS Safari có một dải màu xanh dương nhạt. Ở chế độ tối cũng vậy: Firefox có một dải tối nhưng không phải là màu sắc tuỳ chỉnh mà chúng ta đã đặt, và dải này hoạt động trong các trình duyệt dựa trên Webkit.

Ảnh động

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

Tạo ảnh động cho thanh tiến trình đang lấp đầy

Việc thêm hiệu ứng chuyển đổi vào inline-size của phần tử tiến trình sẽ hoạt động cho Chromium nhưng không hoạt động cho 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 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 được tạo và một dải chuyển màu được áp dụng, được tạo ảnh động qua lại 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 thuộc tính tôi yêu thích nhất là chỉ cần đặt tên cho một giá trị CSS có vẻ kỳ diệu. Sau đây là một linear-gradient khá phức tạp, nhưng có tên gọi hay. Mục đích và trường hợp sử dụng của ứng dụng có thể được hiểu rõ ràng.

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ã luôn DRY vì một lần nữa, chúng ta không thể nhóm các bộ chọn dành riêng cho trình duyệt này lại với nhau.

Khung hình chính

Mục tiêu là tạo một ảnh động vô hạn di chuyển 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, khung hình chính ở giữa tại 50%, để tạo ảnh động lặp đi lặp lại và quay lại vị trí bắt đầu!

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

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

Không phải trình duyệt nào cũng cho phép tạo 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 dải hơn là phần tử giả, vì vậy, tôi nâng cấp từ phần tử giả làm cơ sở và thành thanh ảnh động.

Phần tử giả Chromium

Chromium cho phép phần tử giả: ::after được 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 và ảnh động qua lại 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 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 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 đối với 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 quá trình điều phối giao diện người dùng/trải nghiệm người dùng. Bắt đầu bằng cách tạo một hàm setProgress(). Không cần tham số vì hàm 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 việc tiến trình đã hoàn tất hay chưa, phần tử <main> có liên quan cần được cập nhật thuộc tính aria-busy:

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

Xoá các thuộc tính nếu không biết số tiền nạp

Nếu giá trị không xác định hoặc chưa được đặt, null trong trường hợp này, hãy xoá các thuộc tính value và aria-valuenow. Thao tác này sẽ chuyển <progress> sang trạng thái 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 giữ nguyên giá trị tối đa mặc định của tiến trình là 1, nên các hàm tăng và giảm trong bản minh hoạ sẽ sử dụng phép toán thập phân. JavaScript và các ngôn ngữ khác không phải lúc nào cũng làm tốt việc đó. Dưới đây là hàm roundDecimals() sẽ cắt bớt phần thừa của kết quả toán học:

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

Làm tròn giá trị để có thể trình bày 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ị này được dùng ở 3 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 trình

Khi các giá trị được cập nhật, người dùng có thị lực bình thường sẽ thấy tiến trình thay đổi, nhưng người dùng trình đọc màn hình chưa nhận được thông báo về thay đổi. Tập trung vào phần tử <progress> và trình duyệt sẽ thông báo về 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 trên Mac OS đọc tiến trình của thanh tải cho người dùng.

Kết luận

Giờ thì bạn đã biết cách tôi làm, vậy bạn sẽ làm thế nào‽ 🙂

Chắc chắn là tôi muốn thay đổi một vài điều nếu có cơ hội khác. Tôi nghĩ rằng chúng ta có thể dọn dẹp thành phần hiện tại và thử tạo một thành phần mà không có các giới hạn về kiểu lớp giả của phần tử <progress>. Bạn nên khám phá!

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

Hãy 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 bản minh hoạ đó 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