diff --git a/apps/e2e/shared/fixtures/selectors.ts b/apps/e2e/shared/fixtures/selectors.ts index 3740aa3f09..3528e2bd60 100644 --- a/apps/e2e/shared/fixtures/selectors.ts +++ b/apps/e2e/shared/fixtures/selectors.ts @@ -83,7 +83,7 @@ export const SELECTORS = { 'time[data-type="remaining"]', ].join(', '), timeToggle: 'media-time[toggle], time[role="button"][data-type]', - poster: 'media-poster, img[data-loaded]', + poster: 'media-poster, .media-poster', bufferingIndicator: 'media-buffering-indicator, .media-buffering-indicator', thumbnail: 'media-slider-thumbnail, .media-slider-thumbnail-image, [role="img"]:has(> img[aria-hidden="true"])', diff --git a/apps/e2e/suites/player/tests/skin-container.spec.ts b/apps/e2e/suites/player/tests/skin-container.spec.ts index 828d8bac6b..6c75c46097 100644 --- a/apps/e2e/suites/player/tests/skin-container.spec.ts +++ b/apps/e2e/suites/player/tests/skin-container.spec.ts @@ -7,9 +7,9 @@ const SOURCE_SKINS = [ const POSTER_SKINS = [ { framework: 'packaged HTML', path: '/pages/html-video-mp4.html', selector: 'img[slot="poster"]' }, - { framework: 'packaged React', path: '/pages/react-video-mp4.html', selector: '.media-skin > img' }, + { framework: 'packaged React', path: '/pages/react-video-mp4.html', selector: '.media-poster img' }, { framework: 'VJSC HTML', path: '/pages/source-html-video-mp4.html', selector: 'media-poster img' }, - { framework: 'VJSC React', path: '/pages/source-react-video-mp4.html', selector: 'img.media-poster' }, + { framework: 'VJSC React', path: '/pages/source-react-video-mp4.html', selector: '.media-poster img' }, ] as const; for (const { framework, path } of SOURCE_SKINS) { @@ -21,11 +21,11 @@ for (const { framework, path } of SOURCE_SKINS) { test('renders media and poster in one container composition', async ({ page }) => { const skin = page.locator('[data-source-skin]'); - const posterImage = skin.locator('media-poster img, img.media-poster').first(); + const posterImage = skin.locator('media-poster img, .media-poster img').first(); await expect(skin).toBeAttached(); await expect(page.locator('video')).toBeAttached(); - await expect(skin.locator('media-poster, img.media-poster')).toBeAttached(); + await expect(skin.locator('media-poster, .media-poster')).toBeAttached(); await expect(skin.locator('media-controls-content, .video-controls')).toBeAttached(); await expect(skin.locator('media-controls-backdrop, .video-controls-backdrop')).toBeAttached(); await expect(skin.locator('media-seek-button, .media-seek-button')).toHaveCount(0); @@ -33,7 +33,7 @@ for (const { framework, path } of SOURCE_SKINS) { }); test('hides the poster once playback starts', async ({ page }) => { - const poster = page.locator('media-poster, img.media-poster').first(); + const poster = page.locator('media-poster, .media-poster').first(); await expect(poster).toHaveAttribute('data-visible', ''); await expect(poster).toHaveCSS('opacity', '1'); diff --git a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts index 1311595af4..2dbf4410fb 100644 --- a/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts +++ b/apps/e2e/suites/skin-parity/tests/vjsc-video-skin-styling.spec.ts @@ -725,7 +725,7 @@ async function preparePanel({ root }: SkinPanel, width: number) { await expect(root).toHaveAttribute('data-controls-visible', ''); await expect(root.getByRole('button', { name: 'Play' })).toBeVisible(); - const poster = root.locator('img[data-loaded], media-poster[data-loaded]').first(); + const poster = root.locator('.media-poster[data-loaded], media-poster[data-loaded]').first(); await expect(poster).toBeVisible(); await expect(poster).toHaveCSS('opacity', '1'); @@ -1525,7 +1525,7 @@ async function sharedMotionContract(root: Locator) { }); const controls = root.locator(CONTROLS_SELECTOR).first(); const button = root.getByRole('button', { name: 'Play', exact: true }); - const poster = root.locator(':scope > .media-poster, :scope > img, :scope > media-poster').first(); + const poster = root.locator(':scope > .media-poster, :scope > media-poster').first(); const settingsIcon = root.getByRole('button', { name: 'Settings', exact: true }).locator('svg, media-icon').first(); const playIconCandidates = await root .getByRole('button', { name: 'Play', exact: true }) @@ -1872,7 +1872,7 @@ async function reducedMotionContract(root: Locator, menu: Locator, tooltipDurati }) ) ); - const poster = root.locator(':scope > .media-poster, :scope > img, :scope > media-poster').first(); + const poster = root.locator(':scope > .media-poster, :scope > media-poster').first(); const settingsIcon = root.getByRole('button', { name: 'Settings', exact: true }).locator('svg, media-icon').first(); const seekThumb = root.getByRole('slider', { name: 'Seek' }); const seekSlider = seekThumb.locator('..'); @@ -1986,7 +1986,7 @@ async function layoutContract(root: Locator) { return { root: inspect(element), - poster: inspect(query('img[data-loaded], media-poster[data-loaded]'), { includeRadius: false }), + poster: inspect(query('.media-poster[data-loaded], media-poster[data-loaded]'), { includeRadius: false }), controls: inspect(query(controlsSelector), { includeGap: false }), primary: inspect(play?.parentElement ?? null, { includeGap: false }), timeline: inspect(seek?.parentElement?.parentElement ?? null, { @@ -2469,7 +2469,7 @@ async function skinContract(root: Locator) { const mute = element.querySelector('[role="button"][aria-label="Mute"]'); const seek = element.querySelector('[role="slider"][aria-label="Seek"]'); const controls = play?.closest('[data-interactive], media-controls'); - const poster = element.querySelector('img[data-visible], .media-poster, media-poster'); + const poster = element.querySelector('.media-poster, media-poster'); const inspect = (target: HTMLElement | null | undefined) => { if (!target) return null; diff --git a/apps/sandbox/app/shared/html/registry-skins.ts b/apps/sandbox/app/shared/html/registry-skins.ts index 562e5b78dc..8684dee10c 100644 --- a/apps/sandbox/app/shared/html/registry-skins.ts +++ b/apps/sandbox/app/shared/html/registry-skins.ts @@ -115,6 +115,10 @@ export function defineTemplateSkin(tagName: string, source: SkinTemplate): strin if (poster instanceof HTMLImageElement) { poster.removeAttribute('slot'); + + // The template's image carries the skin's image class, which a slotted poster takes over. + if (posterTarget) poster.classList.add(...posterTarget.classList); + posterTarget?.replaceWith(poster); } else if (posterTarget instanceof HTMLSlotElement) { posterTarget.replaceWith(...posterTarget.childNodes); diff --git a/internal/design/ui/poster.md b/internal/design/ui/poster.md index 0800caefd0..fddb38a6ee 100644 --- a/internal/design/ui/poster.md +++ b/internal/design/ui/poster.md @@ -15,20 +15,20 @@ We want a simpler approach: keep the state small, let the user control the image ## Solution -**HTML:** A controller that renders no image and sets `src` on the one that is already its child. `` is the loose precedent for the shape, though sourcing runs the other way around: `` treats the `src` on its `` as the fallback, while here an image with no source of its own is the one we fill in. -**React:** Renders `` directly — no wrapper needed. +**HTML:** A controller that sets `src` on the image that is its child, drawing a fallback `` in its shadow root only while no child image is supplied. `` is the loose precedent for the shape, though sourcing runs the other way around: `` treats the `src` on its `` as the fallback, while here an image with no source of its own is the one we fill in. +**React:** A compound component with a `Poster.Root` presentation boundary and one `Poster.Image` that supplies the image lifecycle. The root accepts other children so loading previews and overlays can respond to the same state without replacing the controlled image. The URL is player state on the metadata feature, not markup here, so a hand-authored layout resolves it the same way a skin does. Either binding fills in `src` only when the consumer supplied none — the [decision below](#component-managed-image-src-prop) still holds, so it is a default rather than a takeover. The packaged skins carry a plain `` as poster-slot fallback content, which an `` displaces. Visibility: `visible = !playback.started`. The poster shows until playback starts. `started` persists — pausing doesn't reset it. -`data-visible`, `data-loading`, `data-loaded`, and `data-error` are reported on `` rather than on the image, since the image may be the author's and a skin has no selector that reaches into it. +`data-visible`, `data-loading`, `data-loaded`, and `data-error` are reported on `` and `Poster.Root` rather than on the image. The image may be the author's, and presentation layers around it need the same lifecycle state. ## Accessibility -**HTML element (``):** No ARIA role needed. Custom elements have no implicit role, so there's no semantics to hide or override. Do not add `aria-hidden` — the poster image may be informative. +**Root (`` or the `div` rendered by `Poster.Root`):** No ARIA role is needed. Do not add `aria-hidden` — the poster image may be informative. -**Image:** The one each binding renders carries `alt=""`. A resolved URL says nothing about what it depicts, and announcing the URL is worse than announcing nothing. Supply your own `alt` — as a prop in React, on your own `` in HTML — to describe a poster that carries meaning. Whether a poster is informative or decorative is the author's judgment (per [WAI guidelines](https://www.w3.org/WAI/tutorials/images/decorative/)). This is an advantage over Media Chrome (which forces `aria-hidden="true"` on the internal image) and native `