Tworzenie komponentu dialogu

Podstawowe informacje o tym, jak tworzyć dostosowujące się do kolorów, responsywne i dostępne małe i duże okna modalne za pomocą elementu <dialog>.

W tym poście chcę podzielić się moimi przemyśleniami na temat tworzenia dostosowujących się do kolorów, responsywnych i dostępnych mini- i mega-modalnych okien za pomocą elementu <dialog>. Wypróbuj wersję demonstracyjnąwyświetl kod źródłowy.

Prezentacja dużych i małych okien w motywach jasnym i ciemnym.

Jeśli wolisz film, obejrzyj tę wersję posta w YouTube:

Przegląd

Element <dialog> doskonale nadaje się do wyświetlania informacji kontekstowych lub działań na stronie. Zastanów się, kiedy użytkownik może skorzystać z działania na tej samej stronie zamiast działania na wielu stronach, np. gdy formularz jest mały lub jedynym działaniem wymaganym od użytkownika jest potwierdzenie lub anulowanie.

Element <dialog> stał się ostatnio stabilny w różnych przeglądarkach:

Browser Support

  • Chrome: 37.
  • Edge: 79.
  • Firefox: 98.
  • Safari: 15.4.

Source

Okazało się, że w tym elemencie brakuje kilku rzeczy, więc w tym wyzwaniu dotyczącym interfejsu GUI dodaję oczekiwane elementy związane z wrażeniami programisty: dodatkowe zdarzenia, zamykanie przez kliknięcie poza elementem, niestandardowe animacje oraz typy mini i mega.

Znacznik

Podstawowe elementy <dialog> są skromne. Element zostanie automatycznie ukryty i ma wbudowane style, które umożliwiają nakładanie treści.

<dialog>
  …
</dialog>

Możemy ulepszyć ten punkt odniesienia.

Element dialogu jest podobny do okna modalnego i często nazwy te są używane zamiennie. Użyłem tutaj elementu dialogu zarówno w przypadku małych okien dialogowych (mini), jak i okien dialogowych na całą stronę (mega). Nazwałem je mega i mini, a oba okna dialogowe zostały nieco dostosowane do różnych zastosowań. Dodałem atrybut modal-mode, aby umożliwić Ci określenie typu:

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

Zrzut ekranu przedstawiający zarówno małe, jak i duże okna w motywie jasnym i ciemnym.

Nie zawsze, ale zwykle elementy okna dialogowego służą do zbierania informacji o interakcjach. Formularze w elementach okna dialogowego są ze sobą powiązane. Warto umieścić zawartość okna w elemencie formularza, aby JavaScript mógł uzyskać dostęp do danych wprowadzonych przez użytkownika. Ponadto przyciski w formularzu korzystającym z method="dialog" mogą zamykać okno bez użycia JavaScriptu i przekazywać dane.

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

Okno Mega

Megaokno dialogowe zawiera 3 elementy w formularzu: <header>, <article><footer>. Służą one jako kontenery semantyczne, a także jako cele stylu do prezentacji okna. Nagłówek zawiera tytuł okna modalnego i przycisk zamykania. Artykuł dotyczy danych wejściowych i informacji w formularzu. W stopce znajduje się <menu> przycisków polecenia.

<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>

Pierwszy przycisk menu ma autofocusonclick wbudowany moduł obsługi zdarzeń. Atrybut autofocus zostanie zaznaczony po otwarciu okna, a najlepszym rozwiązaniem jest umieszczenie go na przycisku anulowania, a nie na przycisku potwierdzenia. Dzięki temu potwierdzenie jest celowe, a nie przypadkowe.

Miniokno

Miniokno jest bardzo podobne do megaokna, brakuje w nim tylko elementu <header>. Dzięki temu jest mniejszy i bardziej dopasowany.

<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>

Element dialogu stanowi solidną podstawę dla elementu zajmującego cały obszar widoczny, który może zbierać dane i informacje o interakcjach użytkownika. Te podstawowe elementy mogą zapewnić bardzo ciekawe i skuteczne interakcje w Twojej witrynie lub aplikacji.

Ułatwienia dostępu

Element okna ma bardzo dobre wbudowane ułatwienia dostępu. Zamiast dodawać te funkcje, jak zwykle to robię, wiele z nich jest już dostępnych.

Przywracanie ostrości

Podobnie jak w przypadku ręcznego tworzenia komponentu paska bocznego, ważne jest, aby otwieranie i zamykanie elementu powodowało przeniesienie fokusu na odpowiednie przyciski otwierania i zamykania. Po otwarciu panelu bocznym fokus jest ustawiany na przycisku zamykania. Po naciśnięciu przycisku zamykania fokus wraca do przycisku, który otworzył okno.

W przypadku elementu okna jest to wbudowane zachowanie domyślne:

Jeśli chcesz animować pojawianie się i znikanie okna, ta funkcja nie będzie działać. W sekcji JavaScript przywrócę tę funkcję.

Ostrość z wyprzedzeniem

Element okna dialogowego zarządza inert w dokumencie. Przed wprowadzeniem inert język JavaScript był używany do śledzenia, czy fokus opuszcza element. W takim przypadku kod przechwytywał fokus i przywracał go.

Browser Support

  • Chrome: 102.
  • Edge: 102.
  • Firefox: 112.
  • Safari: 15.5.

Source

Po inert dowolne części dokumentu mogą zostać „zamrożone”, co oznacza, że nie będą już celem zaznaczenia ani nie będą interaktywne w przypadku użycia myszy. Zamiast blokować fokus, jest on kierowany do jedynej interaktywnej części dokumentu.

Otwieranie elementu i automatyczne ustawianie na nim ostrości

Domyślnie element okna dialogowego przypisuje fokus do pierwszego elementu, który można zaznaczyć, w znacznikach okna dialogowego. Jeśli nie jest to najlepszy element domyślny dla użytkownika, użyj atrybutu autofocus. Jak już wspominałem, zalecam umieszczenie tego komunikatu na przycisku anulowania, a nie na przycisku potwierdzenia. Dzięki temu potwierdzenie jest świadome, a nie przypadkowe.

Zamykanie za pomocą klawisza Escape

Ważne jest, aby można było łatwo zamknąć ten potencjalnie przeszkadzający element. Na szczęście element okna dialogowego obsłuży klawisz Escape, zwalniając Cię z obowiązku koordynacji.

Style

Istnieje łatwy i trudny sposób na stylowanie elementu okna dialogowego. Łatwiejsze rozwiązanie polega na tym, że nie zmieniasz właściwości wyświetlania okna i akceptujesz jego ograniczenia. Wybieram trudniejszą ścieżkę, aby zapewnić niestandardowe animacje otwierania i zamykania okna, przejmując kontrolę nad właściwością display i nie tylko.

Stylizowanie za pomocą Open Props

Aby przyspieszyć dostosowywanie kolorów i zapewnić spójność projektu, bez skrupułów wykorzystałem bibliotekę zmiennych CSS Open Props. Oprócz bezpłatnych zmiennych importuję też plik normalize i kilka przycisków, które Open Props udostępnia jako opcjonalne importy. Dzięki tym importom mogę skupić się na dostosowywaniu okna i wersji demonstracyjnej, nie potrzebując wielu stylów, aby je obsługiwać i zapewnić im dobry wygląd.

Stylizowanie elementu <dialog>

Własność miejsca docelowego

Domyślne zachowanie elementu okna dialogowego polegające na wyświetlaniu i ukrywaniu przełącza właściwość display z block na none. Oznacza to, że nie można animować jej pojawiania się i znikania, a tylko pojawiania się. Chcę animować zarówno pojawianie się, jak i znikanie elementu, a pierwszym krokiem jest ustawienie własnej właściwości display:

dialog {
  display: grid;
}

Zmiana wartości właściwości display, a tym samym jej przejęcie, jak pokazano w powyższym fragmencie kodu CSS, wymaga zarządzania znaczną liczbą stylów, aby zapewnić użytkownikom odpowiednią wygodę. Po pierwsze, domyślny stan okna to zamknięcie. Ten stan możesz przedstawić wizualnie i zapobiec interakcjom z oknem za pomocą tych stylów:

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

Teraz okno jest niewidoczne i nie można z niego korzystać, gdy jest zamknięte. Później dodam kod JavaScript, aby zarządzać atrybutem inert w oknie, dzięki czemu użytkownicy klawiatury i czytników ekranu również nie będą mogli dotrzeć do ukrytego okna.

Nadawanie okienku adaptacyjnego motywu kolorystycznego

Duże okno pokazujące motyw jasny i ciemny z kolorami powierzchni.

Chociaż color-scheme włącza w dokumencie adaptacyjny motyw kolorystyczny dostarczany przez przeglądarkę, który dostosowuje się do jasnych i ciemnych ustawień systemu, chciałem bardziej dostosować element okna. Open Props udostępnia kilka kolorów powierzchni, które automatycznie dostosowują się do preferencji systemu dotyczących trybu jasnego i ciemnego, podobnie jak w przypadku używania color-scheme. Świetnie nadają się do tworzenia warstw w projekcie. Uwielbiam używać kolorów, aby wizualnie wspierać wygląd powierzchni warstw. Kolor tła to var(--surface-1). Aby umieścić element na tej warstwie, użyj 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);
  }
}

Później dodamy więcej kolorów adaptacyjnych dla elementów podrzędnych, takich jak nagłówek i stopka. Uważam je za dodatkowe w przypadku elementu okna, ale są one bardzo ważne w tworzeniu atrakcyjnego i dobrze zaprojektowanego okna.

Elastyczne rozmiary okien

Domyślnie okno dialogowe przekazuje swój rozmiar do treści, co jest zwykle bardzo przydatne. Moim celem jest ograniczenie max-inline-size do czytelnego rozmiaru (--size-content-3 = 60ch) lub 90% szerokości widocznego obszaru. Dzięki temu okno nie będzie zajmować całego ekranu urządzenia mobilnego ani nie będzie tak szerokie na ekranie komputera, że trudno będzie je przeczytać. Następnie dodaję max-block-size tak, aby okno nie przekraczało wysokości strony. Oznacza to również, że musimy określić, gdzie znajduje się obszar przewijania okna, jeśli jest to wysoki element okna.

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

Zwróć uwagę, że max-block-size występuje 2 razy. Pierwszy z nich używa jednostki 80vh, czyli fizycznej jednostki widocznego obszaru. Chcę, aby okno dialogowe było umieszczone w przepływie względnym, dlatego w przypadku użytkowników z innych krajów używam w drugiej deklaracji jednostki dvb, która jest nowsza, logiczna i tylko częściowo obsługiwana, ale w przyszłości będzie bardziej stabilna.

Pozycjonowanie megaokna

Aby ułatwić pozycjonowanie elementu okna, warto podzielić go na 2 części: tło pełnoekranowe i kontener okna. Tło musi zakrywać wszystko, zapewniając efekt cienia, który potwierdza, że okno dialogowe jest na pierwszym planie, a treści znajdujące się za nim są niedostępne. Kontener okna dialogowego może być wyśrodkowany na tym tle i przyjmować dowolny kształt wymagany przez jego zawartość.

Poniższe style przytwierdzają element okna do okna, rozciągając go do każdego rogu, i używają margin: auto do wyśrodkowania treści:

dialog {
  
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
Style dużych okien na urządzeniach mobilnych

Na małych obszarach wyświetlania stylizuję ten pełnoekranowy mega modal nieco inaczej. Ustawiam dolny margines na 0, co powoduje, że zawartość okna dialogowego jest wyświetlana u dołu obszaru widocznego na ekranie. Wystarczy kilka zmian stylu, aby przekształcić okno w arkusz działań, który będzie bliżej kciuków użytkownika:

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

Zrzut ekranu przedstawiający narzędzia deweloperskie z nałożonymi odstępami marginesów w otwartym dużym oknie dialogowym na komputerze i urządzeniu mobilnym.

Pozycjonowanie minidialogu

W przypadku większego obszaru wyświetlania, np. na komputerze stacjonarnym, zdecydowałem się umieścić miniokna nad elementem, który je wywołał. Aby to zrobić, potrzebuję JavaScriptu. Stosowaną przeze mnie technikę znajdziesz tutaj, ale uważam, że wykracza ona poza zakres tego artykułu. Bez JavaScriptu małe okno dialogowe pojawi się na środku ekranu, tak samo jak duże okno dialogowe.

Wyróżnij się

Na koniec dodaj do okna trochę stylu, aby wyglądało jak miękka powierzchnia znajdująca się daleko nad stroną. Miękkość uzyskuje się przez zaokrąglenie rogów okna. Głębię uzyskuje się za pomocą jednego ze starannie opracowanych właściwości cienia Open Props:

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

Dostosowywanie pseudoelementu tła

Tło zostało przeze mnie potraktowane bardzo delikatnie. Dodałem tylko efekt rozmycia za pomocą funkcji backdrop-filter w przypadku megaokna:

Browser Support

  • Chrome: 76.
  • Edge: 79.
  • Firefox: 103.
  • Safari: 18.

Source

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

Dodałem też przejście do elementu backdrop-filter, licząc na to, że w przyszłości przeglądarki będą obsługiwać przejścia w przypadku elementu tła:

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

Zrzut ekranu z dużym oknem dialogowym na rozmytym tle z kolorowymi awatarami.

Dodatki do stylizacji

Tę sekcję nazywam „dodatkami”, ponieważ bardziej dotyczy ona wersji demonstracyjnej elementu okna dialogowego niż samego elementu okna dialogowego.

Ograniczenie przewijania

Gdy okno dialogowe jest widoczne, użytkownik nadal może przewijać stronę za nim, co nie jest pożądane:

Zwykle używam rozwiązania overscroll-behavior, ale zgodnie ze specyfikacją nie ma ono wpływu na okno, ponieważ nie jest ono portem przewijania, czyli nie jest przewijane, więc nie ma czego blokować. Mogę użyć JavaScriptu, aby śledzić nowe zdarzenia z tego przewodnika, takie jak „closed” i „opened”, i przełączać overflow: hidden w dokumencie lub poczekać, aż :has() będzie stabilny we wszystkich przeglądarkach:

Browser Support

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 15.4.

Source

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

Gdy otwarte jest duże okno, dokument HTML zawiera teraz element overflow: hidden.

Układ <form>

Oprócz tego, że jest to bardzo ważny element do zbierania informacji o interakcjach użytkownika, używam go tutaj do rozmieszczania elementów nagłówka, stopki i artykułu. W tym układzie chcę przedstawić element podrzędny artykułu jako obszar z możliwością przewijania. Osiągam to za pomocą grid-template-rows. Element artykułu ma wartość 1fr, a sam formularz ma taką samą maksymalną wysokość jak element okna. Ustawienie stałej wysokości i stałego rozmiaru wiersza umożliwia ograniczenie elementu artykułu i przewijanie go, gdy jego zawartość się nie mieści:

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

Zrzut ekranu narzędzi deweloperskich z informacjami o układzie siatki na wierszach.

Stylizowanie okna dialogowego<header>

Jego zadaniem jest nadanie tytułu treściom okna i zapewnienie łatwego do znalezienia przycisku zamykania. Ma też kolor powierzchni, dzięki czemu wydaje się znajdować za treścią artykułu w oknie. Te wymagania prowadzą do utworzenia kontenera flexbox, elementów wyrównanych pionowo, które są rozmieszczone na krawędziach, oraz do dodania dopełnienia i odstępów, aby zapewnić tytułowi i przyciskom zamykania trochę miejsca:

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);
  }
}

Zrzut ekranu przedstawiający Narzędzia deweloperskie w Chrome z informacjami o układzie flexbox na nagłówku okna.

Stylizowanie przycisku zamykania nagłówka

W wersji demonstracyjnej używane są przyciski Open Props, więc przycisk zamknięcia jest dostosowany do okrągłej ikony umieszczonej na środku, jak w tym przykładzie:

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;
}

Zrzut ekranu Narzędzi deweloperskich w Chrome z informacjami o rozmiarze i wypełnieniu przycisku zamykania nagłówka.

Stylizowanie okna dialogowego<article>

Element artykułu odgrywa w tym oknie specjalną rolę: jest to przestrzeń, którą można przewijać w przypadku wysokiego lub długiego okna.

Aby to osiągnąć, element formularza nadrzędnego ma określone maksymalne wartości, które ograniczają wysokość elementu artykułu. Ustaw overflow-y: auto, aby paski przewijania wyświetlały się tylko w razie potrzeby, ogranicz przewijanie do tego elementu za pomocą overscroll-behavior: contain, a resztę dostosuj za pomocą niestandardowych stylów prezentacji:

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);
  }
}

Stopka zawiera menu przycisków poleceń. Flexbox służy do wyrównania treści do końca osi w wierszu stopki, a następnie do dodania odstępów, aby przyciski miały trochę miejsca.

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);
  }
}

Zrzut ekranu przedstawiający Narzędzia deweloperskie w Chrome z informacjami o układzie flexbox na elemencie stopki.

Element menu służy do umieszczania przycisków poleceń w oknie. Używa układu flexbox z zawijaniem i właściwości gap, aby zapewnić odstępy między przyciskami. Elementy menu mają dopełnienie, np. <ul>. Usuwam też ten styl, ponieważ nie jest mi potrzebny.

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;
}

Zrzut ekranu przedstawiający Narzędzia deweloperskie w Chrome z informacjami o flexboxie na elementach menu w stopce.

Animacja

Elementy okna dialogowego są często animowane, ponieważ pojawiają się w oknie i z niego znikają. Dodanie do okien dialogowych animacji podczas ich pojawiania się i zamykania ułatwia użytkownikom orientację w procesie.

Zwykle element okna dialogowego można animować tylko w przypadku pojawiania się, a nie znikania. Dzieje się tak, ponieważ przeglądarka przełącza właściwość display elementu. Wcześniej przewodnik ustawiał wyświetlanie na siatkę i nigdy nie ustawiał go na brak. Umożliwia to animowanie elementów podczas wchodzenia i wychodzenia.

Open Props zawiera wiele animacji klatek kluczowych, które ułatwiają i usprawniają tworzenie kompozycji. Oto cele animacji i zastosowane przeze mnie podejście warstwowe:

  1. Mniej animacji to domyślne przejście, proste zanikanie i pojawianie się.
  2. Jeśli ruch jest w porządku, dodawane są animacje przesuwania i skalowania.
  3. Elastyczny układ mobilny w przypadku dużego okna jest dostosowany do wysuwania.

Bezpieczne i sensowne domyślne przejście

Biblioteka Open Props zawiera klatki kluczowe do efektów pojawiania się i znikania, ale wolę to warstwowe podejście do przejść jako domyślne, z animacjami klatek kluczowych jako potencjalnymi ulepszeniami. Wcześniej stylizowaliśmy widoczność okna za pomocą właściwości opacity, która w zależności od atrybutu [open] przyjmowała wartość 1 lub 0. Aby przejść od 0% do 100%, określ, jak długo ma trwać przejście i jakiego rodzaju ma być wygładzanie:

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

Dodawanie ruchu do przejścia

Jeśli użytkownik nie ma nic przeciwko animacji, oba okna (duże i małe) powinny wjeżdżać od dołu i oddalać się podczas zamykania. Możesz to zrobić za pomocą prefers-reduced-motionzapytania o media i kilku właściwości Open Props:

@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;
  }
}

Dostosowywanie animacji zakończenia do urządzeń mobilnych

W sekcji dotyczącej stylów wspomnieliśmy, że styl mega dialogu jest dostosowany do urządzeń mobilnych, aby przypominał arkusz działań. Wygląda to tak, jakby mały kawałek papieru wysunął się z dołu ekranu i nadal był do niego przymocowany. Animacja wyjścia z efektem powiększenia nie pasuje do tego nowego projektu, ale możemy ją dostosować za pomocą kilku zapytań o media i niektórych właściwości Open Props:

@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

Za pomocą JavaScriptu możesz dodać wiele elementów:

// 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
}

Te dodatki wynikają z chęci wprowadzenia możliwości lekkiego zamykania (kliknięcie tła okna), animacji i dodatkowych zdarzeń, które pozwolą lepiej określać czas uzyskiwania danych z formularza.

Dodawanie lekkiego odrzucania

Jest to proste zadanie, które świetnie uzupełnia element okna, który nie jest animowany. Interakcja jest osiągana przez obserwowanie kliknięć elementu okna i wykorzystanie propagacji zdarzeń do oceny, co zostało kliknięte. Nastąpi to tylko wtedy, gdy będzie to element najwyższego poziomu:close()

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

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

Powiadomienie dialog.close('dismiss'). Zdarzenie jest wywoływane i podawany jest ciąg tekstowy. Ten ciąg znaków może być pobierany przez inne skrypty JavaScript, aby uzyskać informacje o tym, jak zostało zamknięte okno. Za każdym razem, gdy wywołuję funkcję z różnych przycisków, podaję też zbliżone ciągi znaków, aby zapewnić aplikacji kontekst interakcji użytkownika.

Dodawanie wydarzeń zamknięcia i zamkniętych

Element dialogu ma zdarzenie zamknięcia: jest ono emitowane natychmiast po wywołaniu funkcji close() dialogu. Ponieważ animujemy ten element, warto mieć zdarzenia przed i po animacji, aby można było pobrać dane lub zresetować formularz okna. Używam go tutaj do zarządzania dodawaniem atrybutu inert w zamkniętym oknie, a w wersji demonstracyjnej używam go do modyfikowania listy awatarów, jeśli użytkownik przesłał nowy obraz.

Aby to zrobić, utwórz 2 nowe zdarzenia o nazwach closingclosed. Następnie nasłuchuj wbudowanego zdarzenia zamknięcia w oknie. Ustaw tutaj okno na inert i wyślij zdarzenie closing. Następne zadanie polega na poczekaniu, aż animacje i przejścia w oknie dialogowym zostaną zakończone, a następnie na wysłaniu zdarzenia 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))

Funkcja animationsComplete, która jest też używana w tworzeniu komponentu powiadomienia, zwraca obietnicę na podstawie zakończenia animacji i obietnic przejścia. Dlatego dialogClose jest funkcją asynchroniczną. Może wtedy await zwróconą obietnicę i śmiało przejść do zamkniętego wydarzenia.

Dodawanie otwieranych i otwartych wydarzeń

Nie można ich łatwo dodać, ponieważ wbudowany element okna dialogowego nie udostępnia zdarzenia otwarcia, tak jak w przypadku zdarzenia zamknięcia. Używam interfejsu MutationObserver, aby dostarczać informacji o zmianach atrybutów okna. W tym obserwatorze będę śledzić zmiany atrybutu open i odpowiednio zarządzać zdarzeniami niestandardowymi.

Podobnie jak w przypadku zdarzeń zamykających i zamkniętych utwórz 2 nowe zdarzenia o nazwach openingopened. Zamiast nasłuchiwać zdarzenia zamknięcia okna, tym razem użyjemy utworzonego obserwatora zmian, aby śledzić atrybuty okna.


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)
    }
  })
})

Funkcja wywołania zwrotnego obserwatora zmian zostanie wywołana, gdy zmienią się atrybuty okna, i przekaże listę zmian w postaci tablicy. Iteruj po zmianach atrybutów, szukając otwartego elementu attributeName. Następnie sprawdź, czy element ma atrybut: dzięki temu dowiesz się, czy okno zostało otwarte. Jeśli okno zostało otwarte, usuń atrybut inert i ustaw fokus na element, który wysyła żądanie autofocus, lub na pierwszy element button znaleziony w oknie. Na koniec, podobnie jak w przypadku zdarzeń closing i closed, od razu wyślij zdarzenie opening, poczekaj na zakończenie animacji, a następnie wyślij zdarzenie opened.

Dodawanie usuniętego wydarzenia

W aplikacjach na jednej stronie okna dialogowe są często dodawane i usuwane w zależności od tras lub innych potrzeb i stanu aplikacji. Może to być przydatne do czyszczenia zdarzeń lub danych po usunięciu okna.

Możesz to osiągnąć za pomocą innego obserwatora zmian. Tym razem zamiast obserwować atrybuty elementu okna dialogowego będziemy obserwować elementy podrzędne elementu body i sprawdzać, czy elementy okna dialogowego nie są usuwane.


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)
      }
    })
  })
})

Funkcja zwrotna obserwatora zmian jest wywoływana za każdym razem, gdy do treści dokumentu dodawane są elementy podrzędne lub są z niej usuwane. Obserwowane mutacje dotyczą removedNodes, które mają nodeName okna. Jeśli okno zostało usunięte, zdarzenia kliknięcia i zamknięcia są usuwane, aby zwolnić pamięć, a wysyłane jest niestandardowe zdarzenie usunięcia.

Usuwanie atrybutu loading

Aby zapobiec odtwarzaniu animacji zamykania okna po dodaniu go do strony lub podczas ładowania strony, do okna dodano atrybut ładowania. Poniższy skrypt czeka na zakończenie animacji okna, a następnie usuwa atrybut. Teraz okno może być animowane podczas otwierania i zamykania, a my skutecznie ukryliśmy animację, która mogłaby rozpraszać uwagę.

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

Dowiedz się więcej o problemie z zapobieganiem animacjom klatek kluczowych podczas wczytywania strony.

Wszystkie razem

Oto pełna treść dialog.js po wyjaśnieniu poszczególnych sekcji:

// 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')
}

Korzystanie z modułu dialog.js

Wyeksportowana funkcja z modułu oczekuje wywołania i przekazania elementu okna, do którego mają zostać dodane te nowe zdarzenia i funkcje:

import GuiDialog from './dialog.js'

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

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

W ten sposób oba okna zostaną ulepszone o funkcję lekkiego zamykania, poprawki animacji wczytywania i więcej zdarzeń do wykorzystania.

Nasłuchiwanie nowych zdarzeń niestandardowych

Każdy ulepszony element okna może teraz nasłuchiwać 5 nowych zdarzeń, np. w ten sposób:

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

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

MegaDialog.addEventListener('removed', dialogRemoved)

Oto 2 przykłady obsługi tych zdarzeń:

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()
  }
}

W demonstracji, którą utworzyłem za pomocą elementu dialogu, używam zdarzenia zamknięcia i danych formularza, aby dodać do listy nowy element awatara. Jest to dobry moment, ponieważ okno dialogowe zakończyło animację wyjścia, a potem niektóre skrypty animują nowy awatar. Dzięki nowym zdarzeniom możesz łatwiej koordynować działania użytkowników.

Pole dialog.returnValue: zawiera ciąg zamykający przekazywany podczas wywoływania zdarzenia close() okna. W przypadku zdarzenia dialogClosed ważne jest, aby wiedzieć, czy okno zostało zamknięte, anulowane czy potwierdzone. Jeśli to się potwierdzi, skrypt pobierze wartości formularza i zresetuje go. Resetowanie jest przydatne, ponieważ gdy okno dialogowe zostanie wyświetlone ponownie, będzie puste i gotowe do przesłania nowego zgłoszenia.

Podsumowanie

Teraz, gdy wiesz, jak to zrobiłem, jak Ty byś to zrobił? 🙂

Urozmaićmy nasze podejście i poznajmy wszystkie sposoby tworzenia treści w internecie.

Utwórz demo, wyślij mi na Twitterze linki, a ja dodam je do sekcji remiksów społeczności poniżej.

Remiksy społeczności

Zasoby