대화상자 구성요소 빌드

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

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

밝은 테마와 어두운 테마의 메가 및 미니 대화상자를 보여주는 데모입니다.

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

개요

<dialog> 요소는 페이지 내 문맥 정보 또는 작업에 적합합니다. 양식이 작거나 사용자에게 필요한 작업이 확인 또는 취소뿐이어서 다중 페이지 작업 대신 동일 페이지 작업이 사용자 환경에 도움이 되는 경우를 고려하세요.

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

브라우저 지원

  • Chrome: 37.
  • Edge: 79
  • Firefox: 98
  • Safari: 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>

Mega 대화상자

메가 대화상자에는 <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가 요소에서 포커스가 사라지는 것을 감시하는 데 사용되었으며, 이때 포커스를 가로채 다시 적용했습니다.

브라우저 지원

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

소스

inert 후에 문서의 모든 부분이 더 이상 포커스 대상이 아니거나 마우스와 상호작용할 수 없을 정도로 '고정'될 수 있습니다. 포커스를 트랩하는 대신 포커스가 문서의 유일한 양방향 부분으로 안내됩니다.

요소 열기 및 자동 포커스 설정

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

Esc 키로 닫기

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

스타일

대화상자 요소의 스타일을 지정하는 쉬운 방법과 어려운 방법이 있습니다. 간단한 방법은 대화상자의 디스플레이 속성을 변경하지 않고 제한사항을 사용하여 작업하는 것입니다. 대화상자를 열고 닫을 때, display 속성을 가져올 때 등의 맞춤 애니메이션을 제공하기 위해 어려운 길을 택했습니다.

개방형 속성을 사용한 스타일 지정

적응형 색상과 전반적인 디자인 일관성을 높이기 위해 CSS 변수 라이브러리 Open Props를 가져왔습니다. 무료로 제공되는 변수 외에도 normalize 파일과 일부 버튼을 가져옵니다. 이 두 가지 모두 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가 필요합니다. 여기에서 제가 사용하는 기법을 확인할 수 있습니다. 하지만 이 기법은 이 도움말의 범위를 벗어납니다. JavaScript가 없으면 메가 대화상자와 마찬가지로 미니 대화상자가 화면 중앙에 표시됩니다.

눈에 띄게 만들기

마지막으로 대화상자가 페이지 위에 떠 있는 부드러운 표면처럼 보이도록 대화상자에 약간의 스타일을 추가합니다. 대화상자의 모서리를 둥글게 처리하여 부드러움을 표현했습니다. 깊이는 Open Props의 세심하게 제작된 쉐도우 프로프 중 하나를 사용하여 구현됩니다.

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

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

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

브라우저 지원

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

소스

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

또한 향후 브라우저에서 배경 요소 전환을 허용할 수 있기를 바라며 backdrop-filter에 전환을 적용했습니다.

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

다채로운 아바타의 흐리게 처리된 배경이 겹쳐진 메가 대화상자의 스크린샷

스타일 지정 추가 항목

이 섹션은 일반적인 대화상자 요소보다는 대화상자 요소 데모와 더 관련이 있으므로 '추가 항목'이라고 합니다.

스크롤 제한

대화상자가 표시되어도 사용자는 대화상자 뒤의 페이지를 계속 스크롤할 수 있습니다. 원하지 않는 동작입니다.

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

브라우저 지원

  • Chrome: 105.
  • Edge: 105.
  • Firefox: 121.
  • Safari: 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> 스타일 지정

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

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> 스타일 지정

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에서 Flexbox 레이아웃 정보를 바닥글 요소에 오버레이하는 스크린샷

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가 바닥글 메뉴 요소에 flexbox 정보를 오버레이하는 스크린샷

애니메이션

대화상자 요소는 창에 들어오고 나감에 따라 애니메이션이 적용되는 경우가 많습니다. 대화상자에 이러한 진입과 종료에 도움이 되는 모션을 제공하면 사용자가 흐름을 파악하는 데 도움이 됩니다.

일반적으로 대화상자 요소는 나가는 애니메이션이 아닌 들어오는 애니메이션만 적용할 수 있습니다. 이는 브라우저가 요소의 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 프로퍼티를 사용하여 이를 실행할 수 있습니다.

@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를 사용하여 대화상자의 속성 변경에 관한 유용한 정보를 제공합니다. 이 관찰자에서는 열린 속성의 변경사항을 확인하고 그에 따라 맞춤 이벤트를 관리합니다.

종료 및 종료됨 이벤트를 시작한 것과 비슷하게 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 이벤트에서 대화상자가 닫혔는지, 취소되었는지, 확인되었는지 아는 것이 중요합니다. 확인되면 스크립트는 양식 값을 가져와 양식을 재설정합니다. 재설정은 대화상자가 다시 표시될 때 빈 상태로 표시되어 새 항목을 제출할 수 있도록 하는 데 유용합니다.

결론

이제 제가 어떻게 했는지 알았으니 어떻게 하시겠어요? 🙂

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

데모를 만들어 트윗해 주시면 아래의 커뮤니티 리믹스 섹션에 추가해 드리겠습니다.

커뮤니티 리믹스

리소스