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 cửa sổ nhỏ và lớn dễ tiếp cận bằng phần tử <dialog> để tạo khả năng thích ứng màu sắc, phản hồi nhanh và dễ tiếp cận.

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 cửa sổ nhỏ và siêu lớn có khả năng thích ứng màu sắc, phản hồi và dễ tiếp cận bằng phần tử <dialog>. 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 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ử <dialog> rất phù hợp cho hành động hoặc thông tin theo bối cảnh trong trang. Cân nhắc thời điể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à do 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ỷ.

Gần đây, phần tử <dialog> đã 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 đợi: sự kiện bổ sung, loại bỏ ánh sáng, ảnh động tuỳ chỉnh, loại nhỏ và loại siêu lớn.

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

Các thành phần cơ bản của phần tử <dialog> khá 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 để 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, phần tử hộp thoại sẽ tương đồng với một phương thức và các tên thường có thể thay thế cho nhau. Ở đây, tôi được quyền sử dụng phần tử hộp thoại cho cả cửa sổ bật lên hộp thoại nhỏ (nhỏ) cũng như hộp thoại toàn trang (mega). Tôi đặt tên cho chúng là lớn và nhỏ, với cả hai hộp thoại được điều chỉnh đôi chút 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 để bạn có thể 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 mini và lớn trong cả giao diện sáng lẫn tối.

Không phải lúc nào cũng vậy, nhưng các phần tử hộp thoại thường sẽ được dùng để thu thập một số thông tin về tương tác. Biểu mẫu bên trong các phần tử hộp thoại được tạo để kết hợp với nhau. Bạn nên có một phần tử 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. Hơn nữa, 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à chuyể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 siêu lớn

Một hộp thoại lớn có 3 phần tử bên trong biểu mẫu: <header>, <article>, và <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 để trình bày hộp thoại. Tiêu đề đặt tiêu đề cho cửa sổ phụ và có một nút đóng. Bài viết này dành cho thông tin và thông tin về biểu mẫu. Chân trang chứa <menu> 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ẽ được lấy tiêu điểm khi hộp thoại được mở và tôi thấy rằng tốt nhất là nên đặ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 rằng việc xác nhận là có chủ ý chứ không phải là ngẫu nhiên.

Hộp thoại nhỏ

Hộp thoại mini rất giống với hộp thoại lớn, chỉ khác là thiếu phần tử <header>. Điều này cho phép kích thước 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 một nền tảng vững chắc cho phần tử khung nhìn toàn diện, 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 hiện đã có sẵn.

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

Như chúng ta đã làm thủ công trong bài viết 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 tiêu điểm 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.

Với phần tử hộp thoại, đây là hành vi mặc định 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 trong và ngoài, 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 đó.

Tập trung vào bẫy

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

Hỗ trợ trình duyệt

  • 102
  • 102
  • 112
  • 15,5

Nguồn

Sau inert, bất kỳ phần nào của tài liệu đều có thể bị "cố định" đến mức không còn là mục tiêu lấy tiêu điểm hoặc không còn tương tác được bằng chuột. Thay vì lấy tiêu điểm, tiêu điểm sẽ được đưa 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ử phù hợp 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 rằng tốt nhất là nên đặt thông tin này trên 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ủ ý chứ không phải ngẫu nhiên.

Đóng bằng phím Escape

Bạn phải đảm bảo 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 để bạn không phải chịu trách nhiệm điều phối.

Kiểu

Có một đường dẫn đơn giản để 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ạo ra đường dẫn dễ dàng khi không thay đổi thuộc tính hiển thị của hộp thoại và xử lý các hạn chế của hộp thoại. Tôi đi theo đường dẫn cứng để cung cấp ảnh động tuỳ chỉnh cho việc mở và đóng hộp thoại, chiếm quyền kiểm soát thuộc tính display và nhiều nội dung khác.

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

Để tăng tốc độ màu sắc thích ứng và tính nhất quán tổng thể của thiết kế, tôi đã rất tự hào khi đưa Open Props vào 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 normal hoá và một số nút, cả hai nút Open Props đều cung cấp dưới dạng dữ liệu nhập không bắt buộc. Các lệnh 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ạ mà không cần nhiều kiểu để hỗ trợ và làm cho giao diện trở nên đẹp mắt.

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

Sở hữu thuộc tính hiển thị

Hành vi hiện và ẩn mặc định của phần tử hộp thoại sẽ chuyển đổi thuộc tính hiển thị từ block thành none. Rất tiếc, điều này có nghĩa là không thể tạo ảnh động trong và ngoài, chỉ trong. Tôi muốn tạo ảnh động cả trong và ngoài, 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à sở hữu, giá trị thuộc tính hiển thị, như minh hoạ trong đoạn mã CSS ở trên, một số lượng kiểu định dạng cần được quản lý đáng kể để mang lại trải nghiệm phù hợp cho người dùng. Đầu 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ác hoạt động tương tác với các kiểu sau:

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

Giờ đây, hộp thoại sẽ không xuất hiện và bạn 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 bị ẩn.

Cung cấp chủ đề màu sắc thích ứng cho hộp thoại

Hộp thoại siêu lớn hiển thị giao diện sáng và tối, thể hiện màu sắc của bề mặt.

Mặc dù color-scheme chọn sử dụng giao diện màu 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 muốn tuỳ chỉnh phần tử hộp thoại nhiều hơn thế. Đạo cụ mở cung cấp một số màu sắc bề mặt tự động điều chỉnh theo các lựa chọn ưu tiên về hệ thống sáng và tối, tương tự như việc sử dụng color-scheme. Các định dạng này rất phù hợp để tạo các lớp trong một thiết kế và tôi thích sử dụng màu sắc để hỗ trợ trực quan giao diện này của các bề mặt lớp. Màu nền là var(--surface-1); để đặt lớp trên cùng, 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 thích ứng khác sẽ được thêm vào sau cho các phần tử con, chẳng hạn như đầu trang và chân trang. Tôi xem đó là yếu tố bổ sung cho thành phần hộp thoại, nhưng thực sự quan trọng trong việc tạo ra thiết kế hộp thoại hấp dẫn và tốt.

Kích thước hộp thoại thích ứng

Theo mặc định, hộp thoại này sẽ uỷ quyền kích thước của hộp thoại cho nội dung trong đó, nhìn chung là rất tốt. Mục tiêu của tôi ở đây là ràng buộc max-inline-size với kích thước dễ đọ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 hiển thị tràn viền trên thiết bị di động và sẽ 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;
}

Bạn có nhận thấy cách tôi có max-block-size hai lần không? 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 siêu lớn

Để hỗ trợ định vị phần tử hộp thoại, bạn nên chia nhỏ hai phần của phần tử này: 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 đổ để hỗ trợ hộp thoại này ở 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ể tuỳ ý căn giữa trên phông nền này và có hình dạng bất kỳ mà nội dung trong đó yêu cầu.

Các kiểu sau đây sẽ cố định phần tử hộp thoại thành cửa sổ, kéo giãn phần tử theo 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 cho toàn bộ trang này hơi khác một chút. Tôi đặt lề dưới cùng thành 0 để đư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 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 ngón cái 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 công cụ cho nhà phát triển che phủ khoảng cách lề trên cả hộp thoại lớn cho máy tính và thiết bị di động trong khi mở.

Định vị hộp thoại 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 nhỏ lên 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 mà tôi sử dụng ở đây, nhưng tôi cho rằng 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.

Làm nổi bật

Cuối cùng, hãy thêm một chút tinh tế cho hộp thoại để hộp thoại trông giống như một bề mặt mềm nằm 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. Độ sâu này đạt được bằng một trong những đạo cụ bóng được chế tác 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ả phông nền

Tôi chọn xử lý rất nhẹ với 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
  • 79
  • 103
  • 9

Nguồn

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

Tôi cũng đã chọn đặt hiệu ứng 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 của hộp thoại lớn phủ lên nền được làm mờ gồ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 nó 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, giải pháp này không ảnh hưởng đến hộp thoại vì đây không phải là cổng cuộn, nghĩa là nó không phải là một trình cuộn nên không có gì ngăn chặn. Tôi có thể sử dụng JavaScript để theo dõi các sự kiện mới từ 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() ổ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;
}

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

Bố cục <form>

Bên cạnh việc là một thành phần rất quan trọng để thu thập thông tin tương tác từ người dùng, tôi còn sử dụng thành phần này ở đây để bố trí các thành phần cho tiêu đề, chân trang và bài viết. Với bố cục này, tôi dự định sẽ trình bày rõ phần con bài viết là một khu vực có thể cuộn. Tôi thực hiện việc 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 như phần tử hộp thoại. Việc đặt chiều cao cố định và kích thước hàng cố định này sẽ cho phép hạn chế và cuộn phần tử bài viết khi 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 công cụ cho nhà phát triển phủ thông tin bố cục lưới lê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ễ thấy. Bài viết cũng được gán màu cho bề mặt để làm cho nó trông giống như phía sau nội dung bài viết trên hộp thoại. Những yêu cầu này dẫn đến một vùng chứa hộp linh hoạt, các mục được căn chỉnh theo chiều dọc được đặt cách nhau theo các cạnh, đồng thời một số khoảng đệm và khoảng trống để tạo ra một số 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 Chrome Devtools che phủ thông tin bố cục hộp linh hoạt trên tiêu đề hộp thoại.

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

Vì bản minh hoạ đang sử dụng các nút Open Props (Mở đạo cụ), nên nút đóng được tuỳ chỉnh thành một nút hình tròn tập trung vào 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 che phủ thông tin về 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à một không gian 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 phần tử này, cung cấp các điều kiện ràng buộc để phần tử bài viết này tiếp cận được nếu phần tử quá cao. Đặt overflow-y: auto để thanh cuộn chỉ hiển thị khi cần, hãy chứa tính năng cuộn bên trong bằng overscroll-behavior: contain và phần còn lại sẽ là 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 gồm các nút hành động. Hộp linh hoạt dùng để căn chỉnh nội dung đến cuối trục cùng dòng ở chân trang, sau đó tạo khoảng cách để các nút có chỗ trống.

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 Chrome Devtools che phủ thông tin bố cục hộp linh hoạt trên phần tử chân trang.

Phần tử menu được dùng để chứa các nút hành động cho hộp thoại. Giao diện này sử dụng bố cục hộp linh hoạt gói với gap để tạo không gian giữa các nút. Các thành phần trong trình đơn có khoảng đệm, chẳng hạn 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 Chrome Devtools che phủ thông tin hộp linh hoạt trên các phần tử của trình đơn chân trang.

Hoạt ảnh

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

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

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

  1. Chuyển động giảm dần là hiệu ứng chuyển đổi mặc định, độ mờ đơn giản hiện dần và rõ dần.
  2. Nếu chuyển động được, ảnh động trượt và tỷ lệ sẽ được thêm.
  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 dạng trượt ra.

Quá trình chuyển đổi mặc định an toàn và hiệu quả

Mặc dù Đạo cụ mở đi kèm với khung hình chính để làm mờ và làm mờ, nhưng tôi thích phương pháp chuyển đổi theo lớp này làm mặc định, trong đó ảnh động khung hình chính là các 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 từ 0% đến 100%, hãy cho trình duyệt biết thời gian và loại tốc độ bạn muốn:

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

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

Nếu người dùng không đồng ý với chuyển động, thì cả hộp thoại lớn và hộp thoại nhỏ sẽ trượt lên khi người dùng vào và mở rộng khi thoát ra. 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 (Đạo cụ mở):

@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 tạo kiểu, kiểu hộp thoại lớn được điều chỉnh cho các thiết bị di động để giống với một trang tính hành động, như thể một mảnh giấy nhỏ trượt lên từ cuối màn hình và vẫn được gắn vào dưới cùng. Ảnh động thoát quy mô không phù hợp với thiết kế mới này. Chúng ta có thể điều chỉnh ảnh động này bằng một số truy vấn nội dung nghe nhìn và một số Mở đạo cụ:

@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 nội dung 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 nội dung bổ sung này xuất phát từ mong muốn loại bỏ nhẹ (nhấp vào phông nền của hộp thoại), ảnh động và một số sự kiện bổ sung để xác định thời gian lấy dữ liệu biểu mẫu nhanh hơn.

Đang thêm thao tác đóng

Tác vụ này rất đơn giản và là nội dung bổ sung tuyệt vời cho phần tử hộp thoại không phải là ảnh động. Bạn có thể tương tá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 hiệu ứng bong bóng sự kiện để đánh giá nội dung đã nhấp vào. Tính năng này sẽ chỉ 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 được gọi và một chuỗi được cung cấp. JavaScript khác có thể truy xuất chuỗi này để biết thông tin chi tiết về cách đóng hộp thoại. Bạn sẽ thấy rằng 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 của tôi về hoạt động tương tác của người dùng.

Thêm sự kiện đóng cửa và đóng cửa

Phần tử hộp thoại đi kèm với một sự kiện đóng: phần tử này phát ra ngay khi hàm close() của hộp thoại được gọi. Vì chúng ta đ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, cũng như thay đổi cách 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 sử 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ại đây, hãy đặt hộp thoại thành inert rồi gửi sự kiện closing. Nhiệm vụ tiếp theo là chờ các ảnh động và hiệu ứng chuyển đổi chạy xong trên hộp thoại, sau đó gử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, trả về một lời hứa dựa trên việc hoàn tất ảnh động và 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 hứa hẹn được trả về và tự tin chuyển tiếp đến sự kiện đã đóng.

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

Những sự kiện này không dễ thêm vào vì phần tử hộp thoại tích hợp không cung cấp sự kiện mở như khi đóng. Tôi sử dụng MutationObserver để cung cấp thông tin chi tiết về các thuộc tính của hộp thoại thay đổ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 open 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 sự kiện kết thúc và kết thúc, 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 đột biến đã 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 gọi lại trình quan sát đột biến sẽ được gọi khi 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ả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: thông tin này cho biết hộp thoại đã mở hay chưa. Nếu hộp thoại đã mở, hãy xoá thuộc tính inert, đặt tiêu điểm thành 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à đóng, hãy điều phối sự kiện mở màn ngay lập tức, đợi ảnh động kết thúc rồi gửi sự kiện đã mở.

Thêm sự kiện đã xóa

Trong các ứng dụng trang đơn, hộp thoại thường được thêm và xoá dựa trên các tuyến đường hoặ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 hộp thoại bị xoá.

Bạn có thể thực hiện việc này bằng một trình quan sát độ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 đột biến được gọi bất cứ khi nào phần tử con được thêm 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.

Đang 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 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 này. Giờ đây, hộp thoại có thể tuỳ ý tạo hiệu ứng động và chúng tôi đã ẩn một cách hiệu quả một ảnh động gây rối mắt.

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

Tìm hiểu thêm về vấ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

Sau đây là toàn bộ dialog.js, vì giờ đây chúng tôi đã giải thích riêng 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 được xuất từ mô-đun dự kiến sẽ đượ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)

Cứ thế, hai hộp thoại này được nâng cấp với tính năng đóng nhẹ, sửa lỗi tải ảnh động và nhiều sự kiện khác để hoạt độ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à 2 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ạ mà tôi 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. Thời điểm thích hợp là hộp thoại đã hoàn tất ảnh động thoát, sau đó một số tập lệnh tạo hiệu ứng độ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ể suôn sẻ hơn.

Lưu ý dialog.returnValue: thuộc tính này chứa chuỗi đóng được truyền khi sự kiện close() của hộp thoại được gọi. Trong sự kiện dialogClosed, điều quan trọng là phải biết hộp thoại đã đóng, huỷ hay xác nhận. Nếu được xác nhận, tập lệnh sẽ lấy các giá trị biểu mẫu và đặt lại biểu mẫu. Thao tác đặt lại sẽ hữu ích khi hộp thoại hiện lại, sẽ trống và sẵn sàng để gửi nội dung mớ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‽ 🙂

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

Tài nguyên