Skip to main content

Dialog v1.0.0

Displays video or other content in a modal dialog, opened when a user presses a button.

<button class="dialog-trigger" data-enable-heading data-audio-description="https://radancy.dev/component-library/dialog/asset/the-last-jedi-desc-en.vtt, English, en-US" data-caption="https://radancy.dev/component-library/dialog/asset/the-last-jedi-en.vtt, English, en, default; https://radancy.dev/component-library/dialog/asset/the-last-jedi-es.vtt, Espanol, es; https://radancy.dev/component-library/dialog/asset/the-last-jedi-fr.vtt, Français, fr" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.mp4" data-show-heading data-transcript-url="https://radancy.dev/component-library/dialog/asset/the-last-jedi.html#transcript">Kitchen Sink</button>
/*!

  Radancy Component Library: Dialog

  Contributor(s):
  Michael "Spell" Spellacy

  Dependencies: Sass

*/

:root {

  --backdrop-filter: blur(3px) brightness(0.2);
  --dialog-play-button-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2' d='M6,22 L24,12 L6,2 L6,22 Z M8,19 L20.6,12 L8,5 L8,19 Z M10,16 L17.2,12 L10,8 L10,16 Z M12,13 L13.8,12 L12,11 L12,13 Z'/%3E%3C/svg%3E");
  --primary-color: #6F00EF;

}

.dialog-trigger {
display: inline-grid;
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
margin-block-end: 0.5rem;
overflow: hidden;
padding: 1rem;
outline-offset: 4px;

  &:has(img) {
  max-inline-size: calc(300rem / 16);
  padding: 0;
  }

  img, &__play {
  grid-area: 1 / -1;
  }

  &__play {
  background-color: #FF0033;
  background-image: var(--dialog-play-button-icon);
  background-position: center;
  background-repeat: no-repeat;
  background-size: calc(24rem / 16);
  block-size: calc(60rem / 16);
  border-radius: 100vw;
  border: 0.2rem #fff solid;
  content: "";
  display: block;
  inline-size: calc(60rem / 16);
  place-self: center;
  }

}

// Fallback backdrop. Native <dialog> gets ::backdrop

.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 1000;
}

.dialog {

// Component Variables

--dialog-close-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 640'%3E%3Cpath fill='%23fff' d='M183.1 137.4C170.6 124.9 150.3 124.9 137.8 137.4C125.3 149.9 125.3 170.2 137.8 182.7L275.2 320L137.9 457.4C125.4 469.9 125.4 490.2 137.9 502.7C150.4 515.2 170.7 515.2 183.2 502.7L320.5 365.3L457.9 502.6C470.4 515.1 490.7 515.1 503.2 502.6C515.7 490.1 515.7 469.8 503.2 457.3L365.8 320L503.1 182.6C515.6 170.1 515.6 149.8 503.1 137.3C490.6 124.8 470.3 124.8 457.8 137.3L320.5 274.7L183.1 137.4z'/%3E%3C/svg%3E");
--dialog-audio-description-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23fff' d='m9.32 16.326h-2.28v-1.366h-3.044l-.872 1.366h-3.124l.189-.255 3.274-4.548 3.091-4.302 1.386-.006h1.38zm-2.181-6.08-.996 1.46-.995 1.464.989.019c.225 0 .434.005.605.005.033.001.072.002.111.002.106 0 .21-.004.313-.013l-.014.001c.012-.012.014-.566.006-1.483zm4.836 6.08h-1.874v-9.111h1.939c.185-.009.402-.014.621-.014.61 0 1.21.041 1.798.12l-.069-.008c1.122.267 2.027 1.024 2.49 2.028l.009.023c.338.618.537 1.354.537 2.137 0 .107-.004.213-.011.318l.001-.014c.004.072.007.157.007.243 0 .448-.069.879-.198 1.284l.008-.03c-.277 1.131-1.006 2.055-1.979 2.586l-.021.01c-.781.381-1.146.429-3.258.429zm.274-6.919v4.654l.866-.026c.994-.033 1.238-.119 1.634-.579.332-.395.534-.91.534-1.471 0-.087-.005-.173-.014-.258l.001.01c.011-.078.017-.168.017-.26 0-.308-.071-.599-.198-.858l.005.011c-.428-.921-.79-1.132-2.037-1.191l-.81-.032zm10.329 6.913c-.096 0-.141-.009-.155-.03s0-.042.01-.074c.506-1.32.803-2.846.814-4.44v-.005c-.017-1.6-.314-3.126-.844-4.537l.03.092c-.014-.035-.016-.055-.006-.07.023-.034.114-.041.357-.041.314 0 .405.024.455.124.239.654.445 1.435.581 2.239l.011.081c.107.635.168 1.366.168 2.111 0 1.419-.221 2.785-.63 4.068l.026-.095-.182.553-.416.02c-.103.002-.169.005-.219.005zm-4.232 0c-.055 0-.115 0-.195-.006l-.436-.02.234-.351c.582-.888.985-1.943 1.134-3.079l.004-.038c.028-.29.044-.626.044-.967 0-.676-.062-1.338-.182-1.98l.01.066c-.243-.984-.641-1.848-1.171-2.614l.017.026-.104-.143h.924l.176.274c.335.578.617 1.246.812 1.95l.014.061c.188.676.295 1.453.295 2.255 0 1.366-.313 2.659-.871 3.811l.023-.052c-.344.7-.4.807-.73.807zm2.024 0c-.02.002-.042.004-.065.004-.065 0-.128-.01-.187-.03l.004.001c0-.024.135-.309.195-.43.13-.26.262-.577.374-.904l.017-.056c.296-.922.466-1.983.466-3.084 0-1.497-.315-2.92-.883-4.207l.026.067c-.059-.121-.195-.405-.195-.43.056-.019.12-.03.186-.03.022 0 .043.001.064.003h-.003c.06 0 .124 0 .188.006l.436.019.247.615c.442 1.159.698 2.5.698 3.9 0 1.465-.28 2.864-.789 4.148l.027-.076-.18.453-.436.02c-.064 0-.127.005-.188.005z'/%3E%3C/svg%3E");
--dialog-transcript-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 640 640'%3E%3Cpath fill='%23fff' d='M128 128C128 92.7 156.7 64 192 64L341.5 64C358.5 64 374.8 70.7 386.8 82.7L493.3 189.3C505.3 201.3 512 217.6 512 234.6L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 128zM336 122.5L336 216C336 229.3 346.7 240 360 240L453.5 240L336 122.5zM248 320C234.7 320 224 330.7 224 344C224 357.3 234.7 368 248 368L392 368C405.3 368 416 357.3 416 344C416 330.7 405.3 320 392 320L248 320zM248 416C234.7 416 224 426.7 224 440C224 453.3 234.7 464 248 464L392 464C405.3 464 416 453.3 416 440C416 426.7 405.3 416 392 416L248 416z'/%3E%3C/svg%3E");
--dialog-media-inline-size: 72rem;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-max-block-size: 80vh;

// Breakpoints (CSS variables are not allowed in media queries - boo!)

$dialog-breakpoint: 45em;

background-color: transparent;
border: 0;
display: none;
grid-template-rows: auto 1fr;
inline-size: 92vw;
opacity: 0;
padding: 0;
scrollbar-color: var(--primary-color) transparent;
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);

// Use for data-aria-dialog (harmless on native dialog)

block-size: fit-content;
inset: 0;
margin: auto;
max-block-size: var(--dialog-max-block-size);
overflow-y: auto;
position: fixed;
z-index: 2147483646;

  @media (prefers-reduced-motion: no-preference) {

    transition-duration: 400ms;

  }

  &:has(.dialog__media) {
  inline-size: fit-content;
  }

  &[data-has-heading], &[data-has-content] {
  background-color: #fff;
  border-radius: calc(6rem / 16);
  box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
  padding: 1rem;
  }

  &[data-fullscreen] {
  resize: both;
  }

  &[open], &[data-open] {
  display: grid;
  opacity: 1;
  translate: 0 0;

    @starting-style {

      opacity: 0;
      translate: 0 calc(-50rem / 16);

    }

  }

  &::backdrop {
  backdrop-filter: var(--backdrop-filter);
  }

  &__header {
  display: grid;
  grid-auto-flow: column;
  justify-content: space-between;
  margin-block-end: 1rem;
  gap: 1rem;

    &:not(:has(.dialog__heading)) {

      justify-content: end;
    
    }
  
  }

  &__heading {
  font-size: clamp(1rem, 0.68rem + 1.6vw, 1.6rem);
  margin: 0;
  place-self: center;
  }
  
  &__controls {
  align-items: start;
  display: grid;
  gap: calc(8rem / 16);
  grid-auto-flow: column;

    &__btn {
    background-color: transparent;
    background-position: center;
    background-repeat: no-repeat;
    background-size: calc(24rem / 16);
    border-radius: calc(5rem / 16);
    border: 0;
    cursor: pointer;
    padding: 1.1rem;

      [data-has-heading] &, [data-has-content] & {

        background-color: var(--primary-color);
        outline-offset: calc(3rem / 16);

      }

      [data-has-content] & { 

        inset-block-start: 1rem;
        inset-inline-end: 1rem;
        position: absolute;

      }

       &--close {
      background-image: var(--dialog-close-icon);
      }

      &--audio-description {
      background-image: var(--dialog-audio-description-icon);
      }

      &--transcript {
      background-image: var(--dialog-transcript-icon);
      }

    }

  }

  &__container {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  min-block-size: 0;

    @media (prefers-reduced-motion: no-preference) {

      transition-duration: 400ms;

    }

    &:has(.dialog__transcript) {

      @media (min-width: $dialog-breakpoint) {

        grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);

        .dialog__asset, .dialog__content {

          grid-column: 2;

        }

      }

    }

    &.transcript-open {

      @media (min-width: $dialog-breakpoint) {

        grid-template-columns: minmax(0, 0.3fr) minmax(0, 0.7fr);

      }

      .dialog__transcript {
      display: grid;
      opacity: 1;

        @starting-style {

          opacity: 0;

        }

      }

    }

  }

  &__transcript, &__asset {
  border-radius: calc(6rem / 16);
  }

  &__transcript {
  background-color: #f0f5f7;
  block-size: calc(260rem / 16);
  display: none;
  grid-template-rows: max-content minmax(0, 1fr);
  margin-block-end: 0.5rem;
  opacity: 0;
  overflow: hidden;
  padding: 0 0.7rem;
  position: relative;
  transition-behavior: allow-discrete;
  transition-property: display, opacity;

    @media (prefers-reduced-motion: no-preference) {

      transition-duration: 400ms;

    }

    @media (min-width: $dialog-breakpoint) {

      block-size: auto; 
      margin-block-end: 0;
      margin-inline-end: 0.5rem;
      max-block-size: var(--dialog-media-block-size);
      min-block-size: 0;

    }

    p:first-child {
    margin-block-start: 0;
    }

    &--hdr {
    font-size: 1rem;
    margin: 0;
    padding: 1rem 0.5rem 0.5rem;

      @media (min-width: $dialog-breakpoint) {

        font-size: 1.4rem;

      }

    }

    &--content {
    margin-bottom: 1rem;
    mask-image: linear-gradient(to left, black 0 1rem, transparent 1rem), linear-gradient(to bottom, transparent 0, black 2.5rem, black calc(100% - 2.5rem), transparent 100%);
    overflow-x: hidden;
    overflow-y: auto;
    padding: 1rem 1rem 1rem 0.5rem;
    scrollbar-color: #0f1632 transparent;
    }

  }

  &__asset {
  background-color: #000;
  min-block-size: 0;
  overflow: hidden;
  }

  &__media {
  aspect-ratio: 16 / 9;
  background-color: transparent;
  block-size: 100%;
  border: 0;
  display: block;
  inline-size: var(--dialog-media-inline-size);
  max-inline-size: 100%;
  object-fit: contain;
  opacity: 1;
  transition-property: opacity;

    [data-fullscreen] & {

      inline-size: 100vw;

    }

    [data-youtube-shorts] & {

      aspect-ratio: 9 / 16;
      inline-size: var(--dialog-media-inline-size-shorts);

    }

    // Address Poster FOUC on iframe videos, specifically YouTube. 

    @media (prefers-reduced-motion: no-preference) {

      transition-delay: 500ms;
      transition-duration: 300ms;

    }

    @starting-style {

      opacity: 0;

    }

  }

}
/*!

  Radancy Component Library: Dialog

  Contributor(s):
  Michael "Spell" Spellacy

*/

(() => {

  "use strict";

  const loadLanguagePack = (url, callback) => {

    // Install Language Pack.

    const getComponentLanguagePack = document.getElementById("component-library-language-pack");

    if (!getComponentLanguagePack) {

      const componentLanguagePack = document.createElement("script");

      componentLanguagePack.setAttribute("src", url);
      componentLanguagePack.setAttribute("id", "component-library-language-pack");
      componentLanguagePack.addEventListener("load", callback);

      document.head.appendChild(componentLanguagePack);

    } else if (typeof componentLibraryLanguagePackLoaded !== "undefined") {

      callback();

    } else {

      getComponentLanguagePack.addEventListener("load", callback);

    }

  };

  const initDialog = () => {

    loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {

      // Display which version is in use via console:

      console.log("%cDialog%cv1.0.0", "background: #2d2d2d; color: #fff; padding: 6px 10px; border-radius: 16px 0 0 16px; font-weight: 600;" , "background: #6e00ee; color: #fff; padding: 6px 10px; border-radius: 0 16px 16px 0; font-weight: 600;");

      // Classes, data attributes, states, and strings.

      const dialogTriggerClass = ".dialog-trigger";
      const dialogDataPlayButton = "data-play-button";
      const dialogTriggerPlayClassName = "dialog-trigger__play";
      const dialogDataLabel = "data-label";
      const dialogDataLabelledby = "data-labelledby";
      const dialogDataCaption = "data-caption";
      const dialogDataDescription = "data-audio-description";
      const dialogDataHeading = "data-enable-heading";
      const dialogDataHasHeading = "data-has-heading";
      const dialogDataHasContent = "data-has-content";
      const dialogDataTranscriptId = "data-transcript-id";
      const dialogDataTranscriptUrl = "data-transcript-url";
      const dialogDataDisableAutoplay = "data-disable-autoplay";
      const dialogDataYoutubeShorts = "data-youtube-shorts";
      const dialogDataDynamicLabel = "data-dynamic-label";
      const dialogDataDynamicAlt = "data-dynamic-alt";
      const dialogDataAriaDialog = "data-aria-dialog";
      const dialogDataSrc = "data-src";
      const dialogDataOpenState = "data-open";
      const dialogBackdropClassName = "dialog-backdrop";
      const dialogClassName = "dialog";
      const dialogHeadingClassName = "dialog__heading";
      const dialogHeaderClassName = "dialog__header";
      const dialogControlsClassName = "dialog__controls";
      const dialogControlsBtnClassName = "dialog__controls__btn";
      const dialogControlsCloseClassName = "dialog__controls__btn--close";
      const dialogControlsAudioDescriptionClassName = "dialog__controls__btn--audio-description";
      const dialogControlsTranscriptClassName = "dialog__controls__btn--transcript";
      const dialogContainerClassName = "dialog__container";
      const dialogContainerOpenState = "transcript-open";
      const dialogTranscriptClassName = "dialog__transcript";
      const dialogTranscriptHeadingClassName = "dialog__transcript--hdr";
      const dialogTranscriptContentClassName = "dialog__transcript--content";
      const dialogAssetClassName = "dialog__asset";
      const dialogContentClassName = "dialog__content";
      const dialogMediaClassName = "dialog__media";

      // Labels. Translation-ready — not yet wired to the language pack.

      const dialogCloseVideoLabel = "Close Video";
      const dialogCloseLabel = "Close";
      const dialogAudioDescriptionLabel = "Audio Description";
      const dialogTranscriptHeadingLabel = "Transcript";
      const dialogTranscriptButtonLabel = "Video Transcript";
      const dialogVideoLabel = "Video";
      const dialogVideoSuffixLabel = "(Video)";
      const dialogLoadingTranscriptLabel = "Loading transcript…";
      const dialogTranscriptNotFoundLabel = "Transcript not found.";
      const dialogTranscriptFailedLabel = "Transcript failed to load.";
      const dialogContentNotFoundLabel = "Content not found.";
      const dialogMissingNameHeadingLabel = "Accessible Name Missing";
      const dialogMissingNameMessageLabel = "An accessible name must be provided. Add data-label with a descriptive value, data-labelledby pointing to an id already present on the page, or data-dynamic-label to fetch one automatically.";
      const dialogVideoFallbackLabel = "Video Player";

      const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
      const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

      // Terms/patterns used to infer a dialog's content type from data-dialog-src,
      // checked in order. Add new entries here as new source types need support —
      // anything that matches none of these is treated as an "element" id.

      const dialogTypeDetectors = [

        { type: "youtube", test: src => src.includes("youtube") },
        { type: "vimeo", test: src => src.includes("vimeo") },
        { type: "cloudflare", test: src => src.includes("cloudflarestream") },
        { type: "brightcove", test: src => src.includes("players.brightcove.net") },
        { type: "video", test: src => /\.(mp4|webm|ogv)($|[?#])/i.test(src) },

      ];

      // Shared by the "video" case below and the hover/focus/touch preload
      // warmer, so both agree on what MIME type a given file extension gets.

      const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };

      // Running counter used to give a dialog an id when no label is available to slugify.

      let dialogInstanceId = 0;

      const detectDialogType = (src) => {

        return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";

      };

      // Timestamp of the last dialog close — see the trigger click handler
      // below for why this exists. Works around a Chrome/Firefox bug, not
      // anything specific to our markup: https://issues.chromium.org/issues/425579196
      // Chrome has a fix slated for v154; Firefox's bug has no fix date yet
      // (https://github.com/mdn/browser-compat-data/issues/30474). Once both
      // ship the fix this guard just goes dormant — safe to leave in place.

      let dialogLastClosedAt = 0;

      const destroyDialog = (dialog) => {

        // Escape and light-dismiss (closedby="any") both fire "cancel" first;
        // our handler for that prevents the default and calls this function
        // directly, then its own dialog.close() below fires "close", which
        // calls this function again. This guard makes the second call a
        // no-op instead of re-running teardown (pausing video twice, running
        // restore callbacks twice, etc.) — command="close" only ever fires
        // "close" on its own, so it never hits this twice in the first place.

        if (dialog.dialogDestroyed) return;

        dialog.dialogDestroyed = true;
        dialogLastClosedAt = Date.now();

        // Pause HTML5 video and reset.

        dialog.querySelectorAll("video").forEach(video => {

          video.pause();
          video.currentTime = 0;

          // Stop any in-progress audio-description narration.

          Array.from(video.textTracks).forEach(track => {

            if (track.kind === "descriptions") {

              track.oncuechange = null;
              track.mode = "disabled";

            }

          });

        });

        window.speechSynthesis?.cancel();

        // Restore whatever body scroll was before this dialog locked it —
        // native dialogs don't lock scroll on their own (unlike focus and
        // background inertness, which come free with top-layer semantics),
        // so we set it ourselves for both paths; the restore is the same
        // either way.

        document.body.style.overflow = dialog.dialogPreviousBodyOverflow;

        if (dialog.dialogClassic) {

          // Classic (non-native) fallback teardown: undo everything openClassicDialog
          // set up, since there's no native close() to do it for us.

          document.removeEventListener("keydown", dialog.dialogKeydownHandler);

          dialog.dialogInertedSiblings?.forEach(el => el.removeAttribute("inert"));

          dialog.dialogBackdrop?.remove();

          dialog.removeAttribute(dialogDataOpenState);

          dialog.dialogTriggerElement?.focus();

        } else {

          dialog.close();

        }

        // Wait for the CSS close transition to finish before removing the element,
        // otherwise it's torn out of the DOM before the browser can animate it out.
        // A timeout backs up transitionend, since discrete-property transitions
        // (display/overlay) can fail to fire it under some interaction sequences.

        let removed = false;

        const finalizeRemoval = () => {

          if (removed) return;

          removed = true;

          // Stop YouTube/Vimeo iframes by resetting src — held off until now
          // (rather than done eagerly at the top of this function) so the
          // loaded frame stays intact and fades out with the rest of the
          // dialog instead of blanking to black the instant dismissal starts.

          dialog.querySelectorAll("iframe").forEach(iframe => {

            iframe.src = "";

          });

          // Return any moved-in source elements to where they came from, only now
          // that the dialog is actually leaving the DOM — doing this any earlier
          // pulls the content out from under the still-visible closing transition.

          dialog.dialogRestoreCallbacks?.forEach(restore => restore());

          dialog.remove();

        };

        dialog.addEventListener("transitionend", finalizeRemoval, { once: true });

        const transitionMs = parseFloat(getComputedStyle(dialog).transitionDuration) * 1000;

        setTimeout(finalizeRemoval, (Number.isFinite(transitionMs) ? transitionMs : 0) + 100);

      };

      // data-aria-dialog fallback: everything <dialog>/showModal() gives us for
      // free (fixed/backdrop positioning aside, handled in CSS) that a plain
      // role="dialog" div needs done by hand — a backdrop element, background
      // inertness (this also traps focus inside, since inert elements can't be
      // focused), scroll lock, Escape support, and focus restored to the trigger.

      const openClassicDialog = (dialog, triggerElement, focusTarget) => {

        const backdrop = document.createElement("div");

        backdrop.className = dialogBackdropClassName;

        document.body.append(backdrop, dialog);

        const invertedSiblings = Array.from(document.body.children).filter(el => el !== dialog && el !== backdrop);

        invertedSiblings.forEach(el => el.setAttribute("inert", ""));

        dialog.dialogClassic = true;
        dialog.dialogBackdrop = backdrop;
        dialog.dialogInertedSiblings = invertedSiblings;
        dialog.dialogTriggerElement = triggerElement;
        dialog.dialogPreviousBodyOverflow = document.body.style.overflow;

        document.body.style.overflow = "hidden";

        backdrop.addEventListener("click", () => destroyDialog(dialog));

        dialog.dialogKeydownHandler = (e) => {

          if (e.key === "Escape") destroyDialog(dialog);

        };

        document.addEventListener("keydown", dialog.dialogKeydownHandler);

        dialog.setAttribute(dialogDataOpenState, "");

        (focusTarget || dialog).focus();

      };

      // Move a hidden source element into the dialog; returns a function that restores it in place.

      const moveIntoDialog = (el) => {

        const anchor = document.createComment("");

        el.before(anchor);
        el.hidden = false;

        return () => {

          el.hidden = true;
          anchor.replaceWith(el);

        };

      };

      // Parse a "src, label, srclang, default; src, label, srclang" caption string into track descriptors.

      const parseCaptions = (value) => {

        return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {

          const [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());

          return { src: trackSrc, label: trackLabel, srclang, default: defaultFlag?.toLowerCase() === "default" };

        });

      };

      // Parse a single "src, label, srclang" description string into a track descriptor.

      const parseDescription = (value) => {

        const [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());

        return { src: trackSrc, label: trackLabel, srclang };

      };

      // Turn heading text into a URL-safe slug for use as an id.

      const slugify = (value) => {

        return value.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-");

      };

      // EXPERIMENTAL (data-dynamic-label): look up a YouTube/Vimeo video's real
      // title via its oEmbed endpoint, for use as the dialog/heading/alt text
      // when no data-dialog-label is authored. Falls back to a generic
      // "Video Player" placeholder on any failure.

      const fetchDynamicVideoTitle = async (type, src) => {

        try {

          const oembedUrl = type === "youtube"? `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/watch?v=${new URL(src).pathname.split("/").pop()}`)}&format=json`: `https://vimeo.com/api/oembed.json?url=${encodeURIComponent(src)}`;

          const response = await fetch(oembedUrl);

          if (!response.ok) return dialogVideoFallbackLabel;

          const data = await response.json();

          return data.title || dialogVideoFallbackLabel;

        } catch {

          return dialogVideoFallbackLabel;

        }

      };

      // YouTube's oEmbed response has no explicit "is this a Short" field,
      // but Shorts are inherently vertical/square, so its width/height come
      // back portrait — as long as it's asked about via a /shorts/ URL
      // specifically. Querying the exact same Short via /watch?v= instead
      // silently returns the wrong (landscape) dimensions — confirmed by
      // testing both formats directly. /shorts/ is safe to use
      // unconditionally though: a genuinely non-Short video still reports
      // its real (landscape) size correctly either way. Sets
      // data-youtube-shorts on the dialog so CSS can hook off it; silently
      // does nothing on failure, since this is a styling enhancement, not
      // something the dialog depends on to function.

      const flagYoutubeShorts = async (dialog, src) => {

        try {

          const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(`https://www.youtube.com/shorts/${new URL(src).pathname.split("/").pop()}`)}&format=json`;
          const response = await fetch(oembedUrl);

          if (!response.ok) return;

          const data = await response.json();

          if (data.height > data.width) dialog.setAttribute(dialogDataYoutubeShorts, "");

        } catch {}

      };

      // Fetch a same-domain page and pull in the element matching its #fragment id.

      const fetchTranscriptFragment = async (url) => {

        const [path, id] = url.split("#");

        const response = await fetch(path);

        if (!response.ok || !id) return null;

        const html = await response.text();
        const fragmentDoc = new DOMParser().parseFromString(html, "text/html");
        const el = fragmentDoc.getElementById(id);

        return el ? document.importNode(el, true) : null;

      };

      // Give the transcript region a tabindex only when it has no focusable link of its own.

      const applyTranscriptTabindex = (transcriptTarget) => {

        if (transcriptTarget.querySelector("a")) {

          transcriptTarget.removeAttribute("tabindex");

        } else {

          transcriptTarget.setAttribute("tabindex", "0");

        }

      };

      // Load a transcript (from an in-page element or a fetched fragment) into a target node, once.

      const loadTranscriptOnce = (transcriptTarget, { transcript, transcriptUrl }, restoreCallbacks) => {

        if (transcript) {

          const transcriptEl = document.getElementById(transcript);

          if (transcriptEl) {

            restoreCallbacks.push(moveIntoDialog(transcriptEl));
            transcriptTarget.append(transcriptEl);

          } else {

            console.error(`Dialog transcript element with id "${transcript}" not found.`);

          }

          applyTranscriptTabindex(transcriptTarget);

          return;

        }

        const placeholder = document.createElement("p");

        placeholder.textContent = dialogLoadingTranscriptLabel;

        transcriptTarget.append(placeholder);

        fetchTranscriptFragment(transcriptUrl).then(fragment => {

          if (fragment) {

            placeholder.replaceWith(fragment);

          } else {

            placeholder.textContent = dialogTranscriptNotFoundLabel;
            console.error(`Dialog transcript URL "${transcriptUrl}" did not resolve to a matching element.`);

          }

          applyTranscriptTabindex(transcriptTarget);

        }).catch(() => {

          placeholder.textContent = dialogTranscriptFailedLabel;
          console.error(`Dialog transcript URL "${transcriptUrl}" failed to load.`);

          applyTranscriptTabindex(transcriptTarget);

        });

      };

      // Wire up the audio-description toggle button: speech-synthesizes description cues, pausing/resuming the video.

      const attachAudioDescription = (video, controls) => {

        const audioDescBtn = document.createElement("button");

        audioDescBtn.setAttribute("aria-label", dialogAudioDescriptionLabel);
        audioDescBtn.className = `${dialogControlsBtnClassName} ${dialogControlsAudioDescriptionClassName}`;
        audioDescBtn.setAttribute("aria-pressed", "false");

        audioDescBtn.addEventListener("click", () => {

          const descTrack = Array.from(video.textTracks).find(t => t.kind === "descriptions");

          if (!descTrack) return;

          const isPressed = audioDescBtn.getAttribute("aria-pressed") === "true";

          if (!isPressed) {

            audioDescBtn.setAttribute("aria-pressed", "true");

            descTrack.mode = "hidden";

            descTrack.oncuechange = () => {

              const currentCue = descTrack.activeCues[0];

              if (currentCue && !video.paused && !video.seeking) {

                const utterance = new SpeechSynthesisUtterance(currentCue.getCueAsHTML().textContent);

                video.pause();

                utterance.onend = () => {

                  if (video.isConnected) video.play();

                };

                window.speechSynthesis.speak(utterance);

              }

            };

          } else {

            audioDescBtn.setAttribute("aria-pressed", "false");

            descTrack.oncuechange = null;
            descTrack.mode = "disabled";

            window.speechSynthesis.cancel();

            video.play();

          }

        });

        controls.append(audioDescBtn);

      };

      // Build the full "media" dialog structure (header + controls, transcript panel, asset) —
      // shared by video/youtube/vimeo (assetClassName dialogAssetClassName) and element/default
      // (assetClassName dialogContentClassName, no video-specific decoration on the content itself).

      const buildMediaDialog = (assetContent, { heading, label, labelledby, hasDescription, transcript, transcriptUrl, classic, assetClassName = dialogAssetClassName, closeLabel = dialogCloseVideoLabel, restoreCallbacks = [] }) => {

        const titleText = label || dialogVideoLabel;
        const baseId = label ? slugify(label) : `dialog-${++dialogInstanceId}`;

        const dialog = document.createElement(classic ? "div" : "dialog");

        dialog.className = dialogClassName;
        dialog.id = baseId;

        if (classic) {

          dialog.setAttribute("role", "dialog");
          dialog.setAttribute("aria-modal", "true");

          // No tabindex here — we never apply focus to the dialog element
          // itself, only to content inside it (the close button, typically).

        } else {

          dialog.setAttribute("closedby", "any");

        }

        let h1;

        if (heading) {

          dialog.setAttribute(dialogDataHasHeading, "");

          h1 = document.createElement("h1");

          h1.id = `hdr-${baseId}`;
          h1.className = dialogHeadingClassName;
          h1.textContent = titleText;

        }

        if (labelledby) {

          dialog.setAttribute("aria-labelledby", labelledby);

        } else if (heading) {

          dialog.setAttribute("aria-labelledby", `hdr-${baseId}`);

        } else {

          dialog.setAttribute("aria-label", titleText);

        }

        const header = document.createElement("div");

        header.className = dialogHeaderClassName;

        const controls = document.createElement("div");

        controls.className = dialogControlsClassName;

        const closeBtn = document.createElement("button");

        closeBtn.setAttribute("aria-label", closeLabel);
        closeBtn.className = `${dialogControlsBtnClassName} ${dialogControlsCloseClassName}`;

        if (classic) {

          closeBtn.addEventListener("click", () => destroyDialog(dialog));

        } else {

          closeBtn.setAttribute("command", "close");
          closeBtn.setAttribute("commandfor", baseId);

          // Native command="close" drives this button, rather than a click
          // listener calling destroyDialog directly, so it shares the exact
          // same close path as Escape/light-dismiss — all three end up going
          // through the dialog's own "close" event listener below.

        }

        controls.append(closeBtn);

        if (hasDescription && assetContent.tagName === "VIDEO") {

          attachAudioDescription(assetContent, controls);

        }

        const container = document.createElement("div");

        container.className = dialogContainerClassName;

        if (transcript || transcriptUrl) {

          const transcriptPanel = document.createElement("div");

          transcriptPanel.className = dialogTranscriptClassName;

          const h2 = document.createElement("h2");

          h2.id = `${baseId}--transcript`;
          h2.className = dialogTranscriptHeadingClassName;
          h2.textContent = dialogTranscriptHeadingLabel;

          const transcriptContent = document.createElement("div");

          transcriptContent.id = `${baseId}--transcript-content`;
          transcriptContent.className = dialogTranscriptContentClassName;
          transcriptContent.setAttribute("aria-labelledby", h2.id);
          transcriptContent.setAttribute("role", "region");

          transcriptPanel.append(h2, transcriptContent);
          container.append(transcriptPanel);

          // Move/fetch the transcript into place now, while the dialog is
          // still hidden, rather than waiting for the first time the panel
          // is toggled open. Moving the (real, existing) transcript element
          // at that exact moment was producing a brief visible flash/shift
          // in the video — doing it up front means nothing new happens in
          // the DOM at the moment the user actually sees the toggle happen.

          loadTranscriptOnce(transcriptContent, { transcript, transcriptUrl }, restoreCallbacks);

          const transcriptBtn = document.createElement("button");

          transcriptBtn.setAttribute("aria-label", dialogTranscriptButtonLabel);
          transcriptBtn.className = `${dialogControlsBtnClassName} ${dialogControlsTranscriptClassName}`;
          transcriptBtn.setAttribute("aria-expanded", "false");
          transcriptBtn.setAttribute("aria-controls", transcriptContent.id);

          transcriptBtn.addEventListener("click", () => {

            const isOpen = container.classList.toggle(dialogContainerOpenState);

            transcriptBtn.setAttribute("aria-expanded", String(isOpen));

          });

          controls.append(transcriptBtn);

        }

        if (h1) header.append(h1);

        header.append(controls);

        if (assetClassName === dialogAssetClassName) {

          assetContent.classList.add(dialogMediaClassName);

          if (assetContent.tagName === "VIDEO") {

            assetContent.setAttribute("aria-label", `${titleText} ${dialogVideoSuffixLabel}`);

          } else {

            assetContent.title = `${titleText} ${dialogVideoSuffixLabel}`;

          }

        }

        const asset = document.createElement("div");

        asset.className = assetClassName;
        asset.append(assetContent);

        container.append(asset);

        dialog.append(header, container);

        dialog.dialogRestoreCallbacks = restoreCallbacks;

        return dialog;

      };

      // Create and show dialog dynamically based on type.

      const openDialog = (type, src, { label, labelledby, dynamicLabel, caption, description, heading, transcript, transcriptUrl, disableAutoplay, classic } = {}) => {

        const triggerElement = document.activeElement;

        let dialog;

        if (!label && !labelledby && !dynamicLabel) {

          // Failsafe: never open a dialog with no accessible name. By the time
          // this runs, an in-progress data-dynamic-label lookup has already
          // resolved label to a real title or its placeholder, but dynamicLabel
          // is still checked directly in case that lookup wasn't supported for
          // this dialog type and never got the chance to set label. This only
          // fires when none of data-label, data-labelledby, or data-dynamic-label
          // were authored — a developer mistake, not something an end user can
          // hit legitimately.

          const contentNode = document.createElement("p");

          contentNode.textContent = dialogMissingNameMessageLabel;

          dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, classic, assetClassName: dialogContentClassName, closeLabel: dialogCloseLabel });
          dialog.setAttribute(dialogDataHasContent, "");

        } else switch (type) {

          case "video": {

            const video = document.createElement("video");

            video.controls = true;
            video.crossOrigin = "anonymous";

            const source = document.createElement("source");
            const extension = src.match(/\.(mp4|webm|ogv)($|[?#])/i)?.[1].toLowerCase();

            source.src = src;
            source.type = videoMimeTypes[extension] ?? "video/mp4";

            video.append(source);

            if (caption) {

              parseCaptions(caption).forEach(({ src: trackSrc, label: trackLabel, srclang, default: isDefault }) => {

                const track = document.createElement("track");

                track.kind = "captions";
                track.src = trackSrc;
                track.label = trackLabel;
                track.srclang = srclang;
                track.default = isDefault;

                video.append(track);

              });

            }

            if (description) {

              const { src: trackSrc, label: trackLabel, srclang } = parseDescription(description);

              const track = document.createElement("track");

              track.kind = "descriptions";
              track.src = trackSrc;
              track.label = trackLabel;
              track.srclang = srclang;

              video.append(track);

            }

            dialog = buildMediaDialog(video, { heading, label, labelledby, hasDescription: Boolean(description), transcript, transcriptUrl, classic });

            break;

          }

          case "youtube": {

            const iframe = document.createElement("iframe");

            // Some videos (Shorts among them, but not only Shorts) ignore the
            // passive autoplay=1 URL param on youtube.com and show YouTube's
            // "Watch on YouTube" click-through card instead of actually
            // playing, even though the exact same video autoplays fine
            // elsewhere. www.youtube-nocookie.com honors an explicit IFrame
            // API play command where youtube.com doesn't — confirmed by
            // testing both domains with identical timing, only the domain
            // made the difference — so normalize to it regardless of which
            // host was authored, and request playback as a command once the
            // player's loaded rather than a passive load-time flag.

            iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=1&disablekb=1&cc_load_policy=1&fs=1&rel=0&hd=1&wmode=transparent&enablejsapi=1&html5=1`;
            iframe.allow = "autoplay; fullscreen";

            if (!disableAutoplay) {

              iframe.addEventListener("load", () => {

                iframe.contentWindow.postMessage(JSON.stringify({ event: "command", func: "playVideo", args: "" }), new URL(iframe.src).origin);

              });

            }

            dialog = buildMediaDialog(iframe, { heading, label, labelledby, transcript, transcriptUrl, classic });

            flagYoutubeShorts(dialog, src);

            break;

          }

          case "vimeo": {

            const iframe = document.createElement("iframe");

            iframe.src = `${src}?autoplay=${disableAutoplay ? 0 : 1}`;
            iframe.allow = "autoplay; fullscreen";
            iframe.allowFullscreen = true;

            dialog = buildMediaDialog(iframe, { heading, label, labelledby, transcript, transcriptUrl, classic });

            break;

          }

          case "cloudflare": {

            const iframe = document.createElement("iframe");

            // Unlike the YouTube/Vimeo embed URLs above, a Cloudflare Stream src
            // often already carries its own query string (poster, title, etc.),
            // so autoplay has to be joined with "&" rather than assuming "?".

            const separator = src.includes("?") ? "&" : "?";

            iframe.src = `${src}${separator}autoplay=${disableAutoplay ? "false" : "true"}`;
            iframe.allow = "accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture; fullscreen";
            iframe.allowFullscreen = true;

            dialog = buildMediaDialog(iframe, { heading, label, labelledby, transcript, transcriptUrl, classic });

            break;

          }

          case "brightcove": {

            const iframe = document.createElement("iframe");

            // Same reasoning as Cloudflare above: a Brightcove Player src is a
            // complete, standalone embed URL, so autoplay is joined with "&"
            // when a query string is already present. "any" attempts autoplay
            // with sound and falls back to muted only if the browser blocks
            // it — the closest match to how autoplay behaves elsewhere here.

            const separator = src.includes("?") ? "&" : "?";

            iframe.src = disableAutoplay ? src : `${src}${separator}autoplay=any`;
            iframe.allow = "encrypted-media; autoplay; fullscreen";
            iframe.allowFullscreen = true;

            dialog = buildMediaDialog(iframe, { heading, label, labelledby, transcript, transcriptUrl, classic });

            break;

          }

          case "element": {

            const el = document.getElementById(src);
            const restoreCallbacks = [];

            let contentNode;

            if (el) {

              restoreCallbacks.push(moveIntoDialog(el));
              contentNode = el;

            } else {

              contentNode = document.createElement("p");
              contentNode.textContent = dialogContentNotFoundLabel;

            }

            dialog = buildMediaDialog(contentNode, { heading, label, labelledby, transcript, transcriptUrl, classic, assetClassName: dialogContentClassName, closeLabel: dialogCloseLabel, restoreCallbacks });
            dialog.setAttribute(dialogDataHasContent, "");

            break;

          }

        }

        if (classic) {

          const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);

          openClassicDialog(dialog, triggerElement, focusTarget);

        } else {

          dialog.addEventListener("cancel", (e) => {

            e.preventDefault(); // Prevent default to avoid exception in some browsers.

            destroyDialog(dialog);

          });

          dialog.addEventListener("close", () => destroyDialog(dialog));

          document.body.appendChild(dialog);

          // showModal() traps focus and makes the background inert for free,
          // but doesn't lock body scroll — do that ourselves, same as the
          // classic fallback below.

          dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
          document.body.style.overflow = "hidden";

          dialog.showModal();

        }

        if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();

      };

      // Loop through and set up all dialog triggers on the page.

      dialogTriggers.forEach(trigger => {

        trigger.setAttribute("aria-haspopup", "dialog");

        // Add play-button indicator, if requested.

        if (trigger.hasAttribute(dialogDataPlayButton)) {

          const playButton = document.createElement("span");

          playButton.className = dialogTriggerPlayClassName;
          playButton.setAttribute("aria-hidden", "true");

          trigger.append(playButton);

        }

        // Read trigger configuration from its data attributes.

        let label = trigger.getAttribute(dialogDataLabel);

        const labelledby = trigger.getAttribute(dialogDataLabelledby);
        const caption = trigger.getAttribute(dialogDataCaption);
        const description = trigger.getAttribute(dialogDataDescription);
        const heading = trigger.hasAttribute(dialogDataHeading);
        const transcript = trigger.getAttribute(dialogDataTranscriptId);
        const transcriptUrl = trigger.getAttribute(dialogDataTranscriptUrl);
        const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
        const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
        const classic = trigger.hasAttribute(dialogDataAriaDialog);
        const type = detectDialogType(trigger.getAttribute(dialogDataSrc));

        // EXPERIMENTAL (data-dynamic-label): kick off the oEmbed title lookup on page
        // load; the trigger's click handler waits on this before opening the dialog.

        let dynamicLabelReady = Promise.resolve();

        if (dynamicLabel && (type === "youtube" || type === "vimeo")) {

          dynamicLabelReady = fetchDynamicVideoTitle(type, trigger.getAttribute(dialogDataSrc)).then(title => {

            label = title;

            const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);

            if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;

          });

        }

        // Warn if the trigger has no accessible name and none is coming asynchronously.

        if (!dynamicLabel && !label && !labelledby) {

          console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby, or data-dynamic-label.", trigger);

        }

        // Warn if a "video" type trigger has no captions configured.

        if (type === "video" && !caption) {

          console.warn(`Warning: Please ensure that your video ("${trigger.getAttribute(dialogDataSrc)}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);

        }

        // Give the browser a network head start on self-hosted video files as
        // soon as there's real signal the user is about to open the dialog,
        // rather than paying for it unconditionally (link rel=preload in the
        // page's own head) or not at all until the click itself. Only applies
        // to "video" — the other types are third-party iframe pages, not a
        // video file we can meaningfully preload this way. as="fetch" rather
        // than the more semantically correct as="video": Chrome recognizes
        // "video" as a valid destination but has never actually implemented
        // preloading for it (silently a no-op, console warning and all) —
        // https://issues.chromium.org/issues/40671675. "fetch" still lands
        // the response in the same HTTP cache a subsequent <video> read from.

        if (type === "video") {

          let warmed = false;

          const warmVideoPreload = () => {

            if (warmed) return;

            warmed = true;

            const videoSrc = trigger.getAttribute(dialogDataSrc);
            const extension = videoSrc.match(/\.(mp4|webm|ogv)($|[?#])/i)?.[1].toLowerCase();

            const link = document.createElement("link");

            link.rel = "preload";
            link.as = "fetch";
            link.href = videoSrc;
            link.type = videoMimeTypes[extension] ?? "video/mp4";
            link.crossOrigin = "anonymous"; // Must match the <video>'s crossOrigin below, or the cache entry won't be reused.

            document.head.append(link);

          };

          ["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));

        }

        // Handle trigger click.

        trigger.addEventListener("click", () => {

          // On touch devices, tapping a trigger while its own dialog is open
          // (e.g. to dismiss it) can both light-dismiss the dialog (native
          // closedby="any") AND deliver a synthesized click to this same
          // trigger, now newly revealed underneath that exact point —
          // reopening the dialog the same gesture just closed. This is a
          // known Chrome/Firefox bug (the dismissing tap's click leaks
          // through to whatever's underneath, not just this trigger —
          // https://issues.chromium.org/issues/425579196), so this only
          // covers the most common case rather than the general one.
          // Ignore a click landing immediately after a dialog closed; a
          // deliberate second tap will always be well outside this window.
          // Note-to-self: Remove when this is better supported by browsers

          if (Date.now() - dialogLastClosedAt < 500) return;

          dynamicLabelReady.then(() => {

            openDialog(type, trigger.getAttribute(dialogDataSrc), { label, labelledby, dynamicLabel, caption, description, heading, transcript, transcriptUrl, disableAutoplay, classic });

          });

        });

      });

    });

  };

  initDialog();

})();

Report Issues

Find a bug? Want a new feature? Report it on JIRA. You're so awesome!