대화상자 구성요소 빌드

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

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

밝은 테마와 어두운 테마의 메가 및 미니 대화상자를 보여줍니다.

동영상을 선호하는 경우 이 게시물의 YouTube 버전을 확인하세요.

개요

<dialog> 요소는 페이지 내 상황 정보 또는 작업에 적합합니다. 사용자 환경이 여러 페이지 작업 대신 동일한 페이지 작업의 이점을 누릴 수 있는 경우를 고려하세요. 예를 들어 양식이 작거나 사용자에게 필요한 유일한 작업이 확인 또는 취소인 경우입니다.

<dialog> 요소는 최근에 브라우저 간에 안정화되었습니다.

Browser Support

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

Source

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

마크업

<dialog> 요소의 필수사항은 많지 않습니다. 이 요소는 자동으로 숨겨지며 콘텐츠를 오버레이하는 스타일이 내장되어 있습니다.

<dialog>
  …
</dialog>

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

전통적으로 대화상자 요소는 모달과 많은 부분을 공유하며 이름이 서로 호환되는 경우가 많습니다. 여기서는 작은 대화상자 팝업 (미니)과 전체 페이지 대화상자 (메가) 모두에 대화상자 요소를 사용했습니다. 이름을 mega와 mini로 지정했으며, 두 대화상자는 서로 다른 사용 사례에 맞게 약간 조정했습니다. 유형을 지정할 수 있도록 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>

대화상자 요소는 데이터를 수집하고 사용자 상호작용을 할 수 있는 전체 뷰포트 요소의 강력한 기반을 제공합니다. 이러한 필수 요소는 사이트 또는 앱에서 매우 흥미롭고 강력한 상호작용을 만들 수 있습니다.

접근성

대화상자 요소에는 매우 우수한 접근성이 내장되어 있습니다. 평소처럼 이러한 기능을 추가하는 대신 이미 많은 기능이 있습니다.

포커스 복원

사이드 탐색 메뉴 구성요소 빌드에서 수동으로 한 것처럼, 무언가를 열고 닫을 때 관련 열기 및 닫기 버튼에 포커스가 올바르게 배치되는 것이 중요합니다. 사이드 탐색 메뉴가 열리면 포커스가 닫기 버튼에 배치됩니다. 닫기 버튼을 누르면 포커스가 닫기 버튼을 연 버튼으로 복원됩니다.

대화상자 요소의 경우 기본 동작이 내장되어 있습니다.

아쉽게도 대화상자를 애니메이션으로 표시하거나 숨기려면 이 기능을 사용할 수 없습니다. JavaScript 섹션에서 이 기능을 복원하겠습니다.

포커스 트래핑

대화상자 요소는 문서에서 inert를 관리합니다. inert 이전에는 JavaScript를 사용하여 요소에서 포커스가 벗어나는지 확인한 후 이를 가로채 다시 배치했습니다.

Browser Support

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

Source

inert 이후에는 문서의 일부가 더 이상 포커스 타겟이 아니거나 마우스로 상호작용하지 않도록 '고정'될 수 있습니다. 포커스를 트래핑하는 대신 포커스가 문서의 유일한 대화형 부분으로 안내됩니다.

요소를 열고 자동으로 포커스 설정

기본적으로 대화상자 요소는 대화상자 마크업에서 포커스를 둘 수 있는 첫 번째 요소에 포커스를 할당합니다. 사용자가 기본적으로 사용할 수 있는 최적의 요소가 아닌 경우 autofocus 속성을 사용하세요. 앞서 설명한 것처럼 확인 버튼이 아닌 취소 버튼에 이를 배치하는 것이 좋습니다. 이렇게 하면 확인이 의도적이고 우연히 이루어지지 않습니다.

Esc 키로 닫기

이러한 방해 요소는 쉽게 닫을 수 있어야 합니다. 다행히 대화상자 요소가 이스케이프 키를 처리하므로 오케스트레이션 부담이 없습니다.

스타일

대화상자 요소를 스타일링하는 쉬운 방법과 어려운 방법이 있습니다. 쉬운 방법은 대화상자의 display 속성을 변경하지 않고 제한사항을 사용하는 것입니다. display 속성을 인계받는 등 대화상자를 열고 닫기 위한 맞춤 애니메이션을 제공하는 어려운 경로를 따릅니다.

Open Props를 사용한 스타일 지정

적응형 색상과 전반적인 디자인 일관성을 높이기 위해 CSS 변수 라이브러리인 Open Props를 사용했습니다. 무료로 제공되는 변수 외에도 normalize 파일과 일부 buttons를 가져옵니다. 둘 다 Open Props에서 선택적 가져오기로 제공합니다. 이러한 가져오기를 사용하면 대화상자와 데모를 맞춤설정하는 데 집중할 수 있으며, 이를 지원하고 보기 좋게 만드는 데 많은 스타일이 필요하지 않습니다.

<dialog> 요소 스타일 지정

디스플레이 속성 소유

대화상자 요소의 기본 표시 및 숨기기 동작은 display 속성을 block에서 none로 전환합니다. 따라서 애니메이션은 안쪽으로만 가능하며, 안팎으로는 불가능합니다. 인과 아웃을 모두 애니메이션으로 처리하고 싶습니다. 첫 번째 단계는 자체 display 속성을 설정하는 것입니다.

dialog {
  display: grid;
}

위의 CSS 스니펫에 표시된 대로 display 속성 값을 변경하여 소유함으로써 적절한 사용자 환경을 지원하기 위해 상당한 양의 스타일을 관리해야 합니다. 먼저 대화상자의 기본 상태는 닫힘입니다. 이 상태를 시각적으로 나타내고 다음 스타일을 사용하여 대화상자가 상호작용을 수신하지 못하도록 할 수 있습니다.

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

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

대화상자에 적응형 색상 테마 부여

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

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

열려 있는 동안 데스크톱과 모바일 메가 대화상자 모두에 여백 간격이 오버레이된 개발자 도구의 스크린샷

미니 대화상자 위치 지정

데스크톱 컴퓨터와 같은 더 큰 뷰포트를 사용할 때는 미니 대화상자를 호출한 요소 위에 배치했습니다. 이 작업을 수행하려면 JavaScript가 필요합니다. 여기에서 제가 사용하는 기법을 확인하실 수 있습니다. 하지만 이 기법은 이 도움말의 범위를 벗어난다고 생각합니다. JavaScript가 없으면 메가 대화상자와 마찬가지로 미니 대화상자가 화면 중앙에 표시됩니다.

눈에 띄게 만들기

마지막으로 대화상자에 약간의 플레어를 추가하여 페이지 위로 멀리 떠 있는 부드러운 표면처럼 보이게 합니다. 부드러움은 대화상자의 모서리를 둥글게 처리하여 구현됩니다. 깊이는 Open Props의 세심하게 제작된 shadow props 중 하나로 구현됩니다.

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

배경화면 가상 요소 맞춤설정

배경은 아주 가볍게 처리하여 메가 대화상자에 backdrop-filter로 흐림 효과만 추가했습니다.

Browser Support

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

Source

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()가 안정화될 때까지 기다릴 수 있습니다.

Browser Support

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

Source

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

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

<form> 레이아웃

사용자로부터 상호작용 정보를 수집하는 데 매우 중요한 요소일 뿐만 아니라 여기서는 헤더, 푸터, article 요소를 배치하는 데 사용합니다. 이 레이아웃을 사용하면 article 하위 요소를 스크롤 가능한 영역으로 명시할 수 있습니다. grid-template-rows를 사용하여 이를 달성합니다. article 요소에 1fr가 지정되고 양식 자체의 최대 높이는 대화상자 요소와 동일합니다. 이 고정 높이와 고정 행 크기를 설정하면 article 요소가 제한되고 오버플로될 때 스크롤될 수 있습니다.

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

행 위에 그리드 레이아웃 정보를 오버레이하는 개발자 도구의 스크린샷

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

이 요소의 역할은 대화상자 콘텐츠의 제목을 제공하고 찾기 쉬운 닫기 버튼을 제공하는 것입니다. 또한 대화상자 문서 콘텐츠 뒤에 표시되도록 표면 색상이 지정됩니다. 이러한 요구사항으로 인해 플렉스박스 컨테이너, 가장자리까지 간격이 있는 세로 정렬 항목, 제목과 닫기 버튼에 여유 공간을 제공하는 패딩과 간격이 생깁니다.

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

대화상자 헤더에 플렉스박스 레이아웃 정보를 오버레이하는 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> 스타일 지정

article 요소는 이 대화상자에서 특별한 역할을 합니다. 키가 크거나 긴 대화상자의 경우 스크롤할 수 있는 공간입니다.

이를 위해 상위 양식 요소는 너무 커질 경우 이 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);
  }
}

Chrome Devtools가 플렉스박스 레이아웃 정보를 바닥글 요소에 오버레이하는 스크린샷

menu 요소는 대화상자의 작업 버튼을 포함하는 데 사용됩니다. 버튼 사이에 공간을 제공하기 위해 gap를 사용하여 래핑 플렉스박스 레이아웃을 사용합니다. 메뉴 요소에는 <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;
}

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로 추가할 수 있는 항목은 다음과 같습니다.

// 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라는 두 개의 새 이벤트를 만듭니다. 이전에는 대화상자 닫기 이벤트를 수신했지만 이번에는 생성된 mutation observer를 사용하여 대화상자의 속성을 감시합니다.


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

대화상자 속성이 변경되면 변경사항 목록을 배열로 제공하는 mutation observer 콜백 함수가 호출됩니다. 속성 변경사항을 반복하여 attributeName이 열려 있는지 확인합니다. 그런 다음 요소에 속성이 있는지 확인합니다. 이를 통해 대화상자가 열렸는지 여부를 알 수 있습니다. 열려 있는 경우 inert 속성을 삭제하고 autofocus를 요청하는 요소나 대화상자에서 찾은 첫 번째 button 요소에 포커스를 설정합니다. 마지막으로 closing 및 closed 이벤트와 마찬가지로 opening 이벤트를 즉시 디스패치하고 애니메이션이 완료될 때까지 기다린 후 opened 이벤트를 디스패치합니다.

삭제된 일정 추가

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

다른 변이 관찰자를 사용하면 됩니다. 이번에는 대화상자 요소의 속성을 관찰하는 대신 본문 요소의 하위 요소를 관찰하고 대화상자 요소가 삭제되는지 확인합니다.


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에 관한 것입니다. 대화상자가 삭제된 경우 클릭 및 닫기 이벤트가 삭제되어 메모리가 확보되고 맞춤 삭제 이벤트가 디스패치됩니다.

loading 속성 삭제

페이지에 추가되거나 페이지 로드 시 대화상자 애니메이션이 종료 애니메이션을 재생하지 않도록 대화상자에 로드 속성이 추가되었습니다. 다음 스크립트는 대화상자 애니메이션이 완료될 때까지 기다린 후 속성을 삭제합니다. 이제 대화상자가 자유롭게 애니메이션으로 표시되거나 사라지며, 그렇지 않으면 주의를 산만하게 하는 애니메이션이 효과적으로 숨겨집니다.

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 이벤트에서 매우 중요합니다. 확인되면 스크립트가 양식 값을 가져와 양식을 재설정합니다. 재설정은 대화상자가 다시 표시될 때 비어 있고 새 제출을 준비할 수 있도록 하는 데 유용합니다.

결론

이제 제가 어떻게 했는지 아셨으니, 어떻게 하시겠어요? 🙂

다양한 접근 방식을 사용하고 웹에서 빌드하는 모든 방법을 알아보세요.

데모를 만들고 트윗으로 링크를 보내주세요. 아래 커뮤니티 리믹스 섹션에 추가해 드리겠습니다.

커뮤니티 리믹스

리소스