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 màu và dễ tiếp cận 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 thanh tải thích ứng màu sắc và có thể truy cập được bằng phần tử <progress>. Hãy xem thử bản minh hoạxem nguồn!

Sáng và tối, không xác định, tăng dần và hoàn thành đã được minh hoạ trên Chrome.

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

Tổng quan

Phần tử <progress> cung cấp ý kiến phản hồi bằng hình ảnh và âm thanh cho người dùng về mức độ hoàn thành. Ý kiến phản hồi trực quan này rất có giá trị trong các trường hợp 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 tiến trình là chưa xác định nhưng công việc vẫn đang hoạt động.

Thử thách GUI này đã hoạt động với phần tử HTML <progress> hiện có để tiết kiệm công sức về khả năng tiếp cận. Màu sắc và bố cục vượt qua các giới hạn tuỳ chỉnh cho phần tử tích hợp sẵn, để hiện đại hoá thành phần và làm cho thành phần phù hợp hơn trong các 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 trong 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 ẩ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, vì vậy, 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ử là không xác định. Thuộc tính max mặc định là 1, do đó tiến trình sẽ nằm trong khoảng từ 0 đến 1. Ví dụ: việc đặt max thành 100 sẽ thiết lập phạm vi thành 0-100. Tôi đã chọn nằm trong giới hạn 0 và 1, dịch các 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 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 gắn 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 <span> và áp dụng một số kiểu cho văn bả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ỉ sẵn sàng cho màn hình.

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, 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à vùng trang có liên quan, nhưng đối với người dùng khiếm thị, thông tin này không được rõ ràng lắm. 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 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 aria-busy sang true khi bắt đầu tác vụ và thành false khi hoàn tất.

Các bổ sung thuộc tính Aria

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

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

Sử dụng tabindex="-1" để lấy phần tử tiến trình 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 lấy tâm đ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 mới được cập nhật.

Kiểu

Phần tử tiến trình có một chút khó khăn khi nói về việc đị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 để thiết lập.

Bố cục

Kiểu bố cục nhằm cho phép một số sự 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 sẽ được thêm vào. Trạng thái này có thể là một chỉ dẫn hình ảnh 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 không bị ảnh hưởng để có thể thu nhỏ và tăng kích thước 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ử trên các trình duyệt, vì mỗi trình duyệt có các kiểu riêng cho phần tử đó.

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ị số lớn, nhờ đó border-radius luôn được làm tròn. Giá trị này tương đương với 1000px. Tôi thích 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 để có thể thiết lập rồi quên đi (và viết ngắn hơn 1000px). Nếu cần, bạn cũng có thể dễ dàng tăng giá trị lớn hơn nữa nếu cần: chỉ cần thay đổi 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. Thao tác này giúp đơn giản hoá một vài việc, chẳng hạn như không cần truyền giá trị border-radius xuống kênh và theo dõi các phần tử tô màu nền; nhưng cũng có nghĩa là không có phần tử con nào của tiến trình có thể tồn tại bên ngoài phần tử đó. Một số vòng lặp khác trên phần tử tiến trình tuỳ chỉnh này có thể được thực hiện mà không cần overflow: hidden. Việc này có thể mở ra một số cơ hội để sử dụng ả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 trong trường hợp 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, thì tiến trình đã hoàn tất. Khi hoàn tất, một phần tử giả sẽ được tạo và nối vào cuối phần tử tiến trình, cung cấp thêm một chỉ dẫn hình ảnh hấp dẫ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 sử dụng màu riêng cho phần tử tiến trình, đồng thời 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 trình duyệt 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

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

progress {
  color-scheme: light dark;
}

Màu nền 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 ý màu nền của kênh 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 2 thuộc tính tuỳ chỉnh trên phần tử <progress>, một cho màu tuyến đường và thuộc tính còn lại cho màu tiến trình kênh. Bên trong truy vấn nội dung đa phương tiện prefers-color-scheme, hãy cung cấp các giá trị màu mới cho theo dõi và tiến trình theo dõi.

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 tôi đã cung cấp cho phần tử một chỉ mục thẻ âm để có thể tập trung vào 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 thao tác này, thao tác nhấp chuột và lấy nét sẽ không hiển thị vòng lấy nét, nhưng các thao tác nhấp bằng bàn phím sẽ xuất hiện. Video trên YouTube sẽ phân tích sâu hơn và đáng để xem xét.

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

Ảnh chụp màn hình thanh đang tải có vòng tròn lấy nét xung quanh. Tất cả màu đề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ẻ đơn lẻ, nhưng được tạo thành từ một số phần tử con 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ử để 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 đề Phần tử, hãy tìm và bật hộp đánh dấu Hiển thị DOM bóng 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 tối 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::-webkit-progress-value, cho phép sử dụng một tập hợp con CSS. Hiện tại, hãy đặt background-color bằng cách sử dụng các thuộc tính tuỳ chỉnh đã tạo trước đó để thích ứng với giao diện 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 tôi không thể phủ màu trực tiếp cho kênh.

/*  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 tiến trình.

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

Lưu ý Firefox có một đường màu được đặt từ accent-color trong khi iOS Safari có một track màu xanh dương nhạt. Việc này tương tự như khi ở chế độ tối: Firefox có bản phát hành tối nhưng không phải màu tuỳ chỉnh mà chúng ta đã đặt, đồng thời hoạt động trong các trình duyệt dựa trên Webkit.

Hoạt ảnh

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

Tạo ảnh động cho bản nhạc đầ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 với Chromium nhưng không hoạt động với 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ẽ 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à áp dụng một hiệu ứng chuyển màu (gradient) được tạo ảnh động quay lại và chuyển tiếp cho cả 3 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 mục đích, 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. Sau đây là một linear-gradient khá phức tạp, nhưng có một cái tên hay. Mục đích và trường hợp sử dụng của tính năng này phải đượ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ã luôn ở trạng thái 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 lại với nhau.

Khung hình chính

Mục tiêu là 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, đó là khung hình chính ở giữa tại 50%, để tạo một ảnh động quay lại điểm bắt đầu của khung hình đó nhiều lần!

@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 kênh hơn so với 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ả của Chromium

Chromium cho phép phần tử giả: ::after dùng với một vị trí để che phủ phần tử. Các thuộc tính tuỳ chỉnh không xác định được sử dụng và ảnh động quay lại 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 trong 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 giả phần tử:

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 cho 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ạ có các nút để kiểm soát tiến trình; chúng cập nhật state.val và sau đó gọi một hàm để cập nhật DOM.

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

setProgress()

Đây là nơi diễn ra việc đ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 hàm setProgress(). Không cần tham số vì 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 = () => {
  
}

Đang đặt trạng thái đang tải cho 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> liên quan cần được cập nhật cho 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 xác định được số lượng tải

Nếu giá trị không xác định hoặc không được đặt, null trong cách sử dụng này, hãy xoá thuộc tính valuearia-valuenow. Thao tác này sẽ chuyể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 số thập phân trong JavaScript

Vì tôi đã chọn sử dụng giá trị tối đa theo mặc định của tiến trình là 1, nên các hàm tăng và giảm minh hoạ sẽ 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à một hàm roundDecimals() sẽ cắt bớt 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ể xuất hiện 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 sử dụng tại 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 độ

Sau khi các giá trị được cập nhật, người dùng sáng mắt sẽ thấy sự thay đổi về tiến trình, nhưng người dùng trình đọc màn hình vẫn chưa đượ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 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 Giọng lồng tiếng trên Mac OS đang đọc tiến trình của thanh tải cho người dùng.

Kết luận

Giờ bạn đã biết tôi làm được như thế nào, bạn sẽ làm thế nào 🙂

Chắc chắn là tôi sẽ thực hiện một vài thay đổi nếu có cơ hội. Tôi cho rằng 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 có giới hạn 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 của chúng ta và tìm hiểu tất cả các cách xây dựng trên web.

Hãy tạo một bản minh hoạ, đường liên kết tweet me và tôi sẽ thêm bản phối lại đó 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