@codecademy/gamut

73.6.273.6.3-alpha.2b3b95.0
dist/Video/index.js
~dist/Video/index.jsModified
+62−9
Index: package/dist/Video/index.js
===================================================================
--- package/dist/Video/index.js
+++ package/dist/Video/index.js
@@ -1,11 +1,15 @@
+// @vidstack/react and react-player are peer deps of @codecademy/gamut.
+// If they can't be resolved, install them: yarn add @vidstack/react react-player
+
 import * as React from 'react';
-import { useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
 import { Box } from '../Box';
 import { useIsMounted } from '../utils';
 import { OverlayPlayButton, ReactVideoPlayer } from './lib/ReactPlayer';
 import { VidstackPlayer } from './lib/VidstackPlayer';
 import { jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
+const DEFAULT_VIDEO_TITLE = 'Video player';
 export const Video = props => {
   const {
     autoplay = false,
     className,
@@ -20,18 +24,56 @@
     showPlayerEmbed = true
   } = props;
   const [loading, setLoading] = useState(true);
   const isMounted = useIsMounted();
+  const playerWrapperRef = useRef(null);
   const config = {
     youtube: {
-      playerVars: {
-        color: 'white'
-      }
-    },
-    vimeo: {
-      title: videoTitle
+      color: 'white'
     }
   };
+
+  // react-player v3 puts `title` on its custom element, not the inner provider
+  // iframe, so the iframe would otherwise have no accessible name (WCAG 4.1.2).
+  // Provider elements like <youtube-video> render that iframe inside an open
+  // shadow root, so search shadow roots too. `onShadowRoot` lets the observer
+  // below watch each shadow root it finds.
+  const labelProviderIframe = onShadowRoot => {
+    const label = root => {
+      root.querySelectorAll('iframe').forEach(iframe => {
+        if (!iframe.title) iframe.title = videoTitle || DEFAULT_VIDEO_TITLE;
+      });
+      root.querySelectorAll('*').forEach(el => {
+        if (el.shadowRoot) {
+          onShadowRoot?.(el.shadowRoot);
+          label(el.shadowRoot);
+        }
+      });
+    };
+    if (playerWrapperRef.current) label(playerWrapperRef.current);
+  };
+
+  // The provider inserts its iframe asynchronously, often before `onReady`
+  // fires, and may replace it later (e.g. on src change), so label it
+  // whenever it appears rather than waiting.
+  useEffect(() => {
+    const wrapper = playerWrapperRef.current;
+    if (!wrapper) return;
+    const observed = new WeakSet();
+    const observe = root => {
+      if (observed.has(root)) return;
+      observed.add(root);
+      observer.observe(root, {
+        childList: true,
+        subtree: true
+      });
+    };
+    const observer = new MutationObserver(() => labelProviderIframe(observe));
+    observe(wrapper);
+    labelProviderIframe(observe);
+    return () => observer.disconnect();
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [videoTitle, videoUrl, isMounted]);
   const isExternallyHostedVideoUrl = url => !!(url.match(/youtu(be\.com|\.be)/) || url.match(/vimeo\.com/));
   const isExternallyHostedVideo = videoUrl => {
     if (!videoUrl) return false;
     if (typeof videoUrl === 'string') {
@@ -59,8 +101,9 @@
       className: className,
       overflow: "hidden",
       position: "relative",
       pt: '56.25%',
+      ref: playerWrapperRef,
       width: "100%",
       children: isMounted ? /*#__PURE__*/_jsx(ReactVideoPlayer, {
         config: config,
         controls: controls === undefined ? true : controls,
@@ -71,13 +114,14 @@
         playIcon: /*#__PURE__*/_jsx(OverlayPlayButton, {
           videoTitle: videoTitle
         }),
         playing: autoplay,
+        src: videoUrl,
         title: videoTitle,
-        url: videoUrl,
         width: "100%",
         onPlay: onPlay,
         onReady: () => {
+          labelProviderIframe();
           onReady?.();
           setLoading(false);
         }
       }) : null
@@ -88,5 +132,14 @@
       ...props,
       onLoad: () => setLoading(false)
     })
   });
-};
\ No newline at end of file
+};
+
+/*
+  Re-exported here (rather than from the main '@codecademy/gamut' barrel)
+  so Markdown's video/iframe overrides stay opt-in: importing them only
+  pulls in react-player if a consumer explicitly wants Video-in-markdown
+  rendering. See Markdown's videoOverride/iframeOverride props.
+*/
+export { Iframe } from '../Markdown/libs/overrides/Iframe';
+export { MarkdownVideo } from '../Markdown/libs/overrides/Video';
\ No newline at end of file