CTA Modal: Cách xây dựng một thành phần web

theanh

Administrator
Nhân viên
Tôi phải thú nhận một điều — tôi không thích hộp thoại modal (hay gọi tắt là "modal"). "Ghét" có lẽ là một từ quá mạnh để sử dụng, nhưng hãy nói rằng không có gì khiến người ta mất hứng hơn khi bắt đầu đọc một bài viết hơn là bị "tát vào mặt" bằng một cửa sổ modal trước khi tôi thậm chí bắt đầu hiểu những gì mình đang xem.

Hoặc, nếu tôi có thể trích dẫn Andy Budd:
Một lượt truy cập trang web thông thường vào năm 2022

1. Tìm cách từ chối tất cả các cookie ngoại trừ cookie cần thiết
2. Đóng tiện ích hỗ trợ hỏi xem tôi có cần trợ giúp không
3. Dừng video tự động phát
4. Đóng cửa sổ bật lên “đăng ký nhận bản tin của chúng tôi”
5. Hãy thử nhớ lại lý do tại sao tôi đến đây ngay từ đầu
— Andy Budd (@andybudd) Ngày 2 tháng 1 năm 2022
Tuy nhiên, hộp thoại có mặt ở khắp mọi nơi trong chúng ta. Chúng là một mô hình giao diện người dùng mà chúng ta không thể dễ dàng thay đổi. Khi được sử dụng một cách tinh tếkhôn ngoan, tôi dám nói rằng chúng thậm chí có thể giúp thêm ngữ cảnh vào tài liệu hoặc ứng dụng.

Trong suốt sự nghiệp của mình, tôi đã viết khá nhiều hộp thoại. Tôi đã xây dựng các triển khai riêng bằng JavaScript thuần túy, jQuery và gần đây hơn là — React. Nếu bạn đã từng vật lộn để xây dựng một mô-đun, thì bạn sẽ hiểu ý tôi khi tôi nói: Rất dễ làm sai. Không chỉ từ quan điểm trực quan mà còn có rất nhiều tương tác phức tạp của người dùng cần được tính đến.

Tôi là kiểu người thích "đi sâu" vào các chủ đề khiến tôi khó chịu — đặc biệt là khi tôi thấy chủ đề đó xuất hiện trở lại — hy vọng là để tránh phải xem lại chúng một lần nữa. Khi tôi bắt đầu tìm hiểu sâu hơn về Web Components, tôi đã có khoảnh khắc "a-ha!". Bây giờ Web Components được hỗ trợ rộng rãi bởi mọi trình duyệt chính (RIP, IE11), điều này mở ra một cánh cửa cơ hội hoàn toàn mới. Tôi tự nhủ:
“Sẽ thế nào nếu có thể xây dựng một modal mà với tư cách là một nhà phát triển tạo ra một trang hoặc ứng dụng, tôi sẽ không phải loay hoay với bất kỳ cấu hình JavaScript bổ sung nào?”
Viết một lần và chạy ở mọi nơi, có thể nói như vậy, hoặc ít nhất đó là nguyện vọng cao cả của tôi. Tin tốt đây. Thật vậy, có thể xây dựng một modal có tương tác phong phú mà chỉ cần sử dụng HTML.

Lưu ý: Để hưởng lợi từ bài viết này và các ví dụ mã, bạn sẽ cần có một số hiểu biết cơ bản về HTML, CSS và JavaScript.




Trước khi chúng ta bắt đầu​

Nếu bạn không có nhiều thời gian và chỉ muốn xem sản phẩm hoàn thiện, hãy xem tại đây:

Sử dụng Nền tảng​

Bây giờ chúng ta đã đề cập đến "lý do" để giải quyết vấn đề cụ thể này, trong phần còn lại của bài viết này, tôi sẽ giải thích "cách" xây dựng nền tảng.

Đầu tiên, chúng ta sẽ tìm hiểu nhanh về Thành phần Web. Chúng là các đoạn mã HTML, CSS và JavaScript được đóng gói lại để bao hàm phạm vi. Nghĩa là không có kiểu nào từ bên ngoài thành phần sẽ ảnh hưởng đến bên trong hoặc ngược lại. Hãy nghĩ về nó như một “phòng sạch” được bịt kín hoàn toàn của thiết kế UI.

Thoạt nhìn, điều này có vẻ vô lý. Tại sao chúng ta lại muốn một phần UI mà chúng ta không thể kiểm soát bên ngoài thông qua CSS? Hãy giữ nguyên suy nghĩ đó, vì chúng ta sẽ sớm quay lại với nó.

Giải thích tốt nhất là khả năng tái sử dụng. Xây dựng một thành phần theo cách này có nghĩa là chúng ta không bị ràng buộc với bất kỳ khuôn khổ JS nào du jour. Một cụm từ phổ biến được sử dụng trong các cuộc trò chuyện xung quanh các tiêu chuẩn web là "sử dụng nền tảng." Bây giờ, hơn bao giờ hết, bản thân nền tảng này có hỗ trợ đa trình duyệt tuyệt vời.

Deep Dive​

Để tham khảo, tôi sẽ tham khảo ví dụ mã này — cta-modal.ts.

Lưu ý: Tôi đang sử dụng TypeScript ở đây, nhưng bạn hoàn toàn không cần bất kỳ công cụ bổ sung nào để tạo Thành phần Web. Trên thực tế, tôi đã viết bằng chứng khái niệm ban đầu của mình bằng JS thuần túy. Sau đó, tôi đã thêm TypeScript để tăng cường sự tự tin cho những người khác sử dụng nó như một gói NPM.

Tệp cta-modal.ts được chia thành nhiều phần:
  1. Gói có điều kiện;
  2. Hằng số:
    • Biến có thể sử dụng lại,
    • Kiểu thành phần,
    • Đánh dấu thành phần;
  3. Lớp CtaModal:
    • Trình xây dựng,
    • Liên kết this context,
    • Vòng đời phương pháp,
    • Thêm và xóa sự kiện,
    • Phát hiện các thay đổi thuộc tính,
    • Tập trung vào các phần tử cụ thể,
    • Phát hiện chế độ modal "bên ngoài",
    • Phát hiện tùy chọn chuyển động,
    • Chuyển đổi chế độ modal hiển thị/ẩn,
    • Xử lý sự kiện: nhấp vào lớp phủ,
    • Xử lý sự kiện: nhấp vào chuyển đổi,
    • Xử lý sự kiện: phần tử tiêu điểm,
    • Xử lý sự kiện: bàn phím;
  4. Lệnh gọi lại đã tải DOM:
    • Chờ trang sẵn sàng,
    • Đăng ký thẻ .

Bộ bao bọc có điều kiện​

Có một if cấp cao nhất, bao bọc toàn bộ mã của tệp:
Mã:
// ==========================// BẮT ĐẦU: if "customElements".// ===========================if ('customElements' in window) { /* LƯU Ý: ĐÃ XÓA CÁC DÒNG, ĐỂ NGẮN GỌN. */}// ========================// KẾT THÚC: if "customElements".// =========================
Có hai lý do cho việc này. Chúng tôi muốn đảm bảo rằng có trình duyệt hỗ trợ cho window.customElements. Nếu có, điều này sẽ cung cấp cho chúng tôi một cách tiện dụng để duy trì phạm vi biến. Nghĩa là khi khai báo biến thông qua const hoặc let, chúng sẽ không "rò rỉ" ra bên ngoài khối if {…}. Trong khi sử dụng var cũ sẽ có vấn đề, vô tình tạo ra nhiều biến toàn cục.

Biến có thể tái sử dụng​

Lưu ý: Lớp Foo {…} của JavaScript khác với class="foo" của HTML hoặc CSS.

Hãy nghĩ đơn giản rằng: "Một nhóm các hàm được đóng gói lại với nhau."

Phần này của tệp chứa các giá trị nguyên thủy mà tôi dự định sử dụng lại trong toàn bộ khai báo lớp JS của mình. Tôi sẽ chỉ ra một số trong số chúng là đặc biệt thú vị.
Mã:
// ==========// Hằng số.// ==========/* LƯU Ý: ĐÃ XÓA CÁC DÒNG, ĐỂ NGẮN GỌN. */const ANIMATION_DURATION = 250;const DATA_HIDE = 'data-cta-modal-hide';const DATA_SHOW = 'data-cta-modal-show';const PREFERS_REDUCED_MOTION = '(prefers-reduced-motion: reduce)';const FOCUSABLE_SELECTORS = [ '[contenteditable]', '[tabindex="0"]:not([disabled])', 'a[href]', 'audio[controls]', 'button:not([disabled])', 'iframe', "input:not([disabled]):not([type='hidden'])", 'select:not([disabled])', 'summary', 'textarea:not([disabled])', 'video[controls]',].join(',');
  • ANIMATION_DURATION
    Chỉ định thời gian hoạt ảnh CSS của tôi sẽ mất. Tôi cũng sử dụng lại điều này sau trong setTimeout để giữ cho CSS và JS của tôi đồng bộ. Nó được đặt thành 250 mili giây, tức là một phần tư giây.
    Trong khi CSS cho phép chúng ta chỉ định animation-duration theo toàn bộ giây (hoặc mili giây), JS sử dụng các gia số của mili giây. Sử dụng giá trị này cho phép tôi sử dụng nó cho cả hai.
  • DATA_SHOWDATA_HIDE
    Đây là các chuỗi cho các thuộc tính dữ liệu HTML 'data-cta-modal-show''data-cta-modal-hide' được sử dụng để kiểm soát việc hiển thị/ẩn của modal, cũng như điều chỉnh thời gian hoạt ảnh trong CSS. Chúng được sử dụng sau đó kết hợp với ANIMATION_DURATION.
  • PREFERS_REDUCED_MOTION
    Một truy vấn phương tiện xác định xem người dùng có đặt tùy chọn hệ điều hành của họ thành reduce cho prefers-reduced-motion hay không. Tôi xem giá trị này trong cả CSS và JS để xác định xem có nên tắt hoạt ảnh hay không.
  • FOCUSABLE_SELECTORS
    Chứa các bộ chọn CSS cho tất cả các phần tử có thể được coi là có thể lấy nét trong một hộp thoại. Nó được sử dụng sau đó nhiều hơn một lần, thông qua querySelectorAll. Tôi đã khai báo nó ở đây để giúp tăng khả năng đọc, thay vì thêm sự lộn xộn vào thân hàm.
Nó tương đương với chuỗi này:
Mã:
[contenteditable], [tabindex="0"]:not([disabled]), a[href], audio[controls], button:not([disabled]), iframe, input:not([disabled]):not([type='hidden']), select:not([disabled]), summary, textarea:not([disabled]), video[controls]
Ghê quá, đúng không!? Bạn có thể thấy lý do tại sao tôi muốn chia thành nhiều dòng.

Là một độc giả tinh ý, bạn có thể nhận thấy type='hidden'tabindex="0" đang sử dụng các dấu ngoặc kép khác nhau. Điều đó có chủ đích, và chúng ta sẽ xem lại lý do sau.

Kiểu thành phần​

Phần này chứa một chuỗi nhiều dòng với thẻ . Như đã đề cập trước đó, các kiểu có trong Thành phần Web không ảnh hưởng đến phần còn lại của trang. Điều đáng chú ý là cách tôi sử dụng các biến nhúng ${etc} thông qua nội suy chuỗi.
  • Chúng tôi tham chiếu biến PREFERS_REDUCED_MOTION của mình để buộc đặt hoạt ảnh thành none cho những người dùng thích chuyển động giảm.
  • Chúng tôi tham chiếu DATA_SHOWDATA_HIDE cùng với ANIMATION_DURATION để cho phép chia sẻ quyền kiểm soát đối với hoạt ảnh CSS. Lưu ý việc sử dụng hậu tố ms cho mili giây, vì đó là ngôn ngữ chung của CSS và JS.
Mã:
// ======// Kiểu.// ======const STYLE = `  /* LƯU Ý: ĐÃ XÓA CÁC DÒNG, ĐỂ NGẮN GỌN. */ @media ${PREFERS_REDUCED_MOTION} { *, *:after, *:before { hoạt ảnh: không có !important; chuyển tiếp: không có !important; } } [${DATA_SHOW}='true'] .cta-modal__overlay { thời lượng hoạt ảnh: ${ANIMATION_DURATION}ms; tên hoạt ảnh: SHOW-OVERLAY; } [${DATA_SHOW}='true'] .cta-modal__dialog { thời lượng hoạt ảnh: ${ANIMATION_DURATION}ms; animation-name: SHOW-DIALOG; } [${DATA_HIDE}='true'] .cta-modal__overlay { animation-duration: ${ANIMATION_DURATION}ms; animation-name: HIDE-OVERLAY; opacity: 0; } [${DATA_HIDE}='true'] .cta-modal__dialog { animation-duration: ${ANIMATION_DURATION}ms; animation-name: HIDE-DIALOG; transform: scale(0.95); } `;

Đánh dấu thành phần​

Đánh dấu cho modal là phần đơn giản nhất. Đây là những khía cạnh thiết yếu tạo nên modal:
  • các khe,
  • khu vực có thể cuộn,
  • bẫy tiêu điểm,
  • lớp phủ bán trong suốt,
  • cửa sổ hộp thoại,
  • nút đóng.
Khi sử dụng thẻ trong trang của một người, có hai điểm chèn cho nội dung. Đặt các thành phần bên trong các khu vực này khiến chúng xuất hiện như một phần của hộp thoại:
  • ánh xạ tới ,
  • ánh xạ tới .
Bạn có thể tự hỏi "bẫy tiêu điểm" là gì và tại sao chúng ta cần chúng. Chúng tồn tại để bắt lấy tiêu điểm khi người dùng cố gắng chuyển tiếp (hoặc lùi) tab bên ngoài hộp thoại hộp thoại. Nếu bất kỳ thành phần nào trong số này nhận được tiêu điểm, chúng sẽ đặt tiêu điểm của trình duyệt trở lại bên trong.

Ngoài ra, chúng tôi cung cấp các thuộc tính này cho div mà chúng tôi muốn dùng làm thành phần hộp thoại hộp thoại của mình. Điều này cho trình duyệt biết rằng có ý nghĩa về mặt ngữ nghĩa. Nó cũng cho phép chúng ta tập trung vào phần tử thông qua JS:
  • aria-modal='true',
  • role='dialog',
  • tabindex'-1'.
Mã:
// =========// Mẫu.// =========const FOCUS_TRAP = ` `;const MODAL = `   ${FOCUS_TRAP}   ×    ${FOCUS_TRAP} `;// Lấy đánh dấu.const markup = [STYLE, MODAL].join(EMPTY_STRING).trim().replace(SPACE_REGEX, SPACE);// Lấy mẫu.const template = document.createElement(TEMPLATE);template.innerHTML = markup;
Bạn có thể tự hỏi: "Tại sao không sử dụng thẻ dialog?" Câu hỏi hay. Tại thời điểm viết bài này, nó vẫn có một số điểm kỳ quặc trên nhiều trình duyệt. Để biết thêm thông tin về điều đó, hãy đọc bài viết này của Scott O’hara. Ngoài ra, theo tài liệu của Mozilla, dialog không được phép có thuộc tính tabindex, thuộc tính này chúng ta cần tập trung vào modal của mình.

Constructor​

Bất cứ khi nào một lớp JS được khởi tạo, hàm constructor của nó sẽ được gọi. Đó chỉ là một thuật ngữ hoa mỹ có nghĩa là một thể hiện của lớp CtaModal đang được tạo. Trong trường hợp Thành phần Web của chúng ta, quá trình khởi tạo này diễn ra tự động bất cứ khi nào gặp trong HTML của trang.

Trong constructor, chúng ta gọi super để yêu cầu lớp HTMLElement (mà chúng ta đang extend-ing) gọi constructor của riêng nó. Hãy nghĩ về nó như mã keo, để đảm bảo chúng ta khai thác một số phương thức vòng đời mặc định.

Tiếp theo, chúng ta gọi this._bind() mà chúng ta sẽ đề cập thêm một chút sau. Sau đó, chúng ta đính kèm "shadow DOM" vào phiên bản lớp của mình và thêm đánh dấu mà chúng ta đã tạo dưới dạng chuỗi nhiều dòng trước đó.

Sau đó, chúng ta lấy tất cả các phần tử — từ bên trong phần component markup đã đề cập ở trên — để sử dụng trong các lệnh gọi hàm sau. Cuối cùng, chúng ta gọi một số phương thức trợ giúp đọc các thuộc tính từ thẻ tương ứng.
Mã:
// ======================// Vòng đời: constructor.// =======================constructor() { // Constructor cha. super(); // Liên kết ngữ cảnh. this._bind(); // Shadow DOM. this._shadow = this.attachShadow({ mode: 'closed' }); // Thêm template. this._shadow.appendChild( // Sao chép node. template.content.cloneNode(true) ); // Lấy các khe cắm. this._slotForButton = this.querySelector("[slot='button']"); this._slotForModal = this.querySelector("[slot='modal']"); // Lấy các phần tử. this._heading = this.querySelector('h1, h2, h3, h4, h5, h6'); // Lấy các phần tử bóng đổ. this._buttonClose = this._shadow.querySelector('.cta-modal__close') dưới dạng HTMLElement; this._focusTrapList = this._shadow.querySelectorAll('.cta-modal__focus-trap'); this._modal = this._shadow.querySelector('.cta-modal__dialog') dưới dạng HTMLElement; this._modalOverlay = this._shadow.querySelector('.cta-modal__overlay') dưới dạng HTMLElement; this._modalScroll = this._shadow.querySelector('.cta-modal__scroll') dưới dạng HTMLElement; // Thiếu khe cắm? if (!this._slotForModal) { window.console.error('Required [slot="modal"] not found inside cta-modal.'); } // Đặt cờ hoạt ảnh. this._setAnimationFlag(); // Đặt tiêu đề đóng. this._setCloseTitle(); // Đặt nhãn modal. this._setModalLabel(); // Đặt cờ tĩnh. this._setStaticFlag(); /* ===== LƯU Ý: ===== Chúng tôi đặt cờ này cuối cùng vì hình ảnh UI bên trong phụ thuộc vào một số cờ khác đang được đặt. */ // Đặt cờ hoạt động. this._setActiveFlag();}

Binding this Context​

Đây là một chút thủ thuật JS giúp chúng ta không phải gõ mã tẻ nhạt không cần thiết ở nơi khác. Khi làm việc với sự kiện DOM, ngữ cảnh của this có thể thay đổi, tùy thuộc vào phần tử nào đang được tương tác trong trang.

Một cách để đảm bảo rằng this luôn có nghĩa là thể hiện của lớp của chúng ta là gọi cụ thể bind. Về cơ bản, hàm this tạo ra nó, để nó được xử lý tự động. Điều đó có nghĩa là chúng ta không phải nhập những thứ như thế này ở mọi nơi.
Mã:
/* LƯU Ý: Chỉ là một ví dụ, chúng ta không cần this. */this.someFunctionName1 = this.someFunctionName1.bind(this);this.someFunctionName2 = this.someFunctionName2.bind(this);
Thay vì nhập đoạn mã trên, mỗi khi chúng ta thêm một hàm mới, một lệnh gọi this._bind() tiện dụng trong constructor sẽ xử lý bất kỳ/tất cả các hàm mà chúng ta có thể có. Vòng lặp này sẽ lấy mọi thuộc tính lớp là hàm và tự động liên kết chúng.
Mã:
// ===========================// Trợ giúp: liên kết ngữ cảnh `this`.// =============================_bind() { // Lấy tên thuộc tính. const propertyNames = Object.getOwnPropertyNames( // Lấy nguyên mẫu. Object.getPrototypeOf(this) ) as (keyof CtaModal)[]; // Lặp lại. propertyNames.forEach((name) => { // Liên kết các hàm. if (typeof this[name] === FUNCTION) { /* ===== LƯU Ý: ===== Tại sao lại sử dụng "@ts-expect-error" ở đây? Gọi `*.bind(this)` là một thông lệ chuẩn khi sử dụng các lớp JavaScript. Điều này là cần thiết đối với các hàm có thể thay đổi ngữ cảnh vì chúng tương tác trực tiếp với các phần tử DOM. Về cơ bản, tôi đang nói với TypeScript: "Hãy để tôi sống cuộc sống của mình!" 😎 */ // @ts-expect-error bind this[name] = this[name].bind(this); } });}

Phương pháp vòng đời​

Theo bản chất của dòng này, khi chúng ta mở rộng từ HTMLElement, chúng ta nhận được một vài lệnh gọi hàm tích hợp "miễn phí". Miễn là chúng ta đặt tên cho các hàm của mình theo những tên này, chúng sẽ được gọi vào thời điểm thích hợp trong vòng đời của thành phần của chúng ta.
Mã:
// =========// Thành phần.// ==========class CtaModal extends HTMLElement { /* LƯU Ý: ĐÃ XÓA CÁC DÒNG, ĐỂ NGẮN GỌN. */}
  • observedAttributes
    Điều này cho trình duyệt biết chúng ta đang theo dõi những thuộc tính nào để thay đổi.
  • attributeChangedCallback
    Nếu bất kỳ thuộc tính nào trong số đó thay đổi, lệnh gọi lại này sẽ được gọi. Tùy thuộc vào thuộc tính nào đã thay đổi, chúng ta sẽ gọi một hàm để đọc thuộc tính đó.
  • connectedCallback
    Điều này được gọi khi thẻ được đăng ký với trang. Chúng tôi sử dụng cơ hội này để thêm tất cả các trình xử lý sự kiện của mình.
    Nếu bạn quen thuộc với React, thì điều này tương tự như sự kiện vòng đời componentDidMount.
  • disconnectedCallback
    Điều này được gọi khi thẻ bị xóa khỏi trang. Tương tự như vậy, chúng tôi xóa tất cả các trình xử lý sự kiện lỗi thời khi/nếu điều này xảy ra.
    Điều này tương tự như sự kiện vòng đời componentWillUnmount trong React.
Lưu ý: Cần lưu ý rằng đây là những hàm duy nhất trong lớp của chúng ta không có tiền tố là dấu gạch dưới (_). Mặc dù không thực sự cần thiết, nhưng lý do cho điều này là hai mặt. Một là, nó làm rõ những hàm nào chúng ta đã tạo cho mới của mình và những hàm nào là sự kiện vòng đời gốc của lớp HTMLElement. Hai là, khi chúng ta thu nhỏ mã sau này, tiền tố biểu thị rằng chúng có thể bị làm sai lệch. Trong khi đó, các phương thức vòng đời gốc cần giữ nguyên tên của chúng.
Mã:
// ===========================// Vòng đời: theo dõi các thuộc tính.// ============================static get observedAttributes() { return [ACTIVE, ANIMATED, CLOSE, STATIC];}// =============================// Vòng đời: các thuộc tính đã thay đổi.// =============================attributeChangedCallback(name: string, oldValue: string, newValue: string) { // Giá trị cũ/mới khác nhau? if (oldValue !== newValue) { // Đã thay đổi giá trị [active="…"]? if (name === ACTIVE) { this._setActiveFlag(); } // Đã thay đổi giá trị [animated="…"]? if (name === ANIMATED) { this._setAnimationFlag(); } // Đã thay đổi giá trị [close="…"]? if (name === CLOSE) { this._setCloseTitle(); } // Đã thay đổi giá trị [static="…"]? if (name === STATIC) { this._setStaticFlag(); } }}// ==========================// Vòng đời: gắn kết thành phần.// ===========================connectedCallback() { this._addEvents();}// ============================// Vòng đời: hủy gắn kết thành phần.// ===========================disconnectedCallback() { this._removeEvents();}

Thêm và xóa sự kiện​

Các hàm này đăng ký (và xóa) các lệnh gọi lại cho nhiều sự kiện ở cấp độ phần tử và trang:
  • nhấp vào nút,
  • lấy tiêu điểm vào phần tử,
  • nhấn bàn phím,
  • nhấp vào lớp phủ.
Mã:
// ===================// Trợ giúp: thêm sự kiện.// ====================_addEvents() { // Ngăn chặn sự trùng lặp. this._removeEvents(); document.addEventListener(FOCUSIN, this._handleFocusIn); document.addEventListener(KEYDOWN, this._handleKeyDown); this._buttonClose.addEventListener(CLICK, this._handleClickToggle); this._modalOverlay.addEventListener(CLICK, this._handleClickOverlay); if (this._slotForButton) { this._slotForButton.addEventListener(CLICK, this._handleClickToggle); this._slotForButton.addEventListener(KEYDOWN, this._handleClickToggle); } if (this._slotForModal) { this._slotForModal.addEventListener(CLICK, this._handleClickToggle); this._slotForModal.addEventListener(KEYDOWN, this._handleClickToggle); }}// =====================// Trợ giúp: xóa sự kiện.// ======================_removeEvents() { document.removeEventListener(FOCUSIN, this._handleFocusIn); document.removeEventListener(KEYDOWN, this._handleKeyDown); nút_nút_đóng.removeEventListener(NHẤP, nút_xử lý_ClickToggle); nút_modalOverlay.removeEventListener(NHẤP, nút_xử lý_ClickOverlay); nếu (nút_xử lý_slotForButton) { nút_xử lý_EventListener(NHẤP, nút_xử lý_ClickToggle); nút_xử lý_EventListener(KEYDOWN, nút_xử lý_ClickToggle); } nếu (nút_xử lý_Modal) { nút_xử lý_EventListener(NHẤP, nút_xử lý_ClickToggle); nút_xử lý_EventListener(KEYDOWN, nút_xử lý_ClickToggle); }}

Phát hiện các thay đổi thuộc tính​

Các hàm này xử lý việc đọc các thuộc tính từ thẻ và thiết lập nhiều cờ khác nhau như sau:
  • Thiết lập boolean _isAnimated trên phiên bản lớp của chúng ta.
  • Thiết lập các thuộc tính titlearia-label trên nút đóng của chúng ta.
  • Thiết lập aria-label cho hộp thoại modal của chúng ta, dựa trên văn bản tiêu đề.
  • Thiết lập boolean _isActive trên phiên bản lớp của chúng ta.
  • Thiết lập boolean _isStatic trên phiên bản lớp của chúng ta.
Bạn có thể tự hỏi tại sao chúng ta sử dụng aria-label để liên kết modal với văn bản tiêu đề của nó (nếu có). Tại thời điểm viết bài này, trình duyệt hiện không thể liên kết thuộc tính aria-labelledby="…" — trong DOM shadow — với id="…" nằm trong DOM chuẩn (hay còn gọi là "light").

Tôi sẽ không đi sâu vào chi tiết về điều đó, nhưng bạn có thể đọc thêm tại đây:
Mã:
// ==========================// Trợ giúp: đặt cờ hoạt ảnh.// ============================_setAnimationFlag() { this._isAnimated = this.getAttribute(ANIMATED) !== FALSE;}// =======================// Trợ giúp: thêm văn bản đóng.// ======================_setCloseTitle() { // Lấy tiêu đề. const title = this.getAttribute(CLOSE) || CLOSE_TITLE; // Đặt tiêu đề. this._buttonClose.title = title; this._buttonClose.setAttribute(ARIA_LABEL, title);}// ======================// Trợ giúp: thêm nhãn modal.// ========================_setModalLabel() { // Đặt sau. let label = MODAL_LABEL_FALLBACK; // Tiêu đề có tồn tại không? if (this._heading) { // Lấy văn bản. label = this._heading.textContent || label; label = label.trim().replace(SPACE_REGEX, SPACE); } // Đặt nhãn. this._modal.setAttribute(ARIA_LABEL, label);}// ======================// Trợ giúp: đặt cờ đang hoạt động.// =======================_setActiveFlag() { // Lấy cờ. const isActive = this.getAttribute(ACTIVE) === TRUE; // Đặt cờ. this._isActive = isActive; // Đặt hiển thị. this._toggleModalDisplay(() => { // Focus modal? if (this._isActive) { this._focusModal(); } });}// ======================// Trợ giúp: đặt cờ tĩnh.// =======================_setStaticFlag() { this._isStatic = this.getAttribute(STATIC) === TRUE;}

Tập trung vào các phần tử cụ thể​

Hàm _focusElement cho phép chúng ta tập trung vào một phần tử có thể đã hoạt động trước khi một modal trở nên hoạt động. Trong khi đó, hàm _focusModal sẽ đặt tiêu điểm vào chính hộp thoại modal và sẽ đảm bảo rằng phông nền modal được cuộn lên trên cùng.
Mã:
// ======================// Trình trợ giúp: phần tử focus.// =====================_focusElement(element: HTMLElement) { window.requestAnimationFrame(() => { if (typeof element.focus === FUNCTION) { element.focus(); } });}// ===================// Trình trợ giúp: phần tử focus.// ====================_focusModal() { window.requestAnimationFrame(() => { this._modal.focus(); this._modalScroll.scrollTo(0, 0); });}

Phát hiện Modal "Ngoài"​

Hàm này rất hữu ích để biết liệu một phần tử có nằm ngoài thẻ cha hay không. Hàm này trả về giá trị boolean, chúng ta có thể sử dụng giá trị này để thực hiện hành động thích hợp. Cụ thể là, điều hướng bẫy tab bên trong modal khi modal đang hoạt động.
Mã:
// ============================// Trợ giúp: phát hiện modal ngoài.// ==============================_isOutsideModal(element?: HTMLElement) { // Thoát sớm. if (!this._isActive || !element) { return false; } // Có phần tử? const hasElement = this.contains(element) || this._modal.contains(element); // Lấy boolean. const bool = !hasElement; // Hiển thị boolean. return bool;}

Phát hiện sở thích chuyển động​

Tại đây, chúng tôi sử dụng lại biến từ trước (cũng được sử dụng trong CSS của chúng tôi) để phát hiện xem người dùng có đồng ý với chuyển động hay không. Tức là họ không thiết lập rõ ràng prefers-reduced-motion thành reduce thông qua tùy chọn hệ điều hành của họ.

Boolean trả về là sự kết hợp của kiểm tra đó, cộng với cờ animated="false" không được đặt trên .
Mã:
// ==========================// Trợ giúp: phát hiện chuyển động pref.// ===========================_isMotionOkay() { // Nhận pref. const { matches } = window.matchMedia(PREFERS_REDUCED_MOTION); // Hiển thị boolean. return this._isAnimated && !matches;}

Chuyển đổi Ẩn/Hiện modal​

Có khá nhiều thứ diễn ra trong hàm này, nhưng về bản chất, nó khá đơn giản.
  • Nếu modal không hoạt động, hãy hiển thị nó. Nếu được phép hoạt ảnh, hãy làm cho nó hoạt động tại chỗ.
  • Nếu modal đang hoạt động, hãy ẩn nó. Nếu được phép hoạt ảnh, hãy làm cho nó biến mất.
Chúng tôi cũng lưu vào bộ nhớ đệm phần tử hiện đang hoạt động, do đó khi modal đóng lại, chúng tôi có thể khôi phục tiêu điểm.

Các biến được sử dụng trong CSS trước đó của chúng tôi cũng được sử dụng ở đây:
  • ANIMATION_DURATION,
  • DATA_SHOW,
  • DATA_HIDE.
Mã:
// ====================// Trợ lý: chuyển đổi chế độ xem modal.// =====================_toggleModalDisplay(callback: () => void) { // @ts-expect-error boolean this.setAttribute(ACTIVE, this._isActive); // Lấy giá trị boolean. const isModalVisible = this._modalScroll.style.display === BLOCK; const isMotionOkay = this._isMotionOkay(); // Lấy độ trễ. const delay = isMotionOkay ? ANIMATION_DURATION : 0; // Lấy chiều rộng thanh cuộn. const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth; // Lấy phần tử đang hoạt động. const activeElement = document.activeElement dưới dạng HTMLElement; // Bộ nhớ đệm phần tử đang hoạt động? if (this._isActive && activeElement) { this._activeElement = activeElement; } // ============= // Modal active? // ============= if (this._isActive) { // Hiển thị modal. this._modalScroll.style.display = BLOCK; // Ẩn thanh cuộn. document.documentElement.style.overflow = HIDDEN; // Thêm chỗ giữ chỗ? if (scrollbarWidth) { document.documentElement.style.paddingRight = `${scrollbarWidth}px`; } // Đặt cờ. if (isMotionOkay) { this._isHideShow = true; this._modalScroll.setAttribute(DATA_SHOW, TRUE); } // Kích hoạt lệnh gọi lại.callback(); // Chờ hoạt ảnh CSS. this._timerForShow = window.setTimeout(() => { // Clear.clearTimeout(this._timerForShow); // Xóa cờ. this._isHideShow = false; this._modalScroll.removeAttribute(DATA_SHOW); // Delay. }, delay); /* ===== LƯU Ý: ===== Chúng ta muốn đảm bảo rằng modal hiện đang hiển thị vì chúng ta không muốn đưa scroll trở lại phần tử `` một cách không cần thiết. Lý do là một `` khác trong trang có thể đã được kết xuất trước với thuộc tính [active="true"] . Nếu vậy, chúng ta muốn giữ nguyên giá trị tràn của trang. */ } else if (isModalVisible) { // Đặt cờ. if (isMotionOkay) { this._isHideShow = true; this._modalScroll.setAttribute(DATA_HIDE, TRUE); } // Kích hoạt lệnh gọi lại? callback(); // Chờ hoạt ảnh CSS. this._timerForHide = window.setTimeout(() => { // Xóa. clearTimeout(this._timerForHide); // Xóa cờ. this._isHideShow = false; this._modalScroll.removeAttribute(DATA_HIDE); // Ẩn modal. this._modalScroll.style.display = NONE; // Hiển thị thanh cuộn. document.documentElement.style.overflow = EMPTY_STRING; // Xóa chỗ giữ chỗ. document.documentElement.style.paddingRight = EMPTY_STRING; // Trì hoãn. }, trì hoãn); }}

Xử lý sự kiện: Click Overlay​

Khi nhấp vào lớp phủ bán trong suốt, giả sử static="true" không được đặt trên thẻ , chúng tôi sẽ đóng hộp thoại.
Mã:
// =====================// Sự kiện: nhấp vào lớp phủ.// =====================_handleClickOverlay(event: MouseEvent) { // Thoát sớm. if (this._isHideShow || this._isStatic) { return; } // Lấy lớp. const target = event.target dưới dạng HTMLElement; // Bên ngoài modal? if (target.classList.contains('cta-modal__overlay')) { this._handleClickToggle(); }}

Xử lý sự kiện: Nhấp vào Chuyển đổi​

Hàm này sử dụng ủy quyền sự kiện trên các phần tử . Bất cứ khi nào một phần tử con có lớp cta-modal-toggle được kích hoạt, nó sẽ khiến trạng thái hoạt động của modal thay đổi.

Điều này bao gồm việc lắng nghe các sự kiện khác nhau được coi là kích hoạt một nút:
  • nhấp chuột,
  • nhấn phím enter,
  • nhấn phím phím cách.
Mã:
// ===================// Sự kiện: chuyển đổi modal.// ====================_handleClickToggle(event?: MouseEvent | KeyboardEvent) { // Đặt sau. let key = EMPTY_STRING; let target = null; // Sự kiện có tồn tại không? if (event) { if (event.target) { target = event.target as HTMLElement; } // Lấy key. if ((event as KeyboardEvent).key) { key = (event as KeyboardEvent).key; key = key.toLowerCase(); } } // Đặt sau. let button; // Target có tồn tại không? if (target) { // Nhấp trực tiếp. if (target.classList.contains('cta-modal__close')) { button = target as HTMLButtonElement; // Nhấp được ủy quyền. } else if (typeof target.closest === FUNCTION) { button = target.closest('.cta-modal-toggle') as HTMLButtonElement; } } // Lấy boolean. const isValidEvent = event && typeof event.preventDefault === FUNCTION; const isValidClick = button && isValidEvent && !key; const isValidKey = button && isValidEvent && [ENTER, SPACE].includes(key); const isButtonDisabled = button && button.disabled; const isButtonMissing = isValidEvent && !button; const isWrongKeyEvent = key && !isValidKey; // Thoát sớm. if (isButtonDisabled || isButtonMissing || isWrongKeyEvent) { return; } // Ngăn chặn mặc định? if (isValidKey || isValidClick) { event.preventDefault(); } // Đặt cờ. this._isActive = !this._isActive; // Đặt hiển thị. this._toggleModalDisplay(() => { // Focus modal? if (this._isActive) { this._focusModal(); // Trả về focus? } else if (this._activeElement) { this._focusElement(this._activeElement); } });}

Xử lý sự kiện: Focus Element​

Hàm này được kích hoạt bất cứ khi nào một phần tử nhận được focus trên trang. Tùy thuộc vào trạng thái của modal và phần tử nào được focus, chúng ta có thể bẫy điều hướng tab trong hộp thoại modal. Đây là nơi FOCUSABLE_SELECTORS của chúng ta từ đầu phát huy tác dụng.
Mã:
// =========================// Sự kiện: tiêu điểm trong tài liệu.// ========================_handleFocusIn() { // Thoát sớm. if (!this._isActive) { return; } // prettier-ignore const activeElement = ( // Lấy phần tử đang hoạt động. this._shadow.activeElement || document.activeElement ) dưới dạng HTMLElement; // Lấy giá trị boolean. const isFocusTrap1 = activeElement === this._focusTrapList[0]; const isFocusTrap2 = activeElement === this._focusTrapList[1]; // Đặt sau. let focusListReal: HTMLElement[] = []; // Slot có tồn tại không? if (this._slotForModal) { // Lấy các phần tử "thực". focusListReal = Array.from( this._slotForModal.querySelectorAll(FOCUSABLE_SELECTORS) ) as HTMLElement[]; } // Lấy các phần tử "bóng". const focusListShadow = Array.from( this._modal.querySelectorAll(FOCUSABLE_SELECTORS) ) as HTMLElement[]; // Lấy các phần tử "tổng số". const focusListTotal = focusListShadow.concat(focusListReal); // Lấy các mục đầu tiên và cuối cùng. const focusItemFirst = focusListTotal[0]; const focusItemLast = focusListTotal[focusListTotal.length - 1]; // Bẫy tiêu điểm: ở trên? if (isFocusTrap1 && focusItemLast) { this._focusElement(focusItemLast); // Bẫy tiêu điểm: ở dưới? } else if (isFocusTrap2 && focusItemFirst) { this._focusElement(focusItemFirst); // Outside modal? } else if (this._isOutsideModal(activeElement)) { this._focusModal(); }}

Xử lý sự kiện: Bàn phím​

Nếu modal đang hoạt động khi nhấn phím escape, modal đó sẽ đóng lại. Nếu nhấn phím tab, chúng tôi sẽ đánh giá xem có cần điều chỉnh phần tử nào đang được lấy nét hay không.
Mã:
// =================// Sự kiện: nhấn phím.// =================_handleKeyDown({ key }: KeyboardEvent) { // Thoát sớm. if (!this._isActive) { return; } // Lấy key. key = key.toLowerCase(); // Phím thoát? if (key === ESCAPE && !this._isHideShow && !this._isStatic) { this._handleClickToggle(); } // Phím Tab? if (key === TAB) { this._handleFocusIn(); }}

DOM Loaded Callback​

Trình lắng nghe sự kiện này yêu cầu cửa sổ đợi DOM (trang HTML) được tải, sau đó phân tích cú pháp cho bất kỳ trường hợp nào của và đính kèm tính tương tác JS của chúng tôi vào đó. Về cơ bản, chúng tôi đã tạo một thẻ HTML mới và bây giờ trình duyệt biết cách sử dụng thẻ đó.
Mã:
// ==============// Xác định phần tử.// ==============window.addEventListener('DOMContentLoaded', () => { window.customElements.define('cta-modal', CtaModal);});

Tối ưu hóa thời gian xây dựng​

Tôi sẽ không đi sâu vào khía cạnh này, nhưng tôi nghĩ rằng nó đáng để nêu ra.

Sau khi biên dịch từ TypeScript sang JavaScript, tôi chạy Terser so với đầu ra JS. Tất cả các hàm đã đề cập ở trên bắt đầu bằng dấu gạch dưới (_) đều được đánh dấu là an toàn để làm biến dạng. Nghĩa là, chúng chuyển từ tên _bind_addEvents thành các chữ cái đơn lẻ.

Bước đó làm giảm đáng kể kích thước tệp. Sau đó, tôi chạy đầu ra đã thu nhỏ thông qua quy trình minifyWebComponent.js mà tôi đã tạo, quy trình này nén nhúng và đánh dấu thậm chí còn hơn nữa.

Ví dụ, tên lớp và các thuộc tính khác (và bộ chọn) được thu nhỏ. Điều này xảy ra trong CSS và HTML.
  • class='cta-modal__overlay' trở thành class=o. Các dấu ngoặc kép cũng bị xóa vì về mặt kỹ thuật, trình duyệt không cần chúng để hiểu ý định.
  • Bộ chọn CSS duy nhất không bị thay đổi là [tabindex="0"], vì việc xóa các dấu ngoặc kép quanh 0 dường như khiến nó không hợp lệ khi được phân tích cú pháp bởi querySelectorAll. Tuy nhiên, an toàn khi thu nhỏ trong HTML từ tabindex='0' thành tabindex=0.
Khi tất cả đã được nói và thực hiện, kích thước tệp giảm trông như thế này (tính bằng byte):
  • chưa thu nhỏ: 16.849,
  • thu nhỏ terser: 10.230,
  • và tập lệnh của tôi: 7.689.
Để hiểu rõ hơn, tệp favicon.ico trên Smashing Magazine là 4.286 byte. Vì vậy, chúng ta thực sự không thêm nhiều chi phí, cho nhiều chức năng chỉ yêu cầu viết HTML để sử dụng.

Kết luận​

Nếu bạn đã đọc đến đây, cảm ơn vì đã theo dõi tôi. Tôi hy vọng rằng tôi đã ít nhất khơi dậy sự quan tâm của bạn đối với Web Components!

Tôi biết chúng ta đã đề cập khá nhiều, nhưng tin tốt là: Đó là tất cả những gì cần làm. Không có khuôn khổ nào để học trừ khi bạn muốn. Thực tế là bạn có thể bắt đầu viết Web Components của riêng mình bằng JS thuần túy mà không cần quy trình xây dựng.

Thực sự chưa bao giờ có thời điểm nào tốt hơn để #UseThePlatform. Tôi mong muốn được thấy những gì bạn tưởng tượng.

Đọc thêm​

Tôi sẽ là người thiếu sót nếu không đề cập rằng có vô số các tùy chọn modal khác ngoài kia.

Mặc dù tôi thiên vị và cảm thấy cách tiếp cận của mình mang lại điều gì đó độc đáo — nếu không, tôi đã không cố gắng "phát minh lại bánh xe" — bạn có thể thấy rằng một trong những cách này sẽ phù hợp hơn với nhu cầu của bạn.

Các ví dụ sau đây khác với CTA Modal ở chỗ tất cả chúng đều yêu cầu ít nhất một số JavaScript bổ sung do nhà phát triển người dùng cuối viết. Trong khi với CTA Modal, tất cả những gì bạn phải viết là mã HTML.

HTML phẳng & JS:
Thành phần web:
jQuery:
React:
Vue:
 
Back
Bên trên