Dialog v1.2.4
An accessible, easy-to-implement modal for video and other content, replacing third-party components such as Fancybox.
Accessibility: To ensure better accessibility, dialog labeling is strictly enforced by the script, and content will not load unless one of the supported labeling methods is used: Label, Label by ID, or Dynamic Label. A modal dialog's label should describe its overall content.
Reminder: All features can be used with either a button or a hyperlink. The hyperlink uses href instead of data-src, providing a fallback to a user-friendly alternative if the script fails or is disrupted. An href attribute will not work on a button element.
<button class="dialog-trigger" data-label="Radancy Expands AI Platform with Advanced Recruiter and Candidate AI Agents" data-src="https://youtube.com/embed/PnDd0O6vIts">Button Trigger</button>
<a class="dialog-trigger" data-caption="https://radancy.dev/component-library/dialog/asset/the-last-jedi-en.vtt, English, en, default" data-label="Star Wars: The Last Jedi Trailer" href="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Hyperlink Trigger</a>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: This example showcases most of the features available in this component. While some are visually apparent, others work behind the scenes to benefit users with disabilities.
<button class="dialog-trigger" 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-description="This is an example description. When needed, they should suuply supplemental, detailed explanation rather than a short name or primary label." data-enable-heading data-label="Star Wars: The Last Jedi Trailer" data-poster="https://radancy.dev/component-library/dialog/asset/the-last-jedi.jpg" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.mp4" data-transcript-fetch="https://radancy.dev/component-library/dialog/asset/the-last-jedi.html#transcript">Kitchen Sink Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-src attribute to a URL followed by a #fragment identifier to fetch content from a same-domain page and insert the children of the element matching that identifier. The source element's own ID and any styles on the source page that specifically target it are not preserved.
Best Practice: This may be useful in a CMS environment, where an author cannot directly edit the contents of a dialog.
<button class="dialog-trigger" data-labelledby="hdr-my-content" data-src="https://radancy.dev/component-library/dialog/asset/content-fetch.html#my-content">Fetch Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();My Awesome Content
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit.
Note: Set the data-src attribute to the ID of hidden content elsewhere on the page to use as the dialog's content. The source element's own ID and any styles that specifically target it are not preserved.
<button class="dialog-trigger" data-labelledby="my-awesome-heading" data-src="my-awesome-content">Fragment Example</button>
<div id="my-awesome-content" hidden>
<h1 id="my-awesome-heading">My Awesome Content</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
<p>Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit.
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
<p>Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit.
</div>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-iframe-src attribute to load a URL directly into an iframe within the dialog. Takes precedence over data-src.
<button class="dialog-trigger" data-label="Web Resource Example" data-iframe-src="https://radancy.dev/component-library/dialog/asset/content-iframe.html">Web Resource Example</button>
<button class="dialog-trigger" data-label="Radancy Headquarters" data-iframe-src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d6049.3295658269935!2d-74.0163814235868!3d40.70337983814272!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x89c25a148a2257b5%3A0x4d7089bee29e03bf!2sRadancy!5e0!3m2!1sen!2sus!4v1790709678034!5m2!1sen!2sus">Google Maps Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-audio-description attribute to a src, label, srclang value referencing a valid third-party or self-produced VTT file to add an audio description track. For self-hosted video, these parameters can alternatively be included in the data-src query string (e.g., video.mp4?src=file.vtt&label=English&srclang=en). Use this approach only when a separate attribute isn't practical.
Accessibility: Audio descriptions are only necessary when meaningful visual information, such as on-screen text, actions, or scene changes, isn't conveyed through existing dialogue or sound. When needed, they provide a separate narration track that describes this content during natural pauses in the audio.
<button class="dialog-trigger" data-audio-description="https://radancy.dev/component-library/dialog/asset/the-last-jedi-desc-en.vtt, English, en-US" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Audio Description Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-caption attribute to a src, label, srclang, default. The optional (but strongly recommended) default flag displays the captions automatically. Multiple caption tracks are supported by separating each group with a semicolon (e.g., src, label, srclang, default; src, label, srclang). Alternatively, label, srclang, and default can be specified as query parameters on the VTT URL (e.g., english.vtt?label=English&srclang=en&default; french.vtt?label=Français&srclang=fr) when a separate attribute isn't practical.
Accessibility: All videos must include captions, even those without dialogue, to convey meaningful audio such as music and sound effects. Captions benefit everyone, including second-language learners, people with attention or auditory-processing differences, and viewers watching without sound. For details, see this guide to captioned videos. Videos that are missing captions will result in a console warning and reminder.
<button class="dialog-trigger" data-caption="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back-en.vtt, English, en, default; https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back-es.vtt, Espanol, es" data-label="Star Wars: The Empire Strikes Back Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.webm">Caption Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-disable-autoplay attribute to prevent the video from autoplaying when the dialog opens. The video can still be started manually using its own play control.
Accessibility: Autoplaying video can disorient users with vestibular disorders or motion sensitivity, distract users with attention-related conditions, and interfere with screen reader use, do disabling them is often recommended, though not mandatory in WCAG. However, the dialog component automatically respects OS-level motion preferences and all video wil be paused by default under these conditions.
<button class="dialog-trigger" data-disable-autoplay data-label="Radancy Expands AI Platform with Advanced Recruiter and Candidate AI Agents" data-src="https://youtube.com/embed/PnDd0O6vIts">Disable Autoplay Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-description attribute to a plain text string to use as the dialog's accessible description. The text is inserted into a hidden element referenced by aria-describedby.
Accessibility: This text is exposed only to assistive technology users, and is only meant to act as supplementary information. Keep it to one or two sentences that provide context beyond the dialog's name, rather than repeating its label or heading, which screen readers announce first.
<button class="dialog-trigger" data-label="Description Example" data-description="This is a description of my dialog which is awesome." data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.webm">Description Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-initial-focus attribute to any CSS selector to move focus to that element when the dialog opens, instead of the default close button. The selector is resolved against the dialog's own contents; an absent, invalid, or non-matching selector falls back to the default close-button focus. To test, use your keyboard to navigate.
<button class="dialog-trigger" data-initial-focus=".dialog__controls__btn--transcript" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm" data-transcript-fetch="https://radancy.dev/component-library/dialog/asset/the-last-jedi.html#transcript">Initial Focus Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-label attribute to a descriptive string to define the dialog's accessible name. If data-enable-heading is present, this value will serve as the visible heading and accessible name.
Reminder: This attribute is optional when data-labelledby or data-dynamic-label is present. Otherwise, data-label is required to provide the dialog with an accessible name. At least one of these three attributes must be set.
<button class="dialog-trigger" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Hidden Label (Default)</button>
<button class="dialog-trigger" data-enable-heading data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Visible Label</button>
<button class="dialog-trigger" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Missing Label</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set data-labelledby to the id of existing or fetched page element to use to use as the dialog's accessible name. The value is applied directly to aria-labelledby. The attribute also accepts a space-separated list of id values to combine multiple labels.
Reminder: This attribute is optional when data-label or data-dynamic-label is present. Otherwise, data-labelledy is required to provide the dialog with an accessible name. At least one of these three attributes must be set.
<button class="dialog-trigger" data-labelledby="my-custom-name" data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.webm">Label by ID Example</button>
<div hidden id="my-custom-name">My Custom Dialog Name</div>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Narrator (Harrison Ford): Luke Skywalker and Han Solo rescue the princess, destroy the Death Star, but their story didn't end there.
Now, the creators of the biggest smash hit of all time, bring you the next episode in the "Star Wars" saga, "The Empire Strikes Back."
The continuing story of our band of heroes. Luke Skywalker, Princess Leia, Han Solo, C3PO, R2D2 and Chewbacca and introducing Lando Calrissian.
It's an epic of romance. Of heroes and villains. They cross trackless voids to unknown worlds.
A galactic odyssey against oppression.
A big new sprawling space adventure in the "Star Wars" saga, "The Empire Strikes Back."
Coming to your galaxy next summer.
Note: Set the data-transcript-fragment attribute to the ID of hidden content elsewhere on the page to use as the transcript's content. The source element's own ID and any styles that specifically target it are not preserved.
Are Transcripts Necessary? Anchor (Are Transcripts Necessary?)
Yes! Providing an alternative way to share information through a transcript is a great idea! Here are some reasons why you should include them:
- Captions may not be accessible to certain assistive technologies, like refreshable Braille displays or screen readers.
- Captions may move too quickly or be distracting for some users.
- Transcripts allow people to search and scan content more easily.
- Transcripts can include additional details, semantic markup and hyperlinks, that wouldn't be possible or appropriate in captions.
- Besides enhancing accessibility, search engines may benefit from the extra information, helping them better understand and index your content.
Source: Captioning Videos FAQ
<button class="dialog-trigger" data-label="Star Wars: The Empire Strikes Back Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.webm" data-transcript-fragment="star-wars">Transcript (Fragment) Example</button>
<div id="star-wars" hidden>
<p><strong>Narrator (Harrison Ford):</strong> Luke Skywalker and Han Solo rescue the princess, destroy the Death Star, but their story didn't end there.</p>
<p>Now, the creators of the biggest smash hit of all time, bring you the next episode in the "Star Wars" saga, "The Empire Strikes Back."</p>
<p>The continuing story of our band of heroes. Luke Skywalker, Princess Leia, Han Solo, C3PO, R2D2 and Chewbacca and introducing Lando Calrissian.</p>
<p>It's an epic of romance. Of heroes and villains. They cross trackless voids to unknown worlds.</p>
<p>A galactic odyssey against oppression.</p>
<p>A big new sprawling space adventure in the "Star Wars" saga, "<a href="https://www.imdb.com/title/tt0080684/">The Empire Strikes Back</a>."</p>
<p>Coming to your galaxy <strong>next summer</strong>.</p>
</div>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set the data-transcript-fetch attribute to a URL followed by a #fragment identifier to fetch content from a same-domain page and insert the children of the element matching that identifier. The source element's own ID and any styles on the source page that specifically target it are not preserved.
Best Practice: This may be useful in a CMS environment, where an author cannot directly edit the contents of a transcript.
Are Transcripts Necessary? Anchor (Are Transcripts Necessary?)
Yes! Providing an alternative way to share information through a transcript is a great idea! Here are some reasons why you should include them:
- Captions may not be accessible to certain assistive technologies, like refreshable Braille displays or screen readers.
- Captions may move too quickly or be distracting for some users.
- Transcripts allow people to search and scan content more easily.
- Transcripts can include additional details, semantic markup and hyperlinks, that wouldn't be possible or appropriate in captions.
- Besides enhancing accessibility, search engines may benefit from the extra information, helping them better understand and index your content.
Source: Captioning Videos FAQ
<button class="dialog-trigger" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm" data-transcript-fetch="https://radancy.dev/component-library/dialog/asset/the-last-jedi.html#transcript">Transcript (Fetch) Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set data-dynamic-label to retrieve the dialog's accessible name automatically from the video provider (YouTube and Vimeo only). This may be useful in CMS authoring environment and when manually authored labels and alternative text may be overlooked, which is common occurance in web development. Optionally, add data-dynamic-alt to an inner img to apply the resolved label to its alt text. Additional functional text, such as (Video), is added automatically to clarify the purpose of the trigger and dialog.
Reminder: This attribute is optional when data-label or data-labelledy is present. Otherwise, data-dynamic-label is required to provide the dialog with an accessible name. At least one of these three attributes must be set.
Experimental: Experimental examples or components may require additional work, further testing, and may not be fully supported or accessible. They should be carefully evaluated before being used in a production environment.
<button class="dialog-trigger" data-dynamic-label data-enable-heading data-play-button data-src="https://youtube.com/embed/OZ1ZIMv5K_w">
<img alt="" data-dynamic-alt src="https://radancy.dev/placeholder.png" >
</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();ARIA Dialog
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit.
Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
Note: By default, the component loads only the dialog element. This fallback is available if the element causes compatibility or rendering issues. For example, absolutely positioned elements may render behind the dialog due to its stacking context.
<button class="dialog-trigger" data-aria-dialog data-label="ARIA Dialog Example" data-src="aria-dialog-content">ARIA Dialog Example</button>
<div id="aria-dialog-content" hidden>
<h1>ARIA Dialog</h1>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.</p>
<p>Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa. Fusce ac turpis quis ligula lacinia aliquet. Mauris ipsum. Nulla metus metus, ullamcorper vel, tincidunt sed, euismod in, nibh. Quisque volutpat condimentum velit.</p>
<p>Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.</p>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.</p>
</div>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set data-class to append one or more space-separated class names to the dialog element, alongside the default dialog class.
<button class="dialog-trigger" data-class="my-custom-class" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Class Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}.my-custom-class {
.dialog__media {
border: 6px dashed rebeccapurple;
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: By default, dialogs do not display a heading. Set data-enable-heading to enable one, using the required value from data-label. The heading uses an h1 element, which is appropriate because all other page content is made inert while the dialog is open.
<button class="dialog-trigger" data-label="Star Wars: The Empire Strikes Back Trailer" data-enable-heading data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.webm">Enabled Heading Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Add the data-fullscreen attribute (no value required) to expand the dialog's media to fill the viewport width.
<button class="dialog-trigger" data-fullscreen data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm" data-transcript-fetch="https://radancy.dev/component-library/dialog/asset/the-last-jedi.html#transcript">Fullscreen Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Play buttons often duplicate information already conveyed by the wrapped image's alt text. Add data-play-button to display a visual play indicator without redundant markup or assistive technology announcements. The indicator serves as a visual cue only.
Best Practice: Always add a contrasting border to the play button to keep it visible against varied backgrounds. Pair a dark border with a light button background, or vice versa, to maintain visibility over both light and dark imagery.
<button class="dialog-trigger" data-label="Raiders of the Lost Ark - Trailer 1080p" data-play-button data-src="https://youtube.com/embed/OZ1ZIMv5K_w">
<img src="https://radancy.dev/placeholder.png" alt="Indiana Jones Trailer (Video)">
</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Note: Set data-poster to an image URL to display as the video's poster frame before playback begins. This attribute applies only to self-hosted video formats (MP4, WebM, OGV) that use a native video element.
<button class="dialog-trigger" data-disable-autoplay data-poster="https://radancy.dev/component-library/dialog/asset/the-last-jedi.jpg" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">Poster Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-label="Star Wars: The Mandalorian and Grogu, la saga continúa" data-src="https://players.brightcove.net/55814260001/rkDAq3Wf_default/index.html?videoId=6396247810112">Brightcove Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-label="Developer Quick Takes" data-src="https://iframe.cloudflarestream.com/e7bd2dd67e0f8860b4ae81e33a966049">Cloudflare Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-caption="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back-en.vtt, English, en" data-label="Star Wars: The Empire Strikes Back Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.mp4">MP4 Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-label="Feature Highlight: Scheduled Chat" data-src="https://player.vimeo.com/video/508087059">Vimeo Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-caption="https://radancy.dev/component-library/dialog/asset/the-last-jedi-en.vtt, English, en, default" data-label="Star Wars: The Last Jedi Trailer" data-src="https://radancy.dev/component-library/dialog/asset/the-last-jedi.webm">WebM Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-label="The Easiest Improvement You Can Make To Your CSS" data-src="https://youtube.com/embed/4B_4WLpbyp8">YouTube Shorts Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();<button class="dialog-trigger" data-label="Radancy Expands AI Platform with Advanced Recruiter and Candidate AI Agents" data-src="https://youtube.com/embed/PnDd0O6vIts">YouTube Example</button>/*!
Radancy Component Library: Dialog
Contributor(s):
Michael "Spell" Spellacy
Dependencies: Sass
*/
:root {
--backdrop-filter: blur(3px) brightness(0.2); // Used for both version of dialog (native and ARIA).
--primary-color: #6F00EF;
}
.dialog-trigger {
// Component Variables
--dialog-play-button-background: #FF0033;
--dialog-play-button-border: calc(2rem / 16) #fff solid;
--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");
--dialog-play-button-size: calc(60rem / 16);
// Trigger button styles are for demonstration only and may be changed as needed.
background-color: var(--primary-color);
border-radius: calc(6rem / 16);
border: 0;
color: #fff;
cursor: pointer;
display: inline-grid;
font: inherit;
line-height: normal;
margin-block-end: 0.5rem;
outline-offset: 4px;
overflow: hidden;
padding: 1rem;
text-decoration: none;
&:has(img) {
block-size: 100%;
max-inline-size: calc(280rem / 16);
padding: 0;
}
img, &__play {
grid-area: 1 / -1;
}
&__play {
background-color: var(--dialog-play-button-background);
background-image: var(--dialog-play-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-play-button-size);
border-radius: 100vw;
border: var(--dialog-play-button-border);
display: block;
inline-size: var(--dialog-play-button-size);
place-self: center;
}
}
// ARIA backdrop. Native dialog gets ::backdrop
.dialog-backdrop {
backdrop-filter: var(--backdrop-filter);
inset: 0;
position: fixed;
z-index: 2147483645;
}
.dialog {
// Component Variables
// TODO: Look into using inline SVG instead of pseudo as this technique may not be able to handle low-contrast.
--dialog-animation-speed: 400ms;
--dialog-background-color: white;
--dialog-border-radius: calc(5rem / 16);
--dialog-box-shadow: 0 calc(4rem / 16) 1rem rgba(0, 0, 0, 0.8);
--dialog-button-size: 2.2rem;
--dialog-icon-audio-description: 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-icon-close: 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-icon-transcript: 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-inline-size: 92vw;
--dialog-max-block-size: 80vh;
--dialog-media-block-size: calc(var(--dialog-media-inline-size) * 9 / 16);
--dialog-media-inline-size-shorts: 23.75rem;
--dialog-media-inline-size: 72rem;
--dialog-scrollbar-color: var(--primary-color) transparent;
--dialog-transcript-background: #DCE8EA;
--dialog-transcript-scrollbar-color: #0f1632 transparent;
// Breakpoint (CSS variables are not allowed in media queries - boo!)
$dialog-breakpoint: 45rem; // 720px;
background-color: transparent;
border: 0;
border-radius: var(--dialog-border-radius);
display: none;
grid-template-rows: auto 1fr;
inline-size: var(--dialog-inline-size);
opacity: 0;
padding: 0;
scrollbar-color: var(--dialog-scrollbar-color);
text-align: start;
transition-behavior: allow-discrete;
transition-property: display, opacity, overlay, translate;
translate: 0 calc(50rem / 16);
// Used 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: var(--dialog-animation-speed);
}
&[data-has-content], &[data-has-iframe] {
min-block-size: 75vh;
@media (min-width: $dialog-breakpoint) {
inline-size: 50vw;
}
}
// Note: Text content has its own intrinsic height to auto-size to on desktop, an iframe doesn't, so it keeps the enforced min-block-size at every width.
&[data-has-content] {
@media (min-width: $dialog-breakpoint) {
min-block-size: auto;
}
}
&:has(.dialog__media):not([data-has-iframe]) {
inline-size: fit-content;
}
&[data-has-content], &[data-has-heading] {
background-color: var(--dialog-background-color);
box-shadow: var(--dialog-box-shadow);
padding: 1rem;
}
&[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);
}
// Dialog Header (dialog__header)
&__header {
align-items: center;
display: grid;
gap: 1rem;
grid-auto-flow: column;
margin-block-end: 1rem;
[data-has-content] & {
display: contents;
}
}
// Dialog Heading (dialog__heading)
// Note that this is an H1 element, which is perfectly fine in the context of a dialog.
&__heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.6rem);
margin: 0;
}
// Dialog Controls (dialog__controls)
&__controls {
display: grid;
gap: 0.5rem;
grid-auto-flow: column;
justify-self: end;
// Dialog Buttons (dialog__controls__btn)
&__btn {
background-color: transparent;
background-position: center;
background-repeat: no-repeat;
background-size: calc(24rem / 16);
block-size: var(--dialog-button-size);
border-radius: var(--dialog-border-radius);
border: 0;
cursor: pointer;
display: grid;
inline-size: var(--dialog-button-size);
outline-offset: calc(-4rem / 16);
transition: background-color 300ms;
&:focus-visible, &:hover, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: var(--primary-color);
}
[data-has-heading] &, [data-has-content] & {
background-color: var(--primary-color);
&:hover, &:focus-visible, &[aria-pressed="true"], &[aria-expanded="true"] {
background-color: black;
}
}
[data-has-content] & {
grid-area: 1 / -1;
inset-block-start: 0;
margin-inline-start: auto;
position: sticky;
}
&--close {
background-image: var(--dialog-icon-close);
}
&--audio-description {
background-image: var(--dialog-icon-audio-description);
}
&--transcript {
background-image: var(--dialog-icon-transcript);
}
}
}
// Dialog Container (dialog__container)
&__container {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-block-size: 0;
@media (prefers-reduced-motion: no-preference) {
transition-duration: var(--dialog-animation-speed);
}
// Fix: Sticky button scrolls with content when bottom reached.
[data-has-content] & {
min-block-size: auto;
}
&:has(.dialog__transcript) {
@media (min-width: $dialog-breakpoint) {
grid-template-columns: minmax(0, 0fr) minmax(0, 1fr);
.dialog__asset {
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;
}
}
}
}
// Dialog Transcript (dialog__transcript)
&__transcript {
background-color: var(--dialog-transcript-background);
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: var(--dialog-animation-speed);
}
@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);
max-inline-size: calc(350rem /16);
min-block-size: 0;
[data-fullscreen] & {
block-size: 100%;
max-block-size: none;
max-inline-size: fit-content;
}
}
}
// Dialog Transcript Heading (dialog__transcript-heading)
&__transcript-heading {
font-size: clamp(1rem, calc(0.68rem + 1.6vw), 1.4rem);
margin: 0;
padding: 1rem 0.5rem 0.5rem;
}
// Dialog Transcript Content (dialog__transcript-content)
&__transcript-content {
margin-bottom: 1rem;
mask-image: unquote("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: var(--dialog-transcript-scrollbar-color);
// Fix: Mask is obscuring focus, so let's remove it when focus received.
&:focus-visible {
mask-image: none;
}
}
// Dialog Asset (dialog__asset)
&__asset {
background-color: #000;
min-block-size: 0;
overflow: hidden;
[data-has-content] &, [data-has-iframe] & {
background-color: var(--dialog-background-color);
}
[data-has-iframe] & {
padding: 0.8rem;
}
[data-has-content] & {
min-block-size: auto;
overflow: visible;
}
}
// Dialog Media (dialog__media, Video, Iframe, etc.)
&__media {
aspect-ratio: 16 / 9;
background-color: transparent;
block-size: 100%;
border: 0;
display: block;
frame-sizing: content-block-size;
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);
}
// Note: A generic iframe (page, map, etc.) isn't inherently 16:9 like a video, so let's ovrride it.
[data-has-iframe] & {
aspect-ratio: auto;
inline-size: 100%;
}
// Hack: Address Poster FOUC on iframe videos, specifically YouTube. Remove if not needed.
@media (prefers-reduced-motion: no-preference) {
transition-delay: 450ms;
transition-duration: 300ms;
}
@starting-style {
opacity: 0;
}
}
// Shared: Dialog Controls (dialog__controls) & Dialog Container (dialog__container)
&__controls, &__container {
[data-has-content] & {
grid-area: 1 / -1;
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript Content (dialog__transcript-content)
&__asset, &__transcript-content {
// Common element formatting. Remove if not needed.
& *:first-child {
margin-block-start: 0;
}
& > * + * {
margin-block-start: var(--flow-space, 1em);
}
h1, h2 {
max-inline-size: calc(320rem / 16);
@media (min-width: $dialog-breakpoint) {
max-inline-size: none;
}
}
}
// Shared: Dialog Asset (dialog__asset) & Dialog Transcript (dialog__transcript) & Iframe
&__asset, &__transcript, iframe {
border-radius: var(--dialog-border-radius);
}
}/*!
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 = () => {
// Pages with <html data-relative-path> load shared libraries through a same-origin proxy. Everywhere else (docs, CodePen) loads from the CDN.
const languagePackPath = "/shared-libraries/rcl-language-pack/rolling/init.min.js";
const languagePackUrl = document.documentElement.hasAttribute("data-relative-path") ? languagePackPath : `https://clientfiles.tmpwebeng.com${languagePackPath}`;
loadLanguagePack(languagePackUrl, () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.2.4", "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 dialogDataAriaDescription = "data-description";
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 dialogDataHasIframe = "data-has-iframe";
const dialogDataTranscriptFragment = "data-transcript-fragment";
const dialogDataTranscriptFetch = "data-transcript-fetch";
const dialogDataDisableAutoplay = "data-disable-autoplay";
const dialogDataPoster = "data-poster";
const dialogDataClass = "data-class";
const dialogDataYoutubeShorts = "data-youtube-shorts";
const dialogDataDynamicLabel = "data-dynamic-label";
const dialogDataDynamicAlt = "data-dynamic-alt";
const dialogDataAriaDialog = "data-aria-dialog";
const dialogDataFullscreen = "data-fullscreen";
const dialogDataInitialFocus = "data-initial-focus";
const dialogDataSrc = "data-src";
const dialogDataIframeSrc = "data-iframe-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-heading";
const dialogTranscriptContentClassName = "dialog__transcript-content";
const dialogAssetClassName = "dialog__asset";
const dialogMediaClassName = "dialog__media";
// Labels come from language-pack.js's labelDialog* page-globals (loaded before this runs, one block per locale).
const dialogCloseLabel = labelDialogClose;
const dialogAudioDescriptionLabel = labelDialogAudioDescription;
const dialogTranscriptHeadingLabel = labelDialogTranscriptHeading;
const dialogTranscriptButtonLabel = labelDialogTranscriptButton;
const dialogVideoLabel = labelDialogVideo;
const dialogVideoSuffixLabel = labelDialogVideoSuffix;
const dialogTranscriptNotFoundLabel = labelDialogTranscriptNotFound;
const dialogTranscriptFailedLabel = labelDialogTranscriptFailed;
const dialogContentNotFoundLabel = labelDialogContentNotFound;
const dialogLoadingContentLabel = labelDialogLoadingContent;
const dialogContentFailedLabel = labelDialogContentFailed;
const dialogMissingNameHeadingLabel = labelDialogMissingNameHeading;
const dialogMissingNameMessageLabel = labelDialogMissingNameMessage;
const dialogVideoFallbackLabel = labelDialogVideoFallback;
const dialogIframeFallbackLabel = labelDialogIframeFallback;
const dialogTriggers = document.querySelectorAll(dialogTriggerClass);
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Infers a dialog's type from data-src, in order. "fetch" matches anything with a #fragment; anything else is an "element" id already on the page.
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) },
{ type: "fetch", test: src => src.includes("#") },
];
// Shared by the "video" case and the preload warmer below, so both agree on MIME type per file extension.
const videoMimeTypes = { mp4: "video/mp4", webm: "video/webm", ogv: "video/ogg" };
// Counter for a dialog id when there's no label to slugify.
let dialogInstanceId = 0;
const detectDialogType = (src) => {
return dialogTypeDetectors.find(({ test }) => test(src))?.type ?? "element";
};
// data-labelledby only counts as an accessible name if it resolves to a real id on the page; a dangling reference is treated as never authored.
const hasResolvableLabelledby = (labelledby) => Boolean(labelledby && document.getElementById(labelledby));
// Timestamp of the last dialog close, used by the click handler below. Works around a Chrome/Firefox bug, not our markup: https://issues.chromium.org/issues/425579196
// and https://github.com/mdn/browser-compat-data/issues/30474. Safe to leave; dormant once both ship.
let dialogLastClosedAt = 0;
const destroyDialog = (dialog) => {
// Escape/light-dismiss fires "cancel" first (calling this directly), then dialog.close() fires "close" (calling this again); this guard makes the
// second call a no-op. command="close" only ever fires "close" once.
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 body scroll. Native dialogs don't lock it themselves (unlike focus/inertness, free via top-layer), so we set/restore it ourselves.
document.body.style.overflow = dialog.dialogPreviousBodyOverflow;
if (dialog.dialogClassic) {
// Classic (non-native) teardown: undo everything openClassicDialog set up (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 close transition before removing the element, or it's torn out before the browser can animate it. A timeout backs up transitionend,
// since discrete transitions (display/overlay) can fail to fire it.
let removed = false;
const finalizeRemoval = () => {
if (removed) return;
removed = true;
// Reset iframe src to stop YouTube/Vimeo. Held off until now so the frame fades out with the dialog instead of blanking to black.
dialog.querySelectorAll("iframe").forEach(iframe => {
iframe.src = "";
});
// Restore moved-in source elements only once the dialog leaves the DOM; doing it earlier would pull content out mid-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 showModal() gives for free (positioning aside, handled in CSS), a plain role="dialog" div needs by
// hand: backdrop, inertness (traps focus), scroll lock, Escape, focus return.
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();
};
// Moves a hidden source element's children (not the element itself, so its id/styling stay behind) into the dialog. Returns a fragment plus a restore function.
const moveContentIntoDialog = (el) => {
const children = Array.from(el.childNodes);
const fragment = document.createDocumentFragment();
fragment.append(...children);
return { fragment, restore: () => el.append(...children) };
};
// Reads label/srclang/default off a track URL's own query string, for setups where only a single src can be authored, e.g. "video.vtt?label=English&srclang=en&default".
// "default" is presence-only, like every other boolean attribute here. Returns the src with these three keys stripped so they don't leak into the VTT request.
const parseTrackUrlMetadata = (src) => {
const url = new URL(src);
const params = url.searchParams;
const label = params.get("label") ?? undefined;
const srclang = params.get("srclang") ?? undefined;
const isDefault = params.has("default");
params.delete("label");
params.delete("srclang");
params.delete("default");
return { src: url.toString(), label, srclang, isDefault };
};
// Parses a "src, label, srclang, default; ..." caption string into track descriptors, or a bare src URL carrying that metadata as query params.
const parseCaptions = (value) => {
return value.split(";").map(track => track.trim()).filter(Boolean).map(track => {
let [trackSrc, trackLabel, srclang, defaultFlag] = track.split(",").map(field => field.trim());
let isDefault = defaultFlag?.toLowerCase() === "default";
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang, isDefault } = parseTrackUrlMetadata(trackSrc));
return { src: trackSrc, label: trackLabel, srclang, default: isDefault };
});
};
// Parses a single "src, label, srclang" description string into a track descriptor, or a bare src URL carrying that metadata as query params.
const parseDescription = (value) => {
let [trackSrc, trackLabel, srclang] = value.split(",").map(field => field.trim());
if (trackLabel === undefined) ({ src: trackSrc, label: trackLabel, srclang } = parseTrackUrlMetadata(trackSrc));
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): looks up a YouTube/Vimeo title via oEmbed for the dialog/heading/alt text when no data-label is authored; falls back to "Video Player".
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;
}
};
// oEmbed has no "is this a Short" field, but a /shorts/ URL reports true (portrait) dimensions, while /watch?v= reports the wrong (landscape) size
// for the same video. /shorts/ is safe to use unconditionally (non-Shorts still report correctly). Sets data-youtube-shorts for CSS; fails silently.
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 {}
};
// Fetches a same-domain page and returns the children of the element matching its #fragment id. Shared by data-transcript-fetch and a data-src #fragment.
const fetchRemoteFragment = 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 ? Array.from(el.childNodes, node => document.importNode(node, 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");
}
};
// Loads a transcript (in-page element or fetched fragment) into a target node once; only the source's contents are copied, not the element itself.
const loadTranscriptOnce = (transcriptTarget, { transcriptFragment, transcriptFetch }) => {
if (transcriptFragment) {
const transcriptEl = document.getElementById(transcriptFragment);
if (transcriptEl) {
transcriptTarget.append(...Array.from(transcriptEl.childNodes, node => node.cloneNode(true)));
} else {
console.error(`Dialog transcript element with id "${transcriptFragment}" not found.`);
}
applyTranscriptTabindex(transcriptTarget);
return;
}
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
transcriptTarget.append(placeholder);
fetchRemoteFragment(transcriptFetch).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogTranscriptNotFoundLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" did not resolve to a matching element.`);
}
applyTranscriptTabindex(transcriptTarget);
}).catch(() => {
placeholder.textContent = dialogTranscriptFailedLabel;
console.error(`Dialog transcript URL "${transcriptFetch}" failed to load.`);
applyTranscriptTabindex(transcriptTarget);
});
};
// Wires the audio-description toggle: speech-synthesizes 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);
};
// Builds the full dialog structure (header/controls, transcript panel, asset), shared by video/youtube/vimeo (hasContent false) and element/iframe/fetch/fallback (hasContent true).
const buildMediaDialog = (assetContent, { heading, label, labelledby, ariaDescription, hasDescription, transcriptFragment, transcriptFetch, classic, hasContent = false, hasIframe = false, closeLabel = dialogCloseLabel, customClass, 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 (customClass) dialog.classList.add(...customClass.split(/\s+/));
if (classic) {
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
// No tabindex here: we never focus the dialog itself, only 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);
// command="close" drives this (not a click listener), so it shares the same close path as Escape/light-dismiss, via the "close" listener below.
}
controls.append(closeBtn);
if (hasDescription && assetContent.tagName === "VIDEO") {
attachAudioDescription(assetContent, controls);
}
const container = document.createElement("div");
container.className = dialogContainerClassName;
if (transcriptFragment || transcriptFetch) {
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);
// Loads the transcript now, while hidden, rather than on first toggle, so nothing new happens in the DOM when the user opens the panel.
loadTranscriptOnce(transcriptContent, { transcriptFragment, transcriptFetch });
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 (!hasContent) {
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 = dialogAssetClassName;
asset.append(assetContent);
container.append(asset);
dialog.append(header, container);
// Only one dialog is ever open at a time, so a fixed id is fine here. Set via textContent (never innerHTML) since this is author-supplied text.
if (ariaDescription) {
const descriptionEl = document.createElement("div");
descriptionEl.id = "dialog-description";
descriptionEl.hidden = true;
descriptionEl.textContent = ariaDescription;
dialog.append(descriptionEl);
dialog.setAttribute("aria-describedby", descriptionEl.id);
}
if (hasContent) dialog.setAttribute(dialogDataHasContent, "");
if (hasIframe) dialog.setAttribute(dialogDataHasIframe, "");
dialog.dialogRestoreCallbacks = restoreCallbacks;
return dialog;
};
// Create and show dialog dynamically based on type.
const openDialog = (type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus } = {}) => {
const triggerElement = document.activeElement;
let dialog;
// A "fetch" type's data-labelledby may reference an id not yet on the page; trust it as-is, aria-labelledby resolves once the fetch inserts the element.
const labelledbyOk = hasResolvableLabelledby(labelledby) || (type === "fetch" && Boolean(labelledby));
if (!label && !labelledbyOk && !dynamicLabel) {
// Failsafe: never open a dialog with no accessible name. An in-progress data-dynamic-label lookup may have resolved label already; dynamicLabel
// is still checked for types that lookup doesn't support. Only fires when none of data-label/labelledby/dynamic-label were authored: a dev mistake.
const headingId = `hdr-${slugify(dialogMissingNameHeadingLabel)}`;
const heading = document.createElement("h1");
heading.id = headingId;
heading.textContent = dialogMissingNameHeadingLabel;
const message = document.createElement("p");
message.textContent = dialogMissingNameMessageLabel;
const contentNode = document.createDocumentFragment();
contentNode.append(heading, message);
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, labelledby: headingId, ariaDescription, classic, hasContent: true, customClass });
} else switch (type) {
case "video": {
const video = document.createElement("video");
video.controls = true;
video.crossOrigin = "anonymous";
if (poster) video.poster = poster;
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, ariaDescription, hasDescription: Boolean(description), transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "youtube": {
const iframe = document.createElement("iframe");
// Some videos (Shorts among others) ignore youtube.com's passive autoplay=1 and show a "Watch on YouTube" card instead of playing, even though the
// same video autoplays fine elsewhere. youtube-nocookie.com honors an explicit IFrame API play command where youtube.com doesn't, so normalize
// to it and request playback once the player's loaded.
iframe.src = `https://www.youtube-nocookie.com${new URL(src).pathname}?autohide=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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
flagYoutubeShorts(dialog, src);
break;
}
case "vimeo": {
const iframe = document.createElement("iframe");
const vimeoSrc = new URL(src);
// An inline autoplay param (already in data-src) wins; only set ours if the author didn't specify one.
if (!vimeoSrc.searchParams.has("autoplay")) vimeoSrc.searchParams.set("autoplay", disableAutoplay ? "0" : "1");
iframe.src = vimeoSrc.toString();
iframe.allow = "autoplay; fullscreen";
iframe.allowFullscreen = true;
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "cloudflare": {
const iframe = document.createElement("iframe");
// Unlike YouTube/Vimeo above, a Cloudflare Stream src often already has a query string, so autoplay must be joined with "&" rather than "?".
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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "brightcove": {
const iframe = document.createElement("iframe");
// Same reasoning as Cloudflare. "any" attempts autoplay with sound, falling back to muted only if blocked; the closest match available 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, ariaDescription, transcriptFragment, transcriptFetch, classic, customClass });
break;
}
case "iframe": {
const iframe = document.createElement("iframe");
iframe.src = src;
iframe.title = label || dialogIframeFallbackLabel;
iframe.classList.add(dialogMediaClassName);
dialog = buildMediaDialog(iframe, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasIframe: true, customClass });
break;
}
case "fetch": {
const placeholder = document.createElement("p");
placeholder.textContent = dialogLoadingContentLabel;
fetchRemoteFragment(src).then(nodes => {
if (nodes) {
placeholder.replaceWith(...nodes);
} else {
placeholder.textContent = dialogContentNotFoundLabel;
console.error(`Dialog content URL "${src}" did not resolve to a matching element.`);
}
}).catch(() => {
placeholder.textContent = dialogContentFailedLabel;
console.error(`Dialog content URL "${src}" failed to load.`);
});
dialog = buildMediaDialog(placeholder, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass });
break;
}
case "element": {
const el = document.getElementById(src);
const restoreCallbacks = [];
let contentNode;
if (el) {
const { fragment, restore } = moveContentIntoDialog(el);
restoreCallbacks.push(restore);
contentNode = fragment;
} else {
contentNode = document.createElement("p");
contentNode.textContent = dialogContentNotFoundLabel;
}
dialog = buildMediaDialog(contentNode, { heading, label, labelledby, ariaDescription, transcriptFragment, transcriptFetch, classic, hasContent: true, customClass, restoreCallbacks });
break;
}
}
if (fullscreen) dialog.setAttribute(dialogDataFullscreen, "");
// data-initial-focus overrides the default close-button focus with any CSS selector, resolved against the dialog's own contents. Falls back to the
// default (close button for classic, native showModal() default otherwise) when absent, invalid, or not matching anything inside the dialog.
let initialFocusTarget = null;
if (initialFocus) {
try {
initialFocusTarget = dialog.querySelector(initialFocus);
if (!initialFocusTarget) console.error(`Dialog data-initial-focus selector "${initialFocus}" did not match any element in the dialog.`);
} catch {
console.error(`Dialog data-initial-focus selector "${initialFocus}" is not a valid CSS selector.`);
}
}
if (classic) {
const focusTarget = initialFocusTarget || 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.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
initialFocusTarget?.focus();
}
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");
// href only works on <a> triggers (data-src works on every other trigger, typically <button>). <a> triggers also need role="button" plus manual
// Enter/Space handling, since only native <button> gets that for free.
const isAnchor = trigger.tagName === "A";
if (isAnchor) {
trigger.setAttribute("role", "button");
if (trigger.hasAttribute(dialogDataSrc)) {
console.error("data-src is not supported on <a> dialog triggers; use href instead.", trigger);
}
} else if (trigger.hasAttribute("href")) {
console.error("href is not supported on this dialog trigger; use data-src instead (href is only supported on <a> triggers).", trigger);
}
// 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 ariaDescription = trigger.getAttribute(dialogDataAriaDescription);
const caption = trigger.getAttribute(dialogDataCaption);
let description = trigger.getAttribute(dialogDataDescription);
const heading = trigger.hasAttribute(dialogDataHeading);
const transcriptFragment = trigger.getAttribute(dialogDataTranscriptFragment);
let transcriptFetch = trigger.getAttribute(dialogDataTranscriptFetch);
const disableAutoplay = trigger.hasAttribute(dialogDataDisableAutoplay) || prefersReducedMotion;
const poster = trigger.getAttribute(dialogDataPoster);
const customClass = trigger.getAttribute(dialogDataClass);
const dynamicLabel = trigger.hasAttribute(dialogDataDynamicLabel);
const classic = trigger.hasAttribute(dialogDataAriaDialog);
const fullscreen = trigger.hasAttribute(dialogDataFullscreen);
const initialFocus = trigger.getAttribute(dialogDataInitialFocus);
const iframeSrc = trigger.getAttribute(dialogDataIframeSrc);
const rawSrc = iframeSrc ?? (isAnchor ? trigger.getAttribute("href") : trigger.getAttribute(dialogDataSrc));
// Defensive against CMS/template double-encoding query ampersands as &. A literal & is never valid otherwise, so this is safe to correct.
let src = rawSrc?.replace(/&/g, "&") ?? rawSrc;
const type = iframeSrc ? "iframe" : detectDialogType(src);
// Alternative to data-transcript-fetch/data-audio-description, for self-hosted video: both can be folded into data-src's own query string (transcript, src,
// label, srclang), with fragment standing in for a literal #fragment (avoids %23-encoding). An explicit attribute always wins; this is only a fallback,
// and recognized keys are stripped from src before the actual video request.
if (type === "video") {
const videoSrcUrl = new URL(src);
const videoSrcParams = videoSrcUrl.searchParams;
if (!transcriptFetch && videoSrcParams.has("transcript")) {
const path = videoSrcParams.get("transcript");
const fragment = videoSrcParams.get("fragment");
transcriptFetch = fragment ? `${path}#${fragment}` : path;
}
if (!description && videoSrcParams.has("src")) {
description = [videoSrcParams.get("src"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "src", "label", "srclang"].forEach(key => videoSrcParams.delete(key));
src = videoSrcUrl.toString();
}
// EXPERIMENTAL (data-dynamic-label): kicks off the oEmbed lookup on load; the click handler waits on this before opening the dialog.
let dynamicLabelReady = Promise.resolve();
if (dynamicLabel && (type === "youtube" || type === "vimeo")) {
dynamicLabelReady = fetchDynamicVideoTitle(type, src).then(title => {
label = title;
const img = trigger.querySelector(`img[${dialogDataDynamicAlt}]`);
if (img) img.alt = `${title} ${dialogVideoSuffixLabel}`;
});
}
// Warn if no accessible name is set and none is coming async; a "fetch" type's data-labelledby is trusted here too (see openDialog).
if (!dynamicLabel && !label && !hasResolvableLabelledby(labelledby) && !(type === "fetch" && labelledby)) {
console.error("Dialog trigger is missing an accessible name. Add data-label, data-labelledby (pointing to an id that exists on the page), 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 ("${src}") has captions available. If there is no spoken dialogue in the video, captions are still required.`, trigger);
}
// Warms the network for self-hosted video on real intent signal, rather than an unconditional rel=preload or waiting for the click. Only "video"; other
// types are third-party iframe pages. A plain fetch (not <link rel=preload>) warms the same cache entry without the Preload API ever tracking it,
// so it never trips Chrome's "preloaded but not used" warning, even once the video is genuinely opened and played right after.
if (type === "video") {
let warmed = false;
const warmVideoPreload = () => {
if (warmed) return;
warmed = true;
// mode/credentials must match the <video>'s crossOrigin="anonymous" below, or the cache entry won't be reused.
fetch(src, { mode: "cors", credentials: "omit" }).catch(() => {});
};
["pointerenter", "focus", "touchstart"].forEach(evt => trigger.addEventListener(evt, warmVideoPreload, { once: true, passive: true }));
}
// Handle trigger click. Native Enter on <a> already fires a real click; preventDefault stops it from following href.
trigger.addEventListener("click", (e) => {
if (isAnchor) e.preventDefault();
// On touch devices, a dismissing tap can both light-dismiss the dialog (closedby="any") and deliver a synthesized click to the same trigger,
// reopening it. A known Chrome/Firefox bug (leaks through to whatever's underneath): https://issues.chromium.org/issues/425579196. Ignoring a
// click right after close covers it; a real second tap is always well outside this window. Remove once browsers fix this.
if (Date.now() - dialogLastClosedAt < 500) return;
dynamicLabelReady.then(() => {
openDialog(type, src, { label, labelledby, ariaDescription, dynamicLabel, caption, description, heading, transcriptFragment, transcriptFetch, disableAutoplay, poster, customClass, classic, fullscreen, initialFocus });
});
});
// role="button" gets Enter for free (an <a> already fires a native click on Enter), but not Space; wire that up per the ARIA APG button pattern.
if (isAnchor) {
trigger.addEventListener("keydown", (e) => {
if (e.key !== " ") return;
e.preventDefault();
trigger.click();
});
}
});
});
};
initDialog();
})();Diagram Anchor (Diagram)
Use the following diagram to understand this component's structure.
Properties Anchor (Properties)
Use the following properties to configure this component.
| Attribute | Applies To | Note | Description |
|---|---|---|---|
data-aria-dialog |
.dialog-trigger |
Boolean, Optional | Used to switch to the classic ARIA dialog fallback ( role="dialog") instead of the native dialog element. |
data-audio-description |
.dialog-trigger |
String, Optional | Used to add an audio-description track to self-hosted video. Accepts a src, label, srclang value. Can also be folded into data-src's own query string (e.g. video.mp4?src=desc.vtt&label=English&srclang=en) when a separate attribute isn't practical. |
data-caption |
.dialog-trigger |
String, Optional | Used to add caption track(s) to self-hosted video. Accepts a src, label, srclang, default value; separate multiple tracks with a semicolon. label, srclang, and default can alternatively be passed in as a query string on the track URL itself (e.g. english.vtt?label=English&srclang=en&default; french.vtt?label=French&srclang=fr) instead of comma-separated. |
data-class |
.dialog-trigger |
String, Optional | Used to append one or more space-separated class names to the dialog element, alongside its default dialog class. |
data-description |
.dialog-trigger |
String, Optional | Used to set the dialog's accessible description via aria-describedby. |
data-disable-autoplay |
.dialog-trigger |
Boolean, Optional | Used to prevent video or embed autoplay when the dialog opens. Already honored automatically for prefers-reduced-motion. |
data-dynamic-alt |
img |
Boolean, Optional | This feature is experimental. Used on an image inside the trigger whose alt text is updated once data-dynamic-label resolves. |
data-dynamic-label |
.dialog-trigger |
Boolean, Optional | This feature is experimental. Used to fetch the dialog's accessible name from the video provider (YouTube, Vimeo only) via embed. |
data-enable-heading |
.dialog-trigger |
Boolean, Optional | Used to add a visible h1 heading, using the value of data-label. |
data-fullscreen |
.dialog-trigger |
Boolean, Optional | Used to expand the dialog's media to fill the viewport width. |
data-has-content |
.dialog |
Component | Applied automatically to content dialogs (fetch, element, iframe, fallback) for styling. |
data-has-heading |
.dialog |
Component | Applied automatically when data-enable-heading renders a heading. |
data-has-iframe |
.dialog |
Component | Applied automatically to iframe dialogs for styling. |
data-iframe-src |
.dialog-trigger |
String, Optional | Used to load a URL directly inside an iframe. Takes priority over data-src, href when both are present. |
data-initial-focus |
.dialog-trigger |
String, Optional | Used to move focus to a given CSS selector when the dialog opens, instead of the default close button. Falls back to the close button when absent, invalid, or not matching anything in the dialog. |
data-label |
.dialog-trigger |
String, Conditional | Used to set the dialog's accessible name (and heading text, if data-enable-heading is present). Mandatory unless data-labelledby or data-dynamic-label is present. |
data-labelledby |
.dialog-trigger |
String, Conditional | Used to set the dialog's accessible name to an existing (or fetched) element id, instead of an auto-generated one; accepts a space-separated list to combine multiple labels. Mandatory unless data-label or data-dynamic-label is present. |
data-open |
.dialog |
Component | Applied automatically while the classic (non-native) fallback dialog is open. Equivalent to dialog's open attribute. |
data-play-button |
.dialog-trigger |
Boolean, Optional | Used to add a visual play-button indicator to the trigger. |
data-poster |
.dialog-trigger |
String, Optional | Used to set a poster image for self-hosted video. Only applies to mp4, webm, ogv. |
data-src |
button |
String, Conditional | Used to set the dialog's source, a video file, third-party embed URL, url#fragment to fetch, or an existing element id. Not supported on a triggers; use href instead. |
data-transcript-fetch |
.dialog-trigger |
String, Optional | Used to fetch a transcript from a same-domain url#fragment. For self-hosted video, can alternatively be folded into data-src's own query string (e.g. video.mp4?transcript=page.html&fragment=transcript-id) when a separate attribute isn't practical. |
data-transcript-fragment |
.dialog-trigger |
String, Optional | Used to reference hidden content elsewhere on the page, by element id, for use as the transcript. |
data-youtube-shorts |
.dialog |
Component | Applied automatically when a YouTube video is detected as a Short, for correct sizing. |
| Class | Applies To | Note | Description |
|---|---|---|---|
.dialog |
dialog |
Mandatory | Applied to the root dialog element. |
.dialog-backdrop |
div |
Component | Used on the backdrop created for the classic (non-native) fallback dialog. |
.dialog-trigger |
a, button |
Mandatory | Required on every element that opens a dialog. |
.dialog-trigger__play |
span |
Component | Added to the trigger when data-play-button is present. |
.dialog__asset |
div |
Component | Wraps the dialog's video, iframe, or inserted content. |
.dialog__container |
div |
Component | Wraps .dialog__asset and, when present, .dialog__transcript. |
.dialog__controls |
div |
Component | Wraps the close button and any other control buttons (transcript, audio description). |
.dialog__controls__btn |
button |
Component | Used on every control button within .dialog__controls. |
.dialog__controls__btn--audio-description |
button |
Component | Toggles audio-description narration. Added only when data-audio-description is present on a video. |
.dialog__controls__btn--close |
button |
Component | Closes the dialog. |
.dialog__controls__btn--transcript |
button |
Component | Toggles the transcript panel. Added only when a transcript is configured. |
.dialog__header |
div |
Component | Wraps the heading (if any) and .dialog__controls. |
.dialog__heading |
h1 |
Component | Added when data-enable-heading is present. |
.dialog__media |
video, iframe |
Component | Applied to self-hosted video, YouTube, Vimeo, Cloudflare, Brightcove, and iframe media, sized via aspect-ratio. |
.dialog__transcript |
div |
Component | The transcript panel. Rendered only when a transcript is configured. |
.dialog__transcript-content |
div |
Component | Holds the transcript's actual content, inside .dialog__transcript. |
.dialog__transcript-heading |
h2 |
Component | The transcript panel's heading, inside .dialog__transcript. |
.transcript-open |
.dialog__container |
Component | Toggled on .dialog__container while the transcript panel is open. |
Release Notes Anchor (Release Notes)
Keep up to date on major changes to this component.
- Feature
- October 26, 2026: Added the
data-initial-focusattribute, accepting any CSS selector to move focus to that element when the dialog opens, instead of the default close button. Falls back to the close button when absent, invalid, or not matching anything in the dialog. Bumped to 1.2.4. Good idea! Thank you, Carla!
Report Issues
Find a bug? Want a new feature? Report it on JIRA. You're so awesome!