대화상자 구성요소 빌드

<dialog> 요소를 사용하여 색상 적응성, 응답성, 접근성이 뛰어난 미니 및 메가 모달을 빌드하는 방법에 관한 기본적인 개요입니다.

이 게시물에서는 <dialog> 요소를 사용하여 색상 적응성, 반응성, 접근성이 뛰어난 미니 및 메가 모달을 빌드하는 방법에 관한 생각을 공유하고자 합니다. 데모를 사용해 보고 소스를 확인하세요.

밝은 테마와 어두운 테마의 메가 및 미니 대화상자 시연

동영상을 선호한다면 이 게시물의 YouTube 버전을 참조하세요.

개요

<dialog> 요소는 인페이지 문맥 정보 또는 작업에 적합합니다. 다중 페이지 작업 대신 동일한 페이지 작업이 사용자 환경에 도움이 될 수 있는 경우를 고려하세요. 양식이 작거나 사용자가 확인 또는 취소해야 하는 유일한 작업일 수 있습니다.

<dialog> 요소가 최근 여러 브라우저에서 안정화되었습니다.

브라우저 지원

  • 37
  • 79
  • 98
  • 15.4

소스

요소에 몇 가지 누락된 항목이 있으므로 이 GUI 챌린지에서는 추가 이벤트, 조명 닫기, 맞춤 애니메이션, 미니 및 메가 유형 등 예상되는 개발자 환경 항목을 추가합니다.

마크업

<dialog> 요소의 기본사항은 적당합니다. 이 요소는 자동으로 숨겨지고 콘텐츠를 오버레이하는 스타일이 내장되어 있습니다.

<dialog>
  …
</dialog>

이 기준을 개선할 수 있습니다.

일반적으로 대화상자 요소는 모달과 많이 공유되며 이름을 서로 바꿔서 사용할 수 있는 경우가 많습니다. 여기서는 작은 대화상자 팝업 (미니)과 전체 페이지 대화상자 (메가) 모두에 대화상자 요소를 자유롭게 사용했습니다. 메가와 미니라는 이름을 지정했으며 두 대화상자는 서로 다른 사용 사례에 맞게 약간 조정되었습니다. 유형을 지정할 수 있도록 modal-mode 속성을 추가했습니다.

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

밝은 테마와 어두운 테마의 미니 대화상자와 메가 대화상자 스크린샷

항상 그런 것은 아니지만 일반적으로 대화상자 요소를 사용하여 상호작용 정보를 수집합니다. 대화상자 요소 내부의 양식은 함께 이동할 수 있도록 제작됩니다. 양식 요소가 대화상자 콘텐츠를 래핑하도록 하여 JavaScript가 사용자가 입력한 데이터에 액세스할 수 있도록 하는 것이 좋습니다. 또한 method="dialog"를 사용하는 양식 내부의 버튼은 JavaScript 없이 대화상자를 닫고 데이터를 전달할 수 있습니다.

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

메가 대화상자

메가 대화상자의 형식에는 <header>, <article>, <footer>의 세 가지 요소가 있습니다. 이는 시맨틱 컨테이너뿐만 아니라 대화상자를 표현하기 위한 스타일 타겟의 역할을 합니다. 헤더는 모달의 제목을 지정하고 닫기 버튼을 제공합니다. 이 도움말에서는 양식 입력 및 정보에 대해 설명합니다. 바닥글에는 작업 버튼의 <menu>가 있습니다.

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

첫 번째 메뉴 버튼에는 autofocusonclick 인라인 이벤트 핸들러가 있습니다. autofocus 속성은 대화상자가 열릴 때 포커스를 수신합니다. 이 속성은 확인 버튼이 아닌 취소 버튼에 배치하는 것이 좋습니다. 이렇게 하면 우발적이 아닌 의도적임을 확인할 수 있습니다.

미니 대화상자

미니 대화상자는 메가 대화상자와 매우 유사하며 <header> 요소가 누락되어 있습니다. 이렇게 하면 더 작고 인라인으로 표시될 수 있습니다.

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

대화상자 요소는 데이터 및 사용자 상호작용을 수집할 수 있는 전체 표시 영역 요소를 위한 강력한 기반을 제공합니다. 이와 같은 필수 요소는 사이트나 앱에서 매우 흥미롭고 강력한 상호작용을 이끌어낼 수 있습니다.

접근성

대화상자 요소에는 기본으로 제공되는 매우 우수한 접근성이 있습니다. 평소와 같이 이러한 기능을 추가하는 대신 이미 많은 기능이 있습니다.

포커스 복원 중

Sidenav 구성요소 빌드에서 직접 했던 것처럼, 항목을 제대로 열고 닫을 때 관련 열기 및 닫기 버튼에 포커스가 맞춰지는 것이 중요합니다. 측면 탐색 메뉴가 열리면 포커스가 닫기 버튼에 배치됩니다. 닫기 버튼을 누르면 포커스가 열려있던 버튼으로 포커스가 복원됩니다.

대화상자 요소를 사용하는 경우 기본 동작입니다.

안타깝게도 대화상자를 안팎으로 애니메이션 처리하려고 하면 이 기능이 사라집니다. 자바스크립트 섹션에서 관련 기능을 복원해 드리겠습니다.

트래핑 포커스

대화상자 요소는 문서에서 inert를 관리합니다. inert 이전에는 자바스크립트가 요소에서 나가는 포커스를 감시하고 이 지점에 요소를 가로채서 다시 배치하는 것을 관찰했습니다.

브라우저 지원

  • 102
  • 102
  • 112
  • 15.5

소스

inert 이후에는 문서의 모든 부분이 더 이상 포커스 타겟이 아니거나 마우스와 상호작용하기 때문에 문서의 일부가 '고정'될 수 있습니다. 포커스를 가라앉히는 대신 문서에서 유일한 대화형 부분으로 포커스가 이동합니다.

요소 열기 및 자동 포커스

기본적으로 대화상자 요소는 대화상자 마크업에서 포커스 가능한 첫 번째 요소에 포커스를 할당합니다. 사용자가 기본값으로 사용하기에 최적의 요소가 아니라면 autofocus 속성을 사용하세요. 앞서 설명했듯이 확인 버튼이 아닌 취소 버튼에 배치하는 것이 좋습니다 이렇게 하면 우발적이 아닌 의도적인 확인이 이루어집니다.

Esc 키로 닫기

방해가 될 수 있는 이 요소를 쉽게 닫을 수 있도록 하는 것이 중요합니다. 다행히 대화상자 요소가 이스케이프 키를 자동으로 처리하므로 조정 부담이 사라집니다.

스타일

대화상자 요소의 스타일을 지정하는 간단한 경로와 하드 경로가 있습니다. 쉬운 경로는 대화상자의 표시 속성을 변경하지 않고 제한사항을 적용하면 됩니다. 어려운 경로를 따라 대화상자 열기 및 닫기, display 속성 인계 등을 위한 맞춤 애니메이션을 제공합니다.

열린 소품을 사용하여 스타일 지정

자동 조정 색상과 전반적인 디자인 일관성을 높이기 위해 당연히 CSS 변수 라이브러리 Open Props를 가져왔습니다. 무료로 제공되는 변수 외에 정규화 파일과 일부 버튼도 가져옵니다. 둘 다 Open Props에서 선택적으로 가져오기로 제공합니다. 이러한 가져오기를 사용하면 많은 스타일이 없어도 대화상자와 데모를 맞춤설정하는 데 집중할 수 있습니다.

<dialog> 요소의 스타일 지정

디스플레이 속성 소유

대화상자 요소의 기본 표시 및 숨기기 동작은 표시 속성을 block에서 none로 전환합니다. 안타깝게도 이는 안팎으로 애니메이션을 적용할 수 없으며 내부에서만 가능하다는 의미입니다. 안팎으로 애니메이션을 적용하고 싶으며, 첫 번째 단계는 나만의 display 속성을 설정하는 것입니다.

dialog {
  display: grid;
}

위의 CSS 스니펫과 같이 표시 속성 값을 변경하고 소유함으로써 적절한 사용자 환경을 용이하게 하기 위해 상당한 양의 스타일을 관리해야 합니다. 첫째, 대화상자의 기본 상태가 닫힙니다. 이 상태를 시각적으로 표시하여 대화상자가 다음 스타일과 상호작용을 수신하지 못하도록 할 수 있습니다.

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

이제 대화상자가 표시되지 않으며 열지 않으면 상호작용할 수 없습니다. 나중에 키보드 및 스크린 리더 사용자도 숨겨진 대화상자에 도달할 수 없도록 대화상자의 inert 속성을 관리하는 JavaScript를 추가합니다.

대화상자에 적응형 색상 테마 지정

표면 색상을 보여주는 밝은 테마와 어두운 테마를 보여주는 메가 대화상자

color-scheme는 밝은 시스템 환경설정과 어두운 시스템 환경설정에 맞춰 브라우저에서 제공하는 적응형 색상 테마로 문서를 선택하지만 여기서는 그 이상으로 대화상자 요소를 맞춤설정하려고 했습니다. Open Props는 color-scheme 사용과 유사하게 밝은 시스템 환경설정과 어두운 시스템 환경설정에 자동으로 조정되는 몇 가지 표면 색상을 제공합니다. 이는 디자인에서 레이어를 만드는 데 유용하며 저는 색상을 사용하여 이러한 레이어 표면 모양을 시각적으로 지원하는 것을 좋아합니다. 배경 색상은 var(--surface-1)입니다. 이 레이어 위에 배치하려면 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);
  }
}

나중에 머리글 및 바닥글과 같은 하위 요소에 더 많은 자동 조정 색상이 추가됩니다. 저는 이러한 요소가 대화상자 요소에 추가된다고 생각하지만, 매력적이고 잘 디자인된 대화상자 디자인을 만드는 데 정말 중요합니다.

반응형 대화상자 크기 조정

대화상자는 기본적으로 크기를 콘텐츠에 위임하며, 이는 일반적으로 큰 효과가 있습니다. 여기서의 목표는 max-inline-size를 읽을 수 있는 크기 (--size-content-3 = 60ch) 또는 표시 영역 너비의 90% 로 제한하는 것입니다. 이렇게 하면 휴대기기에서 대화상자가 가장자리까지 닿지 않으며 데스크톱 화면에서는 너무 넓어 읽기 어렵지 않습니다. 그런 다음 대화상자가 페이지 높이를 초과하지 않도록 max-block-size를 추가합니다. 또한 세로로 긴 대화상자 요소인 경우 대화상자의 스크롤 가능한 영역을 지정해야 합니다.

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

max-block-size가 두 번 발생한 것을 확인해보세요. 첫 번째는 실제 표시 영역 단위인 80vh를 사용합니다. 정말로 원하는 것은 해외 사용자를 위해 대화상자를 상대적 흐름 내에 유지하는 것입니다. 그래서 더 안정적이 될 때를 위해 두 번째 선언에서 논리적이고 최신이며 부분적으로 지원되는 dvb 단위를 사용합니다.

메가 대화상자 배치

대화상자 요소의 위치 지정을 지원하려면 전체 화면 배경화면과 대화상자 컨테이너라는 두 부분으로 세분화하는 것이 좋습니다. 배경화면은 모든 항목을 덮어야 하며, 음영 효과를 제공하여 이 대화상자가 앞에 있고 뒤에 있는 콘텐츠에 액세스할 수 없음을 지원합니다. 대화상자 컨테이너는 자유롭게 이 배경화면 위에 중심을 맞추고 콘텐츠에 필요한 모양을 취할 수 있습니다.

다음 스타일은 대화상자 요소를 창에 고정하여 각 모서리까지 확장하고 margin: auto를 사용하여 콘텐츠를 중앙에 배치합니다.

dialog {
  …
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
모바일 메가 대화상자 스타일

작은 표시 영역에서는 전체 페이지 메가 모달의 스타일을 약간 다르게 지정합니다. 하단 여백을 0로 설정하여 대화상자 콘텐츠가 표시 영역 하단으로 이동합니다. 몇 가지 스타일을 조정하면 대화상자를 사용자의 엄지손가락에 더 가까이 있는 작업 시트로 바꿀 수 있습니다.

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

열린 상태에서 데스크톱 및 모바일 메가 대화상자 둘 다에 여백 간격이 오버레이된 devtools의 스크린샷

미니 대화상자 배치

데스크톱 컴퓨터와 같이 더 큰 표시 영역을 사용할 때는 미니 대화상자를 호출한 요소 위에 배치하기로 했습니다. 이를 위해서는 JavaScript가 필요합니다. 여기에서 제가 사용하는 기법을 확인할 수 있지만 이 문서에서는 다루지 않습니다. 자바스크립트를 사용하지 않으면 메가 대화상자처럼 작은 대화상자가 화면 중앙에 표시됩니다.

돋보이게 만들기

마지막으로, 페이지 위에 있는 부드러운 표면처럼 보이도록 대화상자에 기능을 추가합니다. 대화의 모서리를 둥글게 처리하면 부드러워집니다. 깊이는 Open Props에서 정교하게 제작된 그림자 소품 중 하나를 사용하면 됩니다.

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

배경화면 유사 요소 맞춤설정

여기서는 배경화면을 매우 가볍게 사용하기로 했고 backdrop-filter로 블러 효과만 메가 대화상자에 추가했습니다.

브라우저 지원

  • 76
  • 17
  • 103
  • 9

소스

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

또한 브라우저에서 향후 배경화면 요소 전환을 허용하기 위해 backdrop-filter에 전환을 배치하기로 했습니다.

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

다채로운 아바타의 블러 처리된 배경을 오버레이하는 메가 대화상자의 스크린샷

추가 스타일 지정

이 섹션을 'extras'라고 부르는 이유는 일반적으로 대화상자 요소보다 대화상자 요소 데모와 더 관련이 있기 때문입니다.

스크롤 억제

대화상자가 표시되면 사용자는 여전히 그 뒤에 있는 페이지를 스크롤할 수 있는데, 이는 제가 원하지 않습니다.

일반적으로 overscroll-behavior는 일반적인 해결 방법이지만 사양에 따라 대화상자에 아무런 영향을 미치지 않습니다. 스크롤 포트가 아니고, 즉 스크롤러가 아니기 때문에 방지할 사항이 없기 때문입니다. JavaScript를 사용하여 이 가이드의 새 이벤트(예: 'closed' 및 'opened')를 감시하고 문서에서 overflow: hidden를 전환하거나 :has()가 모든 브라우저에서 안정화될 때까지 기다릴 수 있습니다.

브라우저 지원

  • 105
  • 105
  • 121
  • 15.4

소스

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

이제 메가 대화상자가 열리면 HTML 문서에 overflow: hidden가 있습니다.

<form> 레이아웃

사용자로부터 상호작용 정보를 수집하는 데 매우 중요한 요소일 뿐만 아니라 여기서는 이를 사용하여 머리글, 바닥글, 기사 요소를 배치합니다. 이 레이아웃을 사용하여 기사 하위 요소를 스크롤 가능한 영역으로 표현하려고 합니다. grid-template-rows를 사용하면 됩니다. 기사 요소에 1fr가 지정되고 양식 자체의 최대 높이가 대화상자 요소와 동일합니다. 이렇게 고정된 높이와 확고한 행 크기를 설정하면 기사 요소가 오버플로될 때 제한되고 스크롤될 수 있습니다.

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

행 위에 그리드 레이아웃 정보를 오버레이하는 devtools의 스크린샷

<header> 대화상자 스타일 지정

이 요소의 역할은 대화상자 콘텐츠의 제목을 제공하고 찾기 쉬운 닫기 버튼을 제공하는 것입니다. 또한 대화상자 기사 콘텐츠 뒤에 있는 것처럼 보이게 하기 위해 표면 색상이 제공됩니다. 이러한 요구사항으로 인해 Flexbox 컨테이너, 가장자리까지 간격을 두고 세로로 정렬된 항목, 제목 및 닫기 버튼에 공간을 제공하기 위한 패딩과 간격을 만듭니다.

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

대화상자 헤더에 Flexbox 레이아웃 정보를 오버레이하는 Chrome Devtools의 스크린샷

헤더 닫기 버튼의 스타일 지정

데모에서는 Open Props 버튼을 사용하므로 닫기 버튼은 다음과 같이 원형 아이콘 중심 버튼으로 맞춤설정됩니다.

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

헤더 닫기 버튼의 크기 및 패딩 정보가 오버레이된 Chrome Devtools의 스크린샷

<article> 대화상자 스타일 지정

기사 요소는 이 대화상자에서 특별한 역할을 합니다. 길거나 긴 대화상자의 경우에 스크롤되는 공간입니다.

이를 위해 상위 양식 요소는 자체적인 최댓값을 설정했습니다. 이 최댓값은 이 기사 요소의 높이가 너무 높아지면 도달해야 하는 제약 조건을 제공합니다. 스크롤바가 필요할 때만 표시되도록 overflow-y: auto를 설정하고 overscroll-behavior: contain를 사용하여 스크롤바 내에 스크롤을 포함하고 나머지는 맞춤 프레젠테이션 스타일이 됩니다.

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

바닥글의 역할은 작업 버튼 메뉴를 포함하는 것입니다. Flexbox는 콘텐츠를 바닥글 인라인 축 끝에 정렬한 다음 간격을 두어 버튼에 공간을 제공하는 데 사용합니다.

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

바닥글 요소에 Flexbox 레이아웃 정보를 오버레이하는 Chrome Devtools의 스크린샷

menu 요소는 대화상자의 작업 버튼을 포함하는 데 사용됩니다. gap와 함께 래핑 Flexbox 레이아웃을 사용하여 버튼 사이에 공간을 제공합니다. 메뉴 요소에는 <ul>와 같은 패딩이 있습니다. 이 스타일도 필요하지 않으므로 삭제합니다.

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

바닥글 메뉴 요소에 Flexbox 정보를 오버레이하는 Chrome Devtools의 스크린샷

애니메이션

대화상자 요소는 창에 들어오고 나기 때문에 애니메이션으로 표시되는 경우가 많습니다. 대화상자에 이 입구와 출구를 지원하는 움직임을 제공하면 사용자가 흐름 속에서 방향을 찾는 데 도움이 됩니다.

일반적으로 대화상자 요소는 외부가 아닌 안쪽에서만 애니메이션될 수 있습니다. 이는 브라우저가 요소의 display 속성을 전환하기 때문입니다. 앞에서 이 가이드는 디스플레이를 그리드로 설정하고 None으로 설정하지 않았습니다. 이를 통해 애니메이션을 켜고 끌 수 있습니다.

Open Props에는 다양한 키프레임 애니메이션이 포함되어 있어 조정을 쉽고 읽기 쉽게 수행할 수 있습니다. 다음은 제가 취한 애니메이션 목표와 계층화된 접근 방식입니다.

  1. 모션 감소는 기본 전환이며, 간단한 불투명도 페이드 인 및 아웃입니다.
  2. 모션이 괜찮으면 슬라이드 및 확장 애니메이션이 추가됩니다.
  3. 메가 대화상자의 반응형 모바일 레이아웃이 슬라이드 아웃되도록 조정됩니다.

안전하고 의미 있는 기본 전환

Open Props에는 페이드 인 및 페이드아웃을 위한 키프레임이 함께 제공되지만, 나는 이러한 계층화된 전환 방식을 기본적으로 키프레임 애니메이션을 사용하는 것을 선호합니다. 앞에서 이미 대화상자의 공개 상태 스타일을 불투명도에 따라 지정했으며, [open] 속성에 따라 1 또는 0를 조정했습니다. 0% 와 100% 간에 전환하려면 브라우저에 원하는 이징 길이와 종류를 알립니다.

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

전환에 모션 추가

사용자가 움직임을 받아들이면 메가 대화상자와 미니 대화상자 모두 입구로 위로 슬라이드되고 이탈 시 축소되어야 합니다. prefers-reduced-motion 미디어 쿼리와 몇 가지 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;
  }
}

모바일에 맞게 종료 애니메이션 조정

스타일 지정 섹션 앞부분에서 메가 대화상자 스타일은 마치 작은 종이가 화면 하단에서 위로 미끄러진 후 여전히 하단에 부착되어 있는 것처럼 휴대기기에 맞게 작업 시트에 맞게 조정되었습니다. 수평 확장 종료 애니메이션은 이 새로운 디자인에 적합하지 않으며 몇 가지 미디어 쿼리와 일부 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

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
}

이러한 추가는 조명 닫기 (대화상자 배경 클릭), 애니메이션, 양식 데이터를 가져오는 더 빠른 타이밍을 위한 몇 가지 추가 이벤트에서 비롯됩니다.

조명 추가 중 닫기

이 작업은 간단하며 애니메이션 대상이 아닌 대화상자 요소에 유용합니다. 상호작용은 대화상자 요소의 클릭을 확인하고 이벤트 버블링을 활용하여 클릭된 항목을 평가하는 방식으로 실행되며 최상위 요소인 경우에만 close()을 실행합니다.

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

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

dialog.close('dismiss')를 확인하세요. 이벤트가 호출되고 문자열이 제공됩니다. 이 문자열은 다른 JavaScript로 가져와서 대화상자가 닫힌 방식에 관한 유용한 정보를 얻을 수 있습니다. 또한 다양한 버튼에서 함수를 호출할 때마다 닫기 문자열도 제공하여 애플리케이션에 사용자 상호작용에 관한 컨텍스트를 제공합니다.

닫는 이벤트 및 닫힌 이벤트 추가

대화상자 요소에는 닫기 이벤트가 함께 제공됩니다. 이 요소는 대화상자 close() 함수가 호출될 때 즉시 내보내집니다. 이 요소에 애니메이션을 적용하고 있으므로 변경사항으로 데이터를 가져오거나 대화상자 양식을 재설정하도록 애니메이션 전후에 이벤트를 두는 것이 좋습니다. 여기에서는 닫힌 대화상자의 inert 속성 추가를 관리하고 데모에서는 사용자가 새 이미지를 제출한 경우 아바타 목록을 수정하는 데 사용합니다.

이렇게 하려면 closingclosed라는 새 이벤트 두 개를 만듭니다. 그런 다음 대화상자에서 내장된 닫기 이벤트를 수신합니다. 여기에서 대화상자를 inert로 설정하고 closing 이벤트를 전달합니다. 다음 작업은 대화상자에서 애니메이션과 전환이 완료될 때까지 기다린 후 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))

토스트 구성요소 빌드에서도 사용되는 animationsComplete 함수는 애니메이션 및 전환 프로미스의 완료를 기반으로 프로미스를 반환합니다. 이러한 이유로 dialogClose비동기 함수입니다. 그러면 반환된 프로미스를 await하여 자신 있게 종료된 이벤트로 이동할 수 있습니다.

시작 및 열린 이벤트 추가

기본 제공 대화상자 요소는 닫기와 달리 열기 이벤트를 제공하지 않으므로 이러한 이벤트는 추가하기가 쉽지 않습니다. 저는 MutationObserver를 사용하여 변경되는 대화상자의 속성에 관한 유용한 정보를 제공합니다. 이 관찰자에서는 open 속성의 변경사항을 확인하고 그에 따라 맞춤 이벤트를 관리합니다.

닫는 이벤트와 닫힌 이벤트를 시작한 방법과 비슷하게 openingopened라는 두 개의 새 이벤트를 만듭니다. 이전에 대화상자 닫기 이벤트를 리슨한 경우 이번에는 생성된 변형 관찰자를 사용하여 대화상자의 속성을 관찰합니다.

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

대화상자 속성이 변경될 때 변형 관찰자 콜백 함수가 호출되어 변경사항 목록을 배열로 제공합니다. 속성 변경사항을 반복하여 attributeName가 열려 있는지 확인합니다. 다음으로, 요소에 속성이 있는지 확인합니다. 이는 대화상자가 열렸는지를 알려줍니다. 열린 경우 inert 속성을 삭제하고 autofocus를 요청하는 요소 또는 대화상자에서 발견된 첫 번째 button 요소에 포커스를 설정합니다. 마지막으로, 닫기 및 닫힘 이벤트와 마찬가지로, 시작 이벤트를 즉시 전달하고 애니메이션이 완료될 때까지 기다린 다음 열린 이벤트를 전달합니다.

삭제된 이벤트 추가

단일 페이지 애플리케이션에서는 경로 또는 기타 애플리케이션 요구사항과 상태에 따라 대화상자가 추가 및 삭제되는 경우가 많습니다. 대화상자가 삭제될 때 이벤트나 데이터를 정리하는 것이 유용할 수 있습니다.

다른 변형 관찰자를 사용하여 이 작업을 수행할 수 있습니다. 이번에는 대화상자 요소의 속성을 관찰하는 대신 본문 요소의 하위 요소를 관찰하고 대화상자 요소가 삭제되는지 확인합니다.

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

문서 본문에서 하위 요소가 추가되거나 삭제될 때마다 변형 관찰자 콜백이 호출됩니다. 감시 중인 특정 변형은 대화상자의 nodeName가 있는 removedNodes에 관한 것입니다. 대화상자가 삭제된 경우 메모리 확보를 위해 클릭 및 닫기 이벤트가 삭제되고 맞춤 삭제된 이벤트가 전달됩니다.

로드 속성 삭제

대화상자 애니메이션이 페이지에 추가될 때 또는 페이지 로드 시 종료 애니메이션이 재생되지 않도록 하기 위해 로드 속성이 대화상자에 추가되었습니다. 다음 스크립트는 대화상자 애니메이션 실행이 완료될 때까지 기다린 후 속성을 삭제합니다. 이제 대화상자에 자유롭게 애니메이션을 적용할 수 있으며 주의를 분산시키는 애니메이션은 효과적으로 숨겨졌습니다.

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

페이지 로드 시 키프레임 애니메이션이 차단되는 문제에 관해 자세히 알아보세요.

모두 함께

다음은 dialog.js에 대한 전체적인 설명입니다. 이제 각 섹션을 개별적으로 설명했습니다.

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

dialog.js 모듈 사용

모듈에서 내보낸 함수는 호출되어 다음과 같은 새로운 이벤트와 기능을 추가하려는 대화상자 요소를 전달할 것으로 예상됩니다.

import GuiDialog from './dialog.js'

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

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

이와 마찬가지로 두 대화상자는 가벼운 닫기, 애니메이션 로드 수정, 작업할 더 많은 이벤트로 업그레이드됩니다.

새 맞춤 이벤트 수신 대기

업그레이드된 각 대화상자 요소는 이제 다음과 같이 5개의 새로운 이벤트를 리슨할 수 있습니다.

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

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

MegaDialog.addEventListener('removed', dialogRemoved)

다음은 이러한 이벤트를 처리하는 두 가지 예입니다.

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

대화상자 요소로 빌드한 데모에서는 닫힌 이벤트와 양식 데이터를 사용하여 목록에 새 아바타 요소를 추가합니다. 대화상자가 나가기 애니메이션을 완료하고 나면 일부 스크립트가 새 아바타로 애니메이션되므로 타이밍이 좋습니다. 새로운 이벤트 덕분에 사용자 경험을 더 원활하게 조정할 수 있습니다.

알림 dialog.returnValue: 여기에는 대화상자 close() 이벤트가 호출될 때 전달되는 닫기 문자열이 포함되어 있습니다. dialogClosed 이벤트에서는 대화상자가 닫혔는지, 취소되었는지 또는 확인되었는지 아는 것이 중요합니다. 확인되면 스크립트는 양식 값을 가져와서 양식을 재설정합니다. 재설정은 대화상자가 다시 표시될 때 비어 있게 되어 새로 제출할 수 있도록 유용합니다.

결론

이제 제가 어떻게 했는지 알았으니 어떻게 되세요?‽ 🙂

접근 방식을 다양화하고 웹에서 빌드하는 모든 방법을 알아보겠습니다.

데모를 만들고 링크를 트윗해 주세요. 그러면 아래의 커뮤니티 리믹스 섹션에 추가하겠습니다.

커뮤니티 리믹스

자료