Hộp thoại

Mẫu này cho biết cách tạo các phương thức nhỏ và siêu đại diện, thích ứng màu sắc, phản hồi và có thể truy cập được bằng phần tử <dialog>.

Bài viết đầy đủ · Video trên YouTube · Nguồn trên GitHub

HTML

<dialog id="MegaDialog" inert loading modal-mode="mega"> 
  <form method="dialog">
    <header> 
      <section class="icon-headline">
        <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
          <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
          <circle cx="8.5" cy="7" r="4"></circle>
          <line x1="20" y1="8" x2="20" y2="14"></line>
          <line x1="23" y1="11" x2="17" y2="11"></line>
        </svg>
        <h3>New User</h3>
      </section>
      <!-- TODO: Devsite - Removed inline handlers -->
      <!-- <button onclick="this.closest('dialog').close('close')" type="button" title="Close dialog">  -->
        <title>Close dialog icon</title>
        <svg width="24" height="24" viewBox="0 0 24 24">
          <line x1="18" y1="6" x2="6" y2="18"/>
          <line x1="6" y1="6" x2="18" y2="18"/>
        </svg>
      </button>
    </header>
    <article>
      <section class="labelled-input">
        <label for="userimage">Upload an image</label>
        <input id="userimage" name="userimage" type="file">
      </section>
      <small><b>*</b> Maximum upload 1mb</small>
    </article>
    <footer>
      <menu>
        <button type="reset" value="clear">Clear</button>
      </menu>
      <menu>
        <!-- TODO: Devsite - Removed inline handlers -->
        <!-- <button autofocus type="button" onclick="this.closest('dialog').close('cancel')">Cancel</button> -->
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

<dialog id="MiniDialog" inert loading modal-mode="mini"> 
  <form method="dialog">
    <article>
      <section class="warning-message">
        <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" >
          <title>A warning icon</title>
          <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
          <line x1="12" y1="9" x2="12" y2="13"></line>
          <line x1="12" y1="17" x2="12.01" y2="17"></line>
        </svg>
        <p>Are you sure you want to remove this user?</p>
      </section>
    </article>
    <footer>
      <menu>
        <!-- TODO: Devsite - Removed inline handlers -->
        <!-- <button autofocus type="button" onclick="this.closest('dialog').close('cancel')">Cancel</button> -->
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

CSS


        @import "https://unpkg.com/open-props";
@import "https://unpkg.com/open-props/normalize.min.css";

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

dialog {
  display: grid;
  background: var(--surface-2);
  color: var(--text-1);
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
  z-index: var(--layer-important);
  overflow: hidden;
  transition: opacity .5s var(--ease-3);

  @media (--motionOK) {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  @media (--OSdark) {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }

  @media (--md-n-below) {
    &[modal-mode="mega"] {
      margin-block-end: 0;
      border-end-end-radius: 0;
      border-end-start-radius: 0;

      @media (--motionOK) {
        animation: var(--animation-slide-out-down) forwards;
        animation-timing-function: var(--ease-squish-2);
      }
    }
  }

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

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

  &[modal-mode="mini"]::backdrop {
    backdrop-filter: none;
  }

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

  &[loading] {
    visibility: hidden;
  }

  &[open] {
    @media (--motionOK) {
      animation: var(--animation-slide-in-up) forwards;
    }
  }

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

    & > 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 (--OSlight) {
        background: var(--surface-1);

        &::-webkit-scrollbar {
          background: var(--surface-1);
        }
      }

      @media (--OSdark) {
        border-block-start: var(--border-size-1) solid var(--surface-3);
      }
    }

    & > header {
      display: flex;
      gap: var(--size-3);
      justify-content: space-between;
      align-items: flex-start;
      padding-block: var(--size-3);
      padding-inline: var(--size-5);

      & > button {
        border-radius: var(--radius-round);
        padding: .75ch;
        aspect-ratio: 1;
        flex-shrink: 0;
        place-items: center;
        stroke: currentColor;
        stroke-width: 3px;
      }
    }

    & > footer {
      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);

      & > menu {
        display: flex;
        flex-wrap: wrap;
        gap: var(--size-3);
        padding-inline-start: 0;

        &:only-child {
          margin-inline-start: auto;
        }

        @media (max-width: 410px) {
          & button[type="reset"] {
            display: none;
          }
        }
      }
    }

    & > :is(header, footer) {
      background-color: var(--surface-2);

      @media (--OSdark) {
        background-color: var(--surface-1);
      }
    }
  }
}
        

JS


        // Custom events to be added to dialog element:
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 and prevent page load @keyframes playing:
  await animationsComplete(dialog);
  dialog.removeAttribute('loading');
}