Dialog v1.0.5
An accessible, easy-to-implement modal for video and other content, replacing third-party components such as Fancybox.
<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 Example</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 Example</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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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-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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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 id to fetch a same-domain page and pull in the contents of the element matching that id. Only the element's children are copied in — its own id, and any styling on the source page that targets it, doesn't come along.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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 already present elsewhere on the page to use as the dialog's content. Only the element's children are moved into the dialog.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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 inside an iframe within the dialog, rather than fetching and extracting its content. Takes priority over data-src when both are present on the same trigger.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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();
})();Accessibility: Audio descriptions aren't always necessary; they're only needed when a video conveys meaningful visual information that isn't already covered by the existing dialogue or sound, such as on-screen text, actions, or scene changes. When a video's visuals are already fully conveyed through what's said, an audio description track is unlikely to add value. When it is warranted, an audio description is delivered as a separate narration track describing that visual content during natural pauses in the audio.
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 to the video.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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();
})();Accessibility: Please note that all videos must include captions. Even when a video has no spoken dialogue, captions are still required to describe other meaningful audio, such as music or sound effects. Captions benefit far more than the Deaf and hard-of-hearing community: most caption users aren't d/Hoh, and captions also help second-language learners, people with attention or auditory-processing differences, and anyone viewing in a sound-off environment. For more, see this guide to captioned videos.
Note: Set the data-caption attribute to a src, label, srclang, default value referencing a valid VTT file. The optional default flag marks that track as the one shown automatically. Multiple language tracks are supported: separate each src, label, srclang, default group with a semicolon to add more than one caption track to the video. Third-party captions are the responsibility of their original authors to supply. To promote a more inclusive experience, third-party caption visibility defaults to on and can't be overridden.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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();
})();Accessibility: Autoplaying video can be disorienting or distressing for users with vestibular disorders or motion sensitivity, and disruptive for anyone with attention-related conditions who is easily pulled away from the surrounding page. This attribute is already applied automatically to honor a user's OS-level settings, regardless of whether autoplay is explicitly disabled; but you can use this setting explicitly when you want autoplay disabled for every visitor, not just those with that OS-level preference set.
Note: Set the data-disable-autoplay attribute (present, no value needed) to prevent the video from autoplaying when the dialog opens. The video can still be started manually using its own play control.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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, which is created when the dialog opens and removed when it closes.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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 use as the dialog's accessible name. When data-enable-heading is also present, this same value is also used as the visible heading text.
Reminder: This attribute is optional when data-labelledby or data-dynamic-label is present; if neither is set, data-label becomes mandatory, since a dialog must always be given an accessible name through one of these three attributes.
<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">Label 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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-labelledby attribute to a valid element id already present on the page to use as the dialog's accessible name, instead of an auto-generated one. This value is always used verbatim for aria-labelledby, taking priority over any auto-generated id. It's particularly useful in content dialog scenarios, such as the Content Insertion via Fragment example, where the dialog's own content already contains a heading that can double as its accessible name.
Reminder: This attribute is optional when data-label or data-dynamic-label is present; if neither is set, data-labelledby becomes mandatory, since a dialog must always be given an accessible name through one of these three attributes.
<button class="dialog-trigger" data-labelledby="my-custom-name" data-src="https://radancy.dev/component-library/dialog/asset/the-empire-strikes-back.webm">Labelled 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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.
Accessibility: Are transcripts necessary? Yes! Providing an alternative way to share a video's information through a transcript is valuable even when captions are already present: captions aren't accessible to every assistive technology, such as refreshable Braille displays or some screen readers, and they can move too quickly or feel distracting for some users. Transcripts, by contrast, let people search and scan content more easily, and can include additional detail, semantic markup, and hyperlinks that wouldn't be possible or appropriate within captions. Beyond accessibility, search engines can also benefit from this extra text, helping them better understand and index your content. For more, see this guide to captioned videos.
Note: Set the data-transcript-fragment attribute to reference hidden content elsewhere on the page for use as the transcript. The value must match a valid element id; invalid or missing IDs, will prevent a transcript from loading.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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();
})();Accessibility: Are transcripts necessary? Yes! Providing an alternative way to share a video's information through a transcript is valuable even when captions are already present: captions aren't accessible to every assistive technology, such as refreshable Braille displays or some screen readers, and they can move too quickly or feel distracting for some users. Transcripts, by contrast, let people search and scan content more easily, and can include additional detail, semantic markup, and hyperlinks that wouldn't be possible or appropriate within captions. Beyond accessibility, search engines can also benefit from this extra text, helping them better understand and index your content.For more, see this guide to captioned videos.
Note: Set the data-transcript-fetch attribute to reference an external resource and specific page fragment (#id), for use as the transcript source. This is especially useful in CMS environments, letting authors update the transcript without a code change. Only a valid URL, with a valid fragment when present, will be accepted.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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-dynamic-label attribute to fetch the dialog's accessible name automatically from the video provider, rather than authoring one by hand. This is especially useful when content is sourced dynamically and its title isn't known ahead of time, or in situations where a manually authored label has a high risk of being forgotten. Pairing it with a data-dynamic-alt attribute on an inner img is optional, but recommended: once the label resolves, its value is also applied to that image's alt text. Additional functional text, such as (Video), is dynamically added to allow users to understand the nature of the button and the dialog.
Reminder: This attribute is optional when data-label or data-labelledby is present; if neither is set, data-dynamic-label becomes mandatory, since a dialog must always be given an accessible name through one of these three attributes. Please also note that dynamic labeling is only supported for YouTube and Vimeo.
<button class="dialog-trigger" data-dynamic-label data-enable-heading 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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 provided in the event that the dialog element causes compatibility or rendering issues. For example, absolutely positioned elements that need to render within the dialog may otherwise be positioned behind it due to stacking context of the dialog element.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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-class attribute to append one or more space-separated class names to the dialog element, alongside its 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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 render a heading. Set the data-enable-heading attribute to enable one, using the required value provided via the data-label attribute. Note that an h1 element is used for this heading; this is acceptable within the context of a dialog, since all other page content is rendered inert while it 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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-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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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();
})();Best Practice: It is considered good practice to always add a border to your play button, as this helps prevent it from becoming lost against a light background. Pairing a darker border with a light button background color (or vice versa) ensures a greater chance that the button remains visible over both lighter and darker background imagery.
Note: Play buttons are often implemented redundantly, adding extra text for assistive technology users that could already be conveyed through a well-written alt value on the image the button wraps. Setting the data-play-button attribute handles this automatically, adding a visual play-button indicator without requiring redundant markup or text, treating is a visual cue only.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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-poster attribute to an image URL to use as the video's poster frame, shown before playback starts. Only applies to self-hosted video (mp4/webm/ogv); other types don't render a native video element to apply it to.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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: The script automatically detects when a YouTube Shorts video is being played and adjusts its default size to render at the correct aspect ratio.
<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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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);
content: "";
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 = () => {
loadLanguagePack("https://services.tmpwebeng.com/component-library/language-pack.js", () => {
// Display which version is in use via console:
console.log("%cDialog%cv1.0.5", "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 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 } = {}) => {
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 contentNode = document.createElement("p");
contentNode.textContent = dialogMissingNameMessageLabel;
dialog = buildMediaDialog(contentNode, { label: dialogMissingNameHeadingLabel, 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, "");
if (classic) {
const focusTarget = dialog.querySelector(`.${dialogControlsCloseClassName}`);
openClassicDialog(dialog, triggerElement, focusTarget);
} else {
dialog.addEventListener("cancel", (e) => {
e.preventDefault(); // Prevent default to avoid exception in some browsers.
destroyDialog(dialog);
});
dialog.addEventListener("close", () => destroyDialog(dialog));
document.body.appendChild(dialog);
// showModal() traps focus and makes the background inert for free, but doesn't lock body scroll; do that ourselves, same as the classic fallback.
dialog.dialogPreviousBodyOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialog.showModal();
}
if (type === "video" && !disableAutoplay) dialog.querySelector("video")?.play();
};
// Loop through and set up all dialog triggers on the page.
dialogTriggers.forEach(trigger => {
trigger.setAttribute("aria-haspopup", "dialog");
// 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 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, ad,
// 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("ad")) {
description = [videoSrcParams.get("ad"), videoSrcParams.get("label"), videoSrcParams.get("srclang")].filter(v => v != null).join(", ");
}
["transcript", "fragment", "ad", "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 });
});
});
// 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();
})();Report Issues
Find a bug? Want a new feature? Report it on JIRA. You're so awesome!