Xây dựng thành phần hộp thoại

Thông tin tổng quan cơ bản về cách tạo các phương thức nhỏ và siêu đại diện, thích ứng màu sắc, phản hồi và dễ tiếp cận bằng phần tử <dialog>.

Trong bài đăng này, tôi muốn chia sẻ suy nghĩ của mình về cách tạo các phương thức nhỏ và siêu đại diện thích ứng màu sắc, thích ứng và dễ truy cập thông qua phần tử <dialog>. Hãy dùng thử bản minh hoạxem nguồn!

Hình minh hoạ các hộp thoại lớn và nhỏ trong giao diện sáng và tối.

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ử <dialog> phù hợp với hành động hoặc thông tin theo ngữ cảnh trong trang. Cân nhắc thời điểm trải nghiệm người dùng có thể hưởng lợi từ cùng một hành động trên trang thay vì hành động trên nhiều trang: có thể là vì biểu mẫu nhỏ hoặc hành động duy nhất mà người dùng yêu cầu là xác nhận hoặc huỷ.

Phần tử <dialog> gần đây đã trở nên ổn định trên các trình duyệt:

Hỗ trợ trình duyệt

  • 37
  • 79
  • 98
  • 15,4

Nguồn

Tôi nhận thấy phần tử này thiếu một vài thứ, vì vậy trong Thử thách GUI này, tôi thêm các mục trải nghiệm dành cho nhà phát triển mà tôi mong muốn: sự kiện bổ sung, loại bỏ nhẹ, ảnh động tuỳ chỉnh, loại cực nhỏ và loại cực đại.

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

Các yếu tố thiết yếu của phần tử <dialog> rất khiêm tốn. Phần tử này sẽ tự động bị ẩn và có các kiểu được tích hợp sẵn để phủ nội dung của bạn.

<dialog>
  …
</dialog>

Chúng tôi có thể cải thiện đường cơ sở này.

Thông thường, một phần tử hộp thoại có chung nhiều nội dung với cửa sổ phụ và thường thì các tên có thể thay thế cho nhau. Ở đây, tôi có quyền sử dụng phần tử hộp thoại cho cả hộp thoại bật lên nhỏ (mini) cũng như hộp thoại toàn trang (mega). Tôi đặt tên cho chúng là siêu lớn và cỡ nhỏ, trong đó cả hai hộp thoại có đôi chút được điều chỉnh cho phù hợp với các trường hợp sử dụng khác nhau. Tôi thêm thuộc tính modal-mode để cho phép bạn chỉ định loại:

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

Ảnh chụp màn hình cả hộp thoại thu nhỏ và lớn ở cả giao diện sáng lẫn tối.

Không phải lúc nào cũng được, nhưng thường thì các phần tử hộp thoại sẽ được dùng để thu thập một số thông tin về hoạt động tương tác. Biểu mẫu bên trong các thành phần hộp thoại được thiết kế để kết hợp với nhau. Bạn nên có một thành phần biểu mẫu bao bọc nội dung hộp thoại để JavaScript có thể truy cập vào dữ liệu mà người dùng đã nhập. Ngoài ra, các nút bên trong biểu mẫu sử dụng method="dialog" có thể đóng hộp thoại mà không cần JavaScript và truyền dữ liệu.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

Hộp thoại Mega

Một hộp thoại lớn có 3 thành phần bên trong biểu mẫu: <header>, <article><footer>. Các đối tượng này đóng vai trò là vùng chứa ngữ nghĩa cũng như mục tiêu kiểu cho phần trình bày hộp thoại. Tiêu đề đặt tiêu đề cho phương thức và cung cấp nút đóng. Bài viết này dành cho mục đích nhập biểu mẫu và thông tin. Phần chân trang chứa một <menu> của các nút hành động.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

Nút trình đơn đầu tiên có autofocus và một trình xử lý sự kiện cùng dòng onclick. Thuộc tính autofocus sẽ nhận được tiêu điểm khi hộp thoại mở ra và tôi thấy phương pháp hay nhất là đặt thuộc tính này vào nút huỷ chứ không phải nút xác nhận. Điều này đảm bảo việc xác nhận là có chủ ý và không do nhầm lẫn.

Hộp thoại nhỏ

Hộp thoại nhỏ này rất giống với hộp thoại lớn, chỉ thiếu phần tử <header>. Điều này cho phép mô-đun nhỏ hơn và cùng dòng hơn.

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

Phần tử hộp thoại cung cấp nền tảng vững chắc cho phần tử khung nhìn đầy đủ có thể thu thập dữ liệu và tương tác của người dùng. Những yếu tố cần thiết này có thể tạo ra một số lượt tương tác rất thú vị và hiệu quả trong trang web hoặc ứng dụng của bạn.

Hỗ trợ tiếp cận

Phần tử hộp thoại có khả năng hỗ trợ tiếp cận tích hợp rất tốt. Thay vì thêm các tính năng này như tôi thường làm, nhiều tính năng đã có sẵn.

Đang khôi phục tiêu điểm

Như chúng ta đã làm trong phần Xây dựng thành phần điều hướng bên, điều quan trọng là việc mở và đóng nội dung nào đó đúng cách sẽ tập trung vào các nút mở và đóng có liên quan. Khi điều hướng bên đó mở ra, tiêu điểm sẽ được đặt vào nút đóng. Khi người dùng nhấn nút đóng, tiêu điểm sẽ được khôi phục về nút đã mở ra.

Đối với phần tử hộp thoại, đây là hành vi mặc định được tích hợp sẵn:

Rất tiếc, nếu bạn muốn tạo ảnh động cho hộp thoại vào và ra, thì chức năng này sẽ bị mất. Trong phần JavaScript, tôi sẽ khôi phục chức năng đó.

Lấy nét bẫy

Phần tử hộp thoại quản lý inert cho bạn trên tài liệu. Trước inert, JavaScript được dùng để theo dõi tiêu điểm rời khỏi một phần tử. Tại thời điểm đó, JavaScript sẽ chặn và đưa phần tử đó trở lại.

Hỗ trợ trình duyệt

  • 102
  • 102
  • 112
  • 15,5

Nguồn

Sau inert, mọi phần của tài liệu đều có thể bị "đóng băng" vì không còn là mục tiêu tiêu điểm hoặc có thể tương tác bằng chuột. Thay vì bị mắc kẹt tiêu điểm, tiêu điểm sẽ được hướng dẫn vào phần tương tác duy nhất của tài liệu.

Mở và tự động lấy nét một phần tử

Theo mặc định, phần tử hộp thoại sẽ gán tiêu điểm cho phần tử có thể làm tâm điểm đầu tiên trong mã đánh dấu hộp thoại. Nếu đây không phải là phần tử tốt nhất để người dùng mặc định, hãy sử dụng thuộc tính autofocus. Như đã mô tả trước đó, tôi thấy phương pháp hay nhất là đặt nút này vào nút huỷ chứ không phải nút xác nhận. Điều này đảm bảo rằng việc xác nhận là có chủ ý và không do nhầm lẫn.

Đóng bằng phím Escape

Điều quan trọng là bạn phải dễ dàng đóng phần tử có thể gây gián đoạn này. Rất may là phần tử hộp thoại sẽ xử lý phím Escape cho bạn, giúp bạn không phải chịu thêm gánh nặng cho việc điều phối.

Kiểu

Có một đường dẫn dễ dàng để tạo kiểu cho phần tử hộp thoại và một đường dẫn cứng. Bạn có thể đạt được đường dẫn dễ dàng bằng cách không thay đổi thuộc tính hiển thị của hộp thoại và làm việc với các giới hạn của hộp thoại. Tôi đi theo đường dẫn cứng để cung cấp ảnh động tuỳ chỉnh cho thao tác mở và đóng hộp thoại, tiếp quản thuộc tính display và nhiều tác vụ khác.

Tạo kiểu bằng đạo cụ mở

Để tăng tốc độ thích ứng màu sắc và tính nhất quán về thiết kế tổng thể, tôi đã sử dụng Open Props trong thư viện biến CSS của mình. Ngoài các biến được cung cấp miễn phí, tôi cũng nhập tệp chuẩn hoá và một số nút, cả hai đều có Open Props dưới dạng dữ liệu nhập không bắt buộc. Các hoạt động nhập này giúp tôi tập trung vào việc tuỳ chỉnh hộp thoại và bản minh hoạ trong khi không cần nhiều kiểu để hỗ trợ và làm cho hộp thoại trông đẹp mắt.

Tạo kiểu cho phần tử <dialog>

Cách sở hữu tài sản quảng cáo hiển thị

Hành vi hiện và ẩn mặc định của một phần tử hộp thoại sẽ chuyển đổi thuộc tính hiển thị từ block sang none. Rất tiếc điều này có nghĩa là bạn không thể tạo ảnh động chỉ trong luồng và sau đó. Tôi muốn tạo ảnh động cho cả trong và ngoài và bước đầu tiên là đặt thuộc tính display của riêng tôi:

dialog {
  display: grid;
}

Bằng cách thay đổi và do đó sở hữu, giá trị thuộc tính hiển thị, như minh hoạ trong đoạn mã CSS ở trên, bạn cần quản lý một số lượng kiểu đáng kể để mang lại trải nghiệm phù hợp cho người dùng. Trước tiên, trạng thái mặc định của hộp thoại là đóng. Bạn có thể biểu thị trạng thái này một cách trực quan và ngăn hộp thoại nhận các lượt tương tác với các kiểu sau:

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

Hộp thoại hiện không hiển thị và không thể tương tác khi không mở. Sau này, tôi sẽ thêm một số JavaScript để quản lý thuộc tính inert trên hộp thoại, đảm bảo rằng người dùng bàn phím và trình đọc màn hình cũng không thể truy cập vào hộp thoại ẩn.

Cung cấp giao diện màu thích ứng cho hộp thoại

Hộp thoại lớn cho thấy giao diện sáng và tối, minh hoạ màu của vùng hiển thị.

Mặc dù color-scheme chọn tài liệu của bạn sử dụng chủ đề màu sắc thích ứng do trình duyệt cung cấp theo các lựa chọn ưu tiên của hệ thống sáng và tối, nhưng tôi vẫn muốn tuỳ chỉnh phần tử hộp thoại nhiều hơn thế. Open Props cung cấp một số màu bề mặt tự động thích ứng với các lựa chọn ưu tiên về ánh sáng và tối của hệ thống, tương tự như cách sử dụng color-scheme. Đây là những công cụ tuyệt vời để tạo các lớp trong thiết kế và tôi thích sử dụng màu sắc để hỗ trợ trực quan cho giao diện của các bề mặt lớp. Màu nền là var(--surface-1); để ở trên cùng của lớp đó, hãy sử dụng var(--surface-2):

dialog {
  …
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

Các màu sắc thích ứng khác sẽ được thêm vào sau này cho các phần tử con, chẳng hạn như tiêu đề và phần chân trang. Tôi xem chúng là thành phần bổ sung cho thành phần hộp thoại, nhưng thực sự quan trọng trong việc tạo thiết kế hộp thoại hấp dẫn và được thiết kế hợp lý.

Định cỡ hộp thoại thích ứng

Hộp thoại mặc định uỷ quyền kích thước cho nội dung. Nhìn chung, điều này là tuyệt vời. Mục tiêu của tôi ở đây là ràng buộc max-inline-size ở kích thước có thể đọc được (--size-content-3 = 60ch) hoặc 90% chiều rộng khung nhìn. Điều này đảm bảo hộp thoại sẽ không bị tràn trên thiết bị di động và không rộng đến mức khó đọc trên màn hình máy tính. Sau đó, tôi thêm max-block-size để hộp thoại không vượt quá chiều cao của trang. Điều này cũng có nghĩa là chúng ta cần chỉ định vị trí của khu vực có thể cuộn của hộp thoại, trong trường hợp đó là một phần tử hộp thoại cao.

dialog {
  …
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

Lưu ý cách tôi có max-block-size hai lần? Phương thức đầu tiên sử dụng 80vh, một đơn vị khung nhìn thực tế. Điều tôi thực sự muốn là giữ hộp thoại trong luồng tương đối đối với người dùng quốc tế, vì vậy tôi sử dụng đơn vị dvb logic, mới hơn và chỉ được hỗ trợ một phần trong nội dung khai báo thứ hai khi nó trở nên ổn định hơn.

Định vị hộp thoại lớn

Để hỗ trợ định vị một phần tử hộp thoại, bạn nên chia nhỏ hai phần: phông nền toàn màn hình và vùng chứa hộp thoại. Phông nền phải bao phủ mọi thứ, cung cấp hiệu ứng đổ bóng để giúp hộp thoại này hiển thị ở phía trước và nội dung phía sau không thể truy cập được. Vùng chứa hộp thoại có thể tự do căn giữa chính nó trên phông nền này và có hình dạng bất kỳ mà nội dung của nó yêu cầu.

Các kiểu sau đây sửa phần tử hộp thoại vào cửa sổ, kéo giãn phần tử đó đến từng góc và sử dụng margin: auto để căn giữa nội dung:

dialog {
  …
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
Kiểu hộp thoại lớn trên thiết bị di động

Trên các khung nhìn nhỏ, tôi tạo kiểu cho cửa sổ lớn toàn trang này hơi khác một chút. Tôi đặt lề dưới thành 0, tuỳ chọn này sẽ đưa nội dung hộp thoại xuống cuối khung nhìn. Với một vài điều chỉnh về kiểu, tôi có thể chuyển hộp thoại thành một bảng hành động, gần với ý định của người dùng hơn:

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

Ảnh chụp màn hình cho thấy công cụ nhà phát triển che phủ khoảng cách lề 
  trên cả hộp thoại lớn trên máy tính và thiết bị di động khi đang mở.

Định vị hộp thoại thu nhỏ

Khi sử dụng một khung nhìn lớn hơn, chẳng hạn như trên máy tính, tôi đã chọn đặt các hộp thoại thu nhỏ trên phần tử gọi các hộp thoại đó. Để thực hiện việc này, tôi cần JavaScript. Bạn có thể tìm thấy kỹ thuật tôi sử dụng tại đây, nhưng tôi cảm thấy nó nằm ngoài phạm vi của bài viết này. Nếu không có JavaScript, hộp thoại nhỏ sẽ xuất hiện ở giữa màn hình, giống như hộp thoại lớn.

Hiển thị nổi bật

Cuối cùng, hãy thêm điểm nhấn cho hộp thoại để hộp thoại trông giống như một bề mặt mềm mại nằm ở phía xa phía trên trang. Độ mềm đạt được bằng cách bo tròn các góc của hộp thoại. Để đạt được chiều sâu này, bạn sẽ đạt được một trong những đốm bóng được chế tạo cẩn thận của Open Props:

dialog {
  …
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

Tuỳ chỉnh phần tử giả của phông nền

Tôi chọn phối hợp rất ít phông nền, chỉ thêm hiệu ứng làm mờ bằng backdrop-filter vào hộp thoại lớn:

Hỗ trợ trình duyệt

  • 76
  • 17
  • 103
  • 9

Nguồn

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

Tôi cũng chọn chuyển đổi vào backdrop-filter, với hy vọng rằng các trình duyệt sẽ cho phép chuyển đổi phần tử phông nền trong tương lai:

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

Ảnh chụp màn hình hộp thoại lớn phủ lên nền mờ các hình đại diện đầy màu sắc.

Tạo kiểu bổ sung

Tôi gọi phần này là "thông tin bổ sung" vì phần này có liên quan nhiều hơn đến bản minh hoạ phần tử hộp thoại của tôi so với phần tử hộp thoại nói chung.

Ngăn cuộn

Khi hộp thoại hiện ra, người dùng vẫn có thể cuộn trang phía sau hộp thoại đó mà tôi không muốn:

Thông thường, overscroll-behavior sẽ là giải pháp thông thường của tôi, nhưng theo thông số kỹ thuật, nó không ảnh hưởng đến hộp thoại vì không phải là cổng cuộn, nghĩa là không phải là thanh cuộn nên không có gì phải ngăn chặn. Tôi có thể dùng JavaScript để theo dõi các sự kiện mới trong hướng dẫn này, chẳng hạn như "đã đóng" và "đã mở" và bật/tắt overflow: hidden trên tài liệu, hoặc tôi có thể đợi :has() trở nên ổn định trong tất cả các trình duyệt:

Hỗ trợ trình duyệt

  • 105
  • 105
  • 121
  • 15,4

Nguồn

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

Giờ đây, khi một hộp thoại lớn được mở, tài liệu html sẽ có overflow: hidden.

Bố cục <form>

Ngoài việc là một phần tử rất quan trọng để thu thập thông tin về hoạt động tương tác của người dùng, tôi cũng sử dụng phần tử này để sắp xếp bố cục các phần tử tiêu đề, chân trang và bài viết. Với bố cục này, tôi dự định trình bày rõ ràng thành phần con của bài viết là một khu vực có thể cuộn. Tôi đạt được điều này bằng grid-template-rows. Phần tử bài viết được cung cấp 1fr và bản thân biểu mẫu có cùng chiều cao tối đa với phần tử hộp thoại. Việc đặt chiều cao nhất định và kích thước hàng chắc chắn này cho phép phần tử bài viết bị hạn chế và cuộn khi phần tử bị tràn:

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

Ảnh chụp màn hình cho thấy các công cụ cho nhà phát triển che phủ thông tin bố cục lưới trên các hàng.

Tạo kiểu cho hộp thoại <header>

Vai trò của phần tử này là cung cấp tiêu đề cho nội dung hộp thoại và cung cấp một nút đóng dễ tìm. Khung này cũng được cung cấp màu cho vùng hiển thị để phía sau nội dung hộp thoại. Các yêu cầu này dẫn đến một vùng chứa flexbox, các mục được căn chỉnh theo chiều dọc được đặt khoảng cách với các cạnh của chúng, cũng như một số khoảng đệm và khoảng trống để tạo ra khoảng trống cho tiêu đề và nút đóng:

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

Ảnh chụp màn hình về Chrome Devtools phủ thông tin bố cục flexbox trên tiêu đề hộp thoại.

Tạo kiểu cho nút đóng tiêu đề

Vì bản minh hoạ sử dụng các nút Open Props, nên nút đóng được tuỳ chỉnh thành một nút tròn làm trung tâm biểu tượng như sau:

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

Ảnh chụp màn hình Chrome Devtools phủ lên thông tin kích thước và khoảng đệm cho nút đóng tiêu đề.

Tạo kiểu cho hộp thoại <article>

Phần tử bài viết có vai trò đặc biệt trong hộp thoại này: đó là khoảng trống mà người dùng có thể cuộn trong trường hợp hộp thoại cao hoặc dài.

Để thực hiện điều này, phần tử biểu mẫu mẹ đã thiết lập một số mức tối đa cho chính nó, mang lại các quy tắc ràng buộc để phần tử bài viết này đạt được nếu quá cao. Đặt overflow-y: auto để thanh cuộn chỉ xuất hiện khi cần thiết, chứa thao tác cuộn trong đó bằng overscroll-behavior: contain và phần còn lại sẽ là các kiểu trình bày tuỳ chỉnh:

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

Vai trò của chân trang là chứa các trình đơn của nút hành động. Hộp linh hoạt dùng để căn chỉnh nội dung với cuối trục cùng dòng của chân trang, sau đó thêm một số khoảng trống để tạo không gian cho các nút.

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

Ảnh chụp màn hình về Chrome Devtools phủ thông tin bố cục flexbox trên phần tử chân trang.

Phần tử menu dùng để chứa các nút hành động cho hộp thoại. Phương thức này sử dụng bố cục hộp linh hoạt bao bọc với gap để tạo không gian giữa các nút. Các phần tử trình đơn có khoảng đệm như <ul>. Tôi cũng xoá kiểu đó vì không cần đến.

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

Ảnh chụp màn hình về Chrome Devtools phủ thông tin flexbox trên các phần tử trình đơn chân trang.

Hoạt ảnh

Các thành phần của hộp thoại thường là ảnh động vì chúng đi vào và thoát khỏi cửa sổ. Bằng việc cung cấp các hộp thoại chuyển động hỗ trợ cho lối vào và lối thoát này, người dùng có thể tự định hướng trong luồng.

Thông thường, bạn chỉ có thể tạo ảnh động cho thành phần hộp thoại vào chứ không thể tạo ảnh động bên ngoài. Lý do là trình duyệt bật/tắt thuộc tính display trên phần tử này. Trước đó, hướng dẫn đặt chế độ hiển thị thành lưới và không bao giờ đặt chế độ này thành không. Điều này mang đến khả năng tạo hiệu ứng động.

Open Props đi kèm với nhiều ảnh động khung hình chính để sử dụng, giúp việc điều phối trở nên dễ dàng và dễ đọc. Dưới đây là mục tiêu ảnh động và phương pháp phân lớp mà tôi đã sử dụng:

  1. Chuyển động giảm là hiệu ứng chuyển đổi mặc định, độ mờ đơn giản rõ dần.
  2. Nếu có thể chuyển động, hãy thêm ảnh động theo tỷ lệ và trượt.
  3. Bố cục thích ứng trên thiết bị di động cho hộp thoại lớn được điều chỉnh thành trượt ra.

Chuyển đổi mặc định an toàn và có ý nghĩa

Mặc dù Đạo cụ mở đi kèm với các khung hình chính để làm mờ và hiện rõ dần, nhưng tôi thích phương pháp chuyển đổi theo lớp này làm phương pháp mặc định, trong đó ảnh động khung hình chính là bản nâng cấp tiềm năng. Trước đó, chúng ta đã tạo kiểu cho chế độ hiển thị của hộp thoại bằng độ mờ, điều chỉnh 1 hoặc 0 tuỳ thuộc vào thuộc tính [open]. Để chuyển đổi giữa 0% và 100%, hãy cho trình duyệt biết thời gian và kiểu tốc độ mà bạn muốn:

dialog {
  transition: opacity .5s var(--ease-3);
}

Thêm chuyển động vào hiệu ứng chuyển cảnh

Nếu người dùng hài lòng với chuyển động, cả hộp thoại lớn và hộp thoại nhỏ đều sẽ trượt lên ở lối vào và mở rộng ra khi thoát. Bạn có thể thực hiện việc này bằng truy vấn nội dung nghe nhìn prefers-reduced-motion và một số Open Prop:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

Điều chỉnh ảnh động thoát cho thiết bị di động

Trước đó trong phần định kiểu, kiểu hộp thoại lớn được điều chỉnh cho phù hợp với thiết bị di động để giống với một bảng hành động, như thể một mảnh giấy nhỏ trượt từ cuối màn hình lên và vẫn được gắn vào phần dưới cùng. Ảnh động thoát mở rộng không phù hợp với thiết kế mới này và chúng ta có thể điều chỉnh điều này bằng một vài truy vấn phương tiện và một số Đạo cụ mở:

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

Có khá nhiều thứ cần thêm với JavaScript:

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

Những bổ sung này xuất phát từ mong muốn loại bỏ ánh sáng (nhấp vào phông nền hộp thoại), ảnh động và một số sự kiện bổ sung để có thời gian tốt hơn cho việc lấy dữ liệu biểu mẫu.

Đang thêm chế độ loại bỏ ánh sáng

Tác vụ này rất đơn giản và là một phần bổ sung tuyệt vời cho phần tử hộp thoại không được tạo ảnh động. Hoạt động tương tác có được bằng cách xem các lượt nhấp vào phần tử hộp thoại và tận dụng tính năng bong bóng sự kiện để đánh giá nội dung được nhấp vào. close() nếu đó là phần tử trên cùng:

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

Lưu ý dialog.close('dismiss'). Sự kiện này được gọi và một chuỗi được cung cấp. Chuỗi này có thể được JavaScript khác truy xuất để lấy thông tin chi tiết về cách hộp thoại được đóng. Bạn sẽ thấy tôi cũng cung cấp các chuỗi đóng mỗi khi gọi hàm từ nhiều nút, để cung cấp ngữ cảnh cho ứng dụng về hoạt động tương tác của người dùng.

Thêm sự kiện đóng và sự kiện đã đóng

Phần tử hộp thoại đi kèm với một sự kiện đóng: nó phát ra ngay lập tức khi hàm close() hộp thoại được gọi. Vì đang tạo ảnh động cho phần tử này, nên bạn nên có các sự kiện trước và sau ảnh động, để thay đổi lấy dữ liệu hoặc đặt lại biểu mẫu hộp thoại. Tôi sử dụng thuộc tính này ở đây để quản lý việc thêm thuộc tính inert vào hộp thoại đóng. Trong bản minh hoạ, tôi dùng các thuộc tính này để sửa đổi danh sách hình đại diện nếu người dùng đã gửi hình ảnh mới.

Để làm việc này, hãy tạo 2 sự kiện mới có tên là closingclosed. Sau đó, hãy theo dõi sự kiện đóng tích hợp sẵn trên hộp thoại. Từ đây, hãy đặt hộp thoại thành inert và gửi sự kiện closing. Nhiệm vụ tiếp theo là đợi các ảnh động và hiệu ứng chuyển đổi hoàn tất quá trình chạy trên hộp thoại, sau đó điều phối sự kiện closed.

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  …
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

Hàm animationsComplete, cũng được dùng trong Thành phần tạo thông báo ngắn, sẽ trả về một lời hứa dựa trên việc hoàn tất ảnh động và các lời hứa chuyển đổi. Đây là lý do tại sao dialogClose là một hàm không đồng bộ; sau đó, hàm này có thể await lời hứa được trả về và chuyển tiếp một cách tự tin đến sự kiện đóng.

Thêm sự kiện khai trương và đã mở

Các sự kiện này không dễ thêm vì phần tử hộp thoại tích hợp không cung cấp sự kiện mở như với trường hợp đóng. Tôi sử dụng MutationObserver để cung cấp thông tin chi tiết về việc thay đổi thuộc tính của hộp thoại. Trong trình quan sát này, tôi sẽ theo dõi các thay đổi đối với thuộc tính mở và quản lý các sự kiện tuỳ chỉnh cho phù hợp.

Tương tự như cách chúng ta bắt đầu các sự kiện kết thúc và đóng, hãy tạo hai sự kiện mới có tên là openingopened. Trong trường hợp trước đây chúng ta đã nghe sự kiện đóng hộp thoại, lần này hãy sử dụng trình quan sát biến đổi đã tạo để theo dõi các thuộc tính của hộp thoại.

…
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  …
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

Hàm callback của trình quan sát biến đổi sẽ được gọi khi các thuộc tính hộp thoại thay đổi, cung cấp danh sách các thay đổi dưới dạng một mảng. Lặp lại các thay đổi về thuộc tính, tìm attributeName để mở. Tiếp theo, hãy kiểm tra xem phần tử có thuộc tính này hay không: điều này cho biết hộp thoại đã mở hay chưa. Nếu nó đã được mở, hãy xoá thuộc tính inert, đặt tiêu điểm thành một phần tử yêu cầu autofocus hoặc phần tử button đầu tiên tìm thấy trong hộp thoại. Cuối cùng, tương tự như sự kiện đóng và sự kiện đóng, hãy điều phối sự kiện mở ngay lập tức, đợi ảnh động hoàn tất rồi gửi sự kiện mở.

Thêm một sự kiện đã bị xoá

Trong các ứng dụng trang đơn, hộp thoại thường được thêm và xoá dựa trên tuyến hoặc các nhu cầu và trạng thái khác của ứng dụng. Bạn nên dọn dẹp các sự kiện hoặc dữ liệu khi một hộp thoại bị xoá.

Bạn có thể thực hiện điều này bằng một đối tượng tiếp nhận dữ liệu đột biến khác. Lần này, thay vì quan sát các thuộc tính trên một phần tử hộp thoại, chúng ta sẽ quan sát các phần tử con của phần tử nội dung và theo dõi các phần tử hộp thoại bị xoá.

…
const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  …
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

Lệnh gọi lại trình quan sát biến đổi được gọi bất cứ khi nào phần tử con được thêm vào hoặc bị xoá khỏi phần nội dung của tài liệu. Các đột biến cụ thể đang được theo dõi là dành cho removedNodesnodeName của hộp thoại. Nếu một hộp thoại bị xoá, thì các sự kiện nhấp và đóng sẽ bị xoá để giải phóng bộ nhớ, đồng thời sự kiện tuỳ chỉnh bị xoá sẽ được gửi đi.

Xoá thuộc tính đang tải

Để ngăn ảnh động của hộp thoại phát ảnh động thoát khi được thêm vào trang hoặc khi tải trang, thuộc tính đang tải đã được thêm vào hộp thoại. Tập lệnh sau đây chờ các ảnh động của hộp thoại chạy xong rồi xoá thuộc tính. Giờ đây, hộp thoại có thể tự do tạo ảnh động để thêm ảnh động vào và ra, đồng thời chúng tôi đã ẩn một cách hiệu quả một ảnh động gây mất tập trung.

export default async function (dialog) {
  …
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Tìm hiểu thêm về vấn đề ngăn chặn ảnh động khung hình chính khi tải trang tại đây.

Tất cả ở cùng một nơi

Dưới đây là toàn bộ nội dung về dialog.js. Giờ đây, chúng tôi đã giải thích riêng cho từng phần:

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

Sử dụng mô-đun dialog.js

Hàm đã xuất từ mô-đun dự kiến được gọi và truyền một phần tử hộp thoại muốn thêm các sự kiện và chức năng mới sau đây:

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

Tương tự như vậy, hai hộp thoại được nâng cấp với tính năng đóng nhẹ, sửa ảnh động tải ảnh động và nhiều sự kiện khác để sử dụng.

Theo dõi các sự kiện tuỳ chỉnh mới

Giờ đây, mỗi phần tử hộp thoại được nâng cấp có thể theo dõi 5 sự kiện mới, chẳng hạn như:

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

Dưới đây là hai ví dụ về cách xử lý các sự kiện đó:

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

Trong bản minh hoạ được tạo bằng phần tử hộp thoại, tôi sử dụng sự kiện đóng đó và dữ liệu biểu mẫu để thêm phần tử hình đại diện mới vào danh sách. Đã đến thời điểm phù hợp vì hộp thoại đã hoàn tất ảnh động thoát, sau đó một số tập lệnh sẽ tạo hiệu ứng trong hình đại diện mới. Nhờ các sự kiện mới, việc sắp xếp trải nghiệm người dùng có thể mượt mà hơn.

Lưu ý dialog.returnValue: Mã này chứa chuỗi đóng được truyền khi sự kiện hộp thoại close() được gọi. Điều quan trọng là bạn phải biết hộp thoại đã đóng, huỷ hay xác nhận trong sự kiện dialogClosed. Nếu được xác nhận, tập lệnh sẽ lấy các giá trị của biểu mẫu và đặt lại biểu mẫu. Thao tác đặt lại rất hữu ích vì khi hộp thoại xuất hiện lại, hộp thoại sẽ trống và sẵn sàng để gửi mới.

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 🙂

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

Tài nguyên