├── .gitignore ├── tests ├── assets │ └── BigBuckBunny.mp4 ├── controlled.html ├── playback.html ├── restartOnPause.html ├── playbackStartDelay.html ├── dataPlaybackState.html ├── utils │ └── hoverEvents.ts ├── unloadOnPause.html ├── events.html ├── sizingMode.html ├── overlays.html ├── hoverTarget.html ├── customElements.html ├── restartOnPause.spec.ts ├── customElements.spec.ts ├── dataPlaybackState.spec.ts ├── playback.spec.ts ├── events.spec.ts ├── playbackStartDelay.spec.ts ├── sizingMode.spec.ts ├── unloadOnPause.spec.ts ├── overlays.spec.ts ├── controlled.spec.ts └── hoverTarget.spec.ts ├── src ├── template.html ├── global.d.ts ├── styles.css └── hover-video-player.ts ├── .github └── workflows │ └── playwright.yml ├── CONTRIBUTING.md ├── package.json ├── LICENSE ├── demo └── index.html ├── CHANGELOG.md ├── playwright.config.ts ├── tsconfig.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | /test-results/ 5 | /playwright-report/ 6 | /playwright/.cache/ 7 | -------------------------------------------------------------------------------- /tests/assets/BigBuckBunny.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Gyanreyer/hover-video-player/HEAD/tests/assets/BigBuckBunny.mp4 -------------------------------------------------------------------------------- /src/template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const content: string; 3 | export default content; 4 | } 5 | declare module "*.html" { 6 | const content: string; 7 | export default content; 8 | } -------------------------------------------------------------------------------- /tests/controlled.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/playback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/restartOnPause.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/playbackStartDelay.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/dataPlaybackState.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /tests/utils/hoverEvents.ts: -------------------------------------------------------------------------------- 1 | import { Locator } from "@playwright/test"; 2 | 3 | export const hoverOver = (hoverTargetLocator: Locator, isMobile: boolean) => { 4 | if (isMobile) { 5 | return hoverTargetLocator.tap(); 6 | } else { 7 | return hoverTargetLocator.hover(); 8 | } 9 | }; 10 | 11 | export const hoverOut = (hoverTargetLocator: Locator, isMobile: boolean) => { 12 | const page = hoverTargetLocator.page(); 13 | if (isMobile) { 14 | return page.dispatchEvent("body", "touchstart"); 15 | } else { 16 | return page.mouse.move(0, 0); 17 | } 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | test: 9 | timeout-minutes: 10 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps chrome firefox 20 | - name: Run tests 21 | run: npm run test 22 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to hover-video-player 2 | 3 | ## Testing 4 | 5 | Tests can be found in the `/tests` directory. They are all integration tests using Playwright. 6 | Tests are all run against html files which are served with `web-dev-server`. Ideally, each test spec file should have its own corresponding html file. 7 | 8 | Tests can be run with `npm run test`. 9 | 10 | To run tests on a specific file instead of the whole suite, use `npm run test -- path/to/file.spec.ts`. 11 | 12 | ## Development 13 | 14 | The core component code is found in the `/src` directory. 15 | 16 | For live local testing, you can use the `demo/index.html` file which can be served with `npm run serve`. 17 | 18 | ## Publishing 19 | 20 | 1. Update version in `package.json` 21 | 1. `npm run build:release` 22 | 1. `npm publish` 23 | -------------------------------------------------------------------------------- /tests/unloadOnPause.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | 19 | 20 | 21 | 24 | 25 | 26 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/events.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |

Hover start count: 0

11 |

Hover end count: 0

12 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /tests/sizingMode.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 23 | 24 | 25 | 26 | 32 |
The video is paused.
33 |
The video is loading...
34 |
The user is hovering!
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hover-video-player", 3 | "version": "1.3.0", 4 | "description": "A web component for rendering videos that play on hover, including support for mouse and touch events and a simple API for adding thumbnails and loading states.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/Gyanreyer/hover-video-player.git" 8 | }, 9 | "files": [ 10 | "dist" 11 | ], 12 | "main": "dist/index.cjs", 13 | "module": "dist/index.mjs", 14 | "browser": "dist/index.client.js", 15 | "unpkg": "dist/index.mjs", 16 | "types": "dist/hover-video-player.d.ts", 17 | "scripts": { 18 | "serve": "web-dev-server --node-resolve --watch --open /demo/", 19 | "build": "node build.mjs", 20 | "build:release": "node build.mjs --clean && tsc", 21 | "dev": "node build.mjs --watch", 22 | "test": "npx playwright test" 23 | }, 24 | "author": "Ryan Geyer", 25 | "license": "MIT", 26 | "devDependencies": { 27 | "@playwright/test": "^1.44.0", 28 | "@web/dev-server": "^0.4.4", 29 | "esbuild": "^0.20.2", 30 | "typescript": "^5.5.3" 31 | } 32 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 hover-video-player 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /tests/overlays.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 25 | 26 | 27 | 28 | 34 |
The player is paused
35 |
The video is loading
36 |
The user is hovering
37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Demo 7 | 8 | 15 | 16 | 17 |
HOVER ON ME
18 | 19 | 29 |
33 | I AM PAUSED 34 |
35 |
36 | I AM HOVERED 37 |
38 |
39 | I AM LOADING 40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v1.2.4 4 | 5 | - Fix to ensure components will respect any initial `data-playback-state` attribute value 6 | - This means autoplay-like behavior can be achieved by setting `data-playback-state="playing"` in your HTML 7 | - Tightens up quirky behavior where duplicate `hoverstart` events could be emitted on mobile 8 | 9 | ## v1.2.3 10 | 11 | - Fix `hoverstart` and `hoverend` events not being emitted when the component is controlled; the events should fire but just be automatically canceled to leave playback state updates to the implementer 12 | - Make `hoverstart` and `hoverend` events include the originating `Event` object on their `detail` 13 | 14 | ## v1.2.2 15 | 16 | - Makes `hoverstart` and `hoverend` events cancelable for more options with controlling playback 17 | - Allows `hoverstart` and `hoverend` events to still be emitted when the component is controlled; the component just won't update playback state in response to these events 18 | 19 | ## v1.2.1 20 | 21 | - Adds `playbackstatechanged` event 22 | - Tightens up internal playback state update logic 23 | 24 | ## v1.2.0 25 | 26 | - Adds `controlled` attribute to enable controlling playback with external JS only 27 | - Adds `hover` and `blur` methods which can be called to programmatically start/stop playback 28 | - Makes component state response to external updates to `data-playback-state` attribute as another means of controlling playback state 29 | -------------------------------------------------------------------------------- /tests/hoverTarget.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 22 | 23 | 24 |
25 |
26 | 30 | 36 | 37 | 38 | 44 | 45 | 49 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /tests/customElements.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 63 | 64 | -------------------------------------------------------------------------------- /tests/restartOnPause.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { hoverOut, hoverOver } from './utils/hoverEvents'; 3 | 4 | test("restartOnPause causes the video to reset to the beginning when paused", async ({ page, isMobile }) => { 5 | await page.goto("/tests/restartOnPause.html"); 6 | 7 | const hoverVideoPlayer = await page.locator("hover-video-player"); 8 | const video = await hoverVideoPlayer.locator("video"); 9 | 10 | await expect(hoverVideoPlayer).toHaveAttribute("restart-on-pause", ""); 11 | await expect(hoverVideoPlayer).toHaveJSProperty("restartOnPause", true); 12 | 13 | await expect(video).toHaveJSProperty("currentTime", 0); 14 | 15 | await hoverOver(hoverVideoPlayer, isMobile); 16 | 17 | await expect(video).toHaveJSProperty("paused", false); 18 | await expect(video).not.toHaveJSProperty("currentTime", 0); 19 | 20 | await hoverOut(hoverVideoPlayer, isMobile); 21 | 22 | await expect(video).toHaveJSProperty("currentTime", 0); 23 | 24 | await hoverVideoPlayer.evaluate((_hoverVideoPlayer: HTMLElement & { restartOnPause: boolean }) => { 25 | _hoverVideoPlayer.restartOnPause = false; 26 | }); 27 | 28 | await expect(hoverVideoPlayer).not.toHaveAttribute("restart-on-pause", ""); 29 | await expect(hoverVideoPlayer).toHaveJSProperty("restartOnPause", false); 30 | 31 | await hoverOver(hoverVideoPlayer, isMobile); 32 | 33 | await expect(video).not.toHaveJSProperty("currentTime", 0); 34 | 35 | await hoverOut(hoverVideoPlayer, isMobile); 36 | 37 | await expect(video).not.toHaveJSProperty("currentTime", 0); 38 | 39 | await hoverVideoPlayer.evaluate((_hoverVideoPlayer: HTMLElement & { restartOnPause: boolean }) => { 40 | _hoverVideoPlayer.restartOnPause = true; 41 | }); 42 | 43 | await expect(hoverVideoPlayer).toHaveAttribute("restart-on-pause", "true"); 44 | await expect(hoverVideoPlayer).toHaveJSProperty("restartOnPause", true); 45 | }); -------------------------------------------------------------------------------- /tests/customElements.spec.ts: -------------------------------------------------------------------------------- 1 | 2 | import { test, expect } from '@playwright/test'; 3 | import { hoverOut, hoverOver } from './utils/hoverEvents'; 4 | import type HoverVideoPlayer from '../src/hover-video-player'; 5 | 6 | test("custom elements which define HTMLMediaElement APIs can be used as video players", async ({ page, isMobile }) => { 7 | await page.goto("/tests/customElements.html"); 8 | 9 | const hoverVideoPlayer = await page.locator("[data-testid=ce-player]"); 10 | const videoPlayer = await hoverVideoPlayer.locator("my-player"); 11 | 12 | await expect(hoverVideoPlayer.evaluate((el: HoverVideoPlayer, expectedVideoPlayer) => el.video === expectedVideoPlayer, await videoPlayer.elementHandle())).resolves.toBeTruthy(); 13 | 14 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"); 15 | 16 | await hoverOver(hoverVideoPlayer, isMobile); 17 | 18 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"); 19 | 20 | await hoverOut(hoverVideoPlayer, isMobile); 21 | }); 22 | 23 | test("slot change promise waiting for a custom element to be defined will be cancelled if another slot change happens", async ({ page }) => { 24 | await page.goto("/tests/customElements.html"); 25 | 26 | const hoverVideoPlayer = await page.locator("[data-testid=cancel-waiting-for-ce-player]"); 27 | 28 | await expect(hoverVideoPlayer.evaluate((el: HoverVideoPlayer) => el.video)).resolves.toBeNull(); 29 | 30 | const newPlayerHandle = await hoverVideoPlayer.evaluateHandle((el: HTMLElement) => { 31 | const newPlayerElement = document.createElement("my-player"); 32 | newPlayerElement.setAttribute("src", "/tests/assets/BigBuckBunny.mp4"); 33 | newPlayerElement.setAttribute("data-testid", "new-player"); 34 | el.appendChild(newPlayerElement); 35 | 36 | return newPlayerElement; 37 | }); 38 | 39 | await expect(hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer, newPlayerHandle) => el.video === newPlayerHandle, newPlayerHandle)).resolves.toBeTruthy(); 40 | }); 41 | 42 | test("custom elements which do not define HTMLMediaElement APIs will not be used", async ({ page }) => { 43 | await page.goto("/tests/customElements.html"); 44 | 45 | const hoverVideoPlayer = await page.locator("[data-testid=invalid-ce-player]"); 46 | 47 | await expect(hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => el.video).then(h => h.asElement())).resolves.toBeNull(); 48 | }); -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { devices, defineConfig, PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | /** 4 | * See https://playwright.dev/docs/test-configuration. 5 | */ 6 | export default defineConfig({ 7 | testDir: './tests', 8 | /* Maximum time one test can run for. */ 9 | timeout: 30 * 1000, 10 | expect: { 11 | /** 12 | * Maximum time expect() should wait for the condition to be met. 13 | * For example in `await expect(locator).toHaveText();` 14 | */ 15 | timeout: 5000 16 | }, 17 | /* Run tests in files in parallel */ 18 | fullyParallel: true, 19 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 20 | forbidOnly: Boolean(process.env.CI), 21 | /* Retry on CI only */ 22 | retries: process.env.CI ? 2 : 0, 23 | /* Opt out of parallel tests on CI. */ 24 | workers: process.env.CI ? 1 : undefined, 25 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 26 | reporter: [['list', { printSteps: true }]], 27 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 28 | use: { 29 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 30 | actionTimeout: 1000, 31 | /* Base URL to use in actions like `await page.goto('/')`. */ 32 | baseURL: 'http://localhost:8080', 33 | 34 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 35 | trace: 'on-first-retry', 36 | }, 37 | 38 | /* Configure projects for major browsers */ 39 | projects: [ 40 | { 41 | name: 'firefox', 42 | use: { 43 | ...devices['Desktop Firefox'], 44 | }, 45 | }, 46 | /* Test against mobile viewports. */ 47 | { 48 | name: 'Mobile Chrome', 49 | use: { 50 | channel: "chrome", 51 | viewport: { width: 360, height: 640 }, 52 | hasTouch: true, 53 | isMobile: true, 54 | }, 55 | }, 56 | { 57 | name: 'Google Chrome', 58 | use: { 59 | channel: 'chrome', 60 | }, 61 | }, 62 | ], 63 | 64 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 65 | // outputDir: 'test-results/', 66 | 67 | /* Run your local dev server before starting the tests */ 68 | webServer: { 69 | command: 'node build.mjs --builds esm && web-dev-server --node-resolve --port 8080', 70 | port: 8080, 71 | }, 72 | }) -------------------------------------------------------------------------------- /tests/dataPlaybackState.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import type HoverVideoPlayer from '../src/hover-video-player'; 3 | 4 | test("playback transitions correctly when data-playback-state attribute is manually manipulated", async ({ page }) => { 5 | await page.goto("/tests/dataPlaybackState.html"); 6 | 7 | const hoverVideoPlayer = await page.locator("[data-testid=no-initial-playback-state]"); 8 | const video = await hoverVideoPlayer.locator("video"); 9 | 10 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"); 11 | 12 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.setAttribute("data-playback-state", "playing"); }); 13 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"); 14 | await expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""); 15 | 16 | await expect(video).toHaveJSProperty("paused", false); 17 | 18 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.dataset.playbackState = "paused"; }); 19 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"); 20 | await expect(video).toHaveJSProperty("paused", true); 21 | 22 | // Setting state to "loading" will start the video as well 23 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.dataset.playbackState = "loading"; }); 24 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"); 25 | await expect(video).toHaveJSProperty("paused", false); 26 | 27 | // Removing the attribute should pause the video 28 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.removeAttribute("data-playback-state"); }); 29 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"); 30 | await expect(video).toHaveJSProperty("paused", true); 31 | }); 32 | 33 | test("an initial data-playback-state attribute will be respected", async ({ context, page }) => { 34 | await page.goto("/tests/dataPlaybackState.html"); 35 | 36 | const hoverVideoPlayer = await page.locator("[data-testid=initial-playing-state]"); 37 | const video = await hoverVideoPlayer.locator("video"); 38 | 39 | await expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"); 40 | await expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""); 41 | await expect(video).toHaveJSProperty("paused", false); 42 | 43 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.dataset.playbackState = "paused"; }); 44 | }); -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: inline-block; 3 | position: relative; 4 | --overlay-transition-duration: 0.4s; 5 | --loading-timeout-duration: 0.2s; 6 | } 7 | 8 | :host([sizing-mode="video"]) ::slotted(:not([slot])) { 9 | display: block; 10 | width: 100%; 11 | } 12 | 13 | :host([sizing-mode="overlay"]) ::slotted([slot="paused-overlay"]) { 14 | position: relative; 15 | } 16 | 17 | :host(:is([sizing-mode="overlay"], [sizing-mode="container"])) 18 | ::slotted(:not([slot])) { 19 | object-fit: cover; 20 | } 21 | 22 | /* Style videos and overlays to cover the container depending on the sizing mode */ 23 | /* The video element should expand to cover the container in all but the "video" sizing mode */ 24 | :host(:is([sizing-mode="overlay"], [sizing-mode="container"])) ::slotted(:not([slot])), 25 | /* The paused overlay should expand to cover the container in all but the "overlay" sizing mode */ 26 | :host(:is([sizing-mode="video"], [sizing-mode="container"])) ::slotted([slot="paused-overlay"]), 27 | /* The loading and hover overlays should always expand to cover the container */ 28 | ::slotted(:is([slot="loading-overlay"], [slot="hover-overlay"])) { 29 | position: absolute; 30 | width: 100%; 31 | height: 100%; 32 | inset: 0; 33 | } 34 | 35 | ::slotted( 36 | :is( 37 | [slot="paused-overlay"], 38 | [slot="loading-overlay"], 39 | [slot="hover-overlay"] 40 | ) 41 | ) { 42 | display: block; 43 | opacity: 0; 44 | visibility: hidden; 45 | --transition-delay: 0s; 46 | --visibility-transition-delay: var(--overlay-transition-duration); 47 | transition: opacity var(--overlay-transition-duration) var(--transition-delay), 48 | visibility 0s 49 | calc(var(--transition-delay) + var(--visibility-transition-delay)); 50 | } 51 | 52 | ::slotted([slot="paused-overlay"]) { 53 | z-index: 1; 54 | } 55 | 56 | ::slotted([slot="loading-overlay"]) { 57 | z-index: 2; 58 | } 59 | 60 | ::slotted([slot="hover-overlay"]) { 61 | z-index: 3; 62 | } 63 | 64 | /* Fade in overlays for their appropriate playback states */ 65 | :host(:is([data-playback-state="paused"], [data-playback-state="loading"])) 66 | ::slotted([slot="paused-overlay"]), 67 | :host([data-playback-state="loading"]) ::slotted([slot="loading-overlay"]), 68 | :host([data-is-hovering]) ::slotted([slot="hover-overlay"]) { 69 | opacity: 1; 70 | visibility: visible; 71 | --visibility-transition-delay: 0s; 72 | } 73 | 74 | :host([data-playback-state="loading"]) ::slotted([slot="loading-overlay"]) { 75 | /* Delay the loading overlay fading in */ 76 | --transition-delay: var(--loading-timeout-duration); 77 | } 78 | -------------------------------------------------------------------------------- /tests/playback.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import type HoverVideoPlayer from '../src/hover-video-player'; 3 | import { hoverOut, hoverOver } from './utils/hoverEvents'; 4 | 5 | test('hover-video-player component starts and stops playback as expected when the user hovers', async ({ context, page, isMobile }) => { 6 | await context.route("**/*.mp4", (route) => new Promise((resolve) => { 7 | // Add a .25 second delay before resolving the request for the video asset so we can 8 | // test that the player enters a loading state while waiting for the video to load. 9 | setTimeout(() => { 10 | resolve(route.continue()); 11 | }, 250); 12 | })); 13 | await page.goto('/tests/playback.html'); 14 | 15 | const hoverVideoPlayer = await page.locator("hover-video-player"); 16 | const video = await hoverVideoPlayer.locator("video"); 17 | 18 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => el.addEventListener("playbackstatechange", (evt) => { 19 | (window as any).playbackstatechangeCalls ??= []; 20 | if (evt instanceof CustomEvent) { 21 | (window as any).playbackstatechangeCalls.push(evt.detail); 22 | } 23 | })); 24 | 25 | // The component's initial state should all be as expected; the user is not hovering and the video is not playing 26 | await Promise.all([ 27 | expect(hoverVideoPlayer).toBeVisible(), 28 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 29 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 30 | expect(video).toHaveJSProperty("paused", true), 31 | ]); 32 | 33 | await hoverOver(hoverVideoPlayer, isMobile); 34 | 35 | // The component's attributes should be updated to show that the user is hovering and the video is loading 36 | await Promise.all([ 37 | expect(hoverVideoPlayer).toHaveAttribute("data-is-hovering", ""), 38 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "loading"), 39 | ]); 40 | 41 | // The video should finish loading and start playing 42 | await Promise.all([ 43 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 44 | expect(video).toHaveJSProperty("paused", false), 45 | ]); 46 | 47 | // Mouse out or tap outside of the player to stop playback 48 | await hoverOut(hoverVideoPlayer, isMobile); 49 | 50 | // The component's state should be updated to show that the user is no longer hovering and the video is paused again 51 | await Promise.all([ 52 | expect(video).toHaveJSProperty("paused", true), 53 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 54 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 55 | ]); 56 | 57 | const playbackStateChangeCalls = await page.evaluate(() => (window as any).playbackstatechangeCalls); 58 | 59 | await expect(playbackStateChangeCalls).toEqual([ 60 | "loading", 61 | "playing", 62 | "paused", 63 | ]); 64 | }); -------------------------------------------------------------------------------- /tests/events.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import type HoverVideoPlayer from '../src/hover-video-player'; 3 | import { hoverOver, hoverOut } from './utils/hoverEvents'; 4 | 5 | test("fires hoverstart and hoverend events as expected", async ({ page, isMobile }) => { 6 | await page.goto("/tests/events.html"); 7 | 8 | const component = await page.locator("hover-video-player"); 9 | const hoverStartCounter = await page.locator("#hover-start-count"); 10 | const hoverEndCounter = await page.locator("#hover-end-count"); 11 | 12 | await expect(hoverStartCounter).toHaveText("0"); 13 | await expect(hoverEndCounter).toHaveText("0"); 14 | 15 | await hoverOver(component, isMobile); 16 | 17 | await expect(hoverStartCounter).toHaveText("1"); 18 | await expect(hoverEndCounter).toHaveText("0"); 19 | 20 | await hoverOut(component, isMobile); 21 | 22 | await expect(hoverStartCounter).toHaveText("1"); 23 | await expect(hoverEndCounter).toHaveText("1"); 24 | }); 25 | 26 | test("hoverstart events can be prevented", async ({ page, isMobile }) => { 27 | await page.goto("/tests/events.html"); 28 | 29 | const component = await page.locator("hover-video-player"); 30 | 31 | await component.evaluate((el: HoverVideoPlayer) => { 32 | (window as any).hoverStartListener = (evt) => { 33 | evt.preventDefault(); 34 | }; 35 | el.addEventListener("hoverstart", (window as any).hoverStartListener); 36 | }); 37 | 38 | await hoverOver(component, isMobile); 39 | 40 | await expect(component).not.toHaveAttribute("data-is-hovering", ""); 41 | 42 | await hoverOut(component, isMobile); 43 | 44 | // Remoe the listener and hover again 45 | await component.evaluate((el: HoverVideoPlayer) => { 46 | el.removeEventListener("hoverstart", (window as any).hoverStartListener); 47 | }); 48 | 49 | // Now hovering should work 50 | await hoverOver(component, isMobile); 51 | await expect(component).toHaveAttribute("data-is-hovering", ""); 52 | 53 | await hoverOut(component, isMobile); 54 | }); 55 | 56 | test("hoverend events can be prevented", async ({ page, isMobile }) => { 57 | await page.goto("/tests/events.html"); 58 | 59 | const component = await page.locator("hover-video-player"); 60 | 61 | await component.evaluate((el: HoverVideoPlayer) => { 62 | (window as any).hoverEndListener = (evt) => { 63 | evt.preventDefault(); 64 | }; 65 | el.addEventListener("hoverend", (window as any).hoverEndListener); 66 | }); 67 | 68 | await hoverOver(component, isMobile); 69 | 70 | await expect(component).toHaveAttribute("data-is-hovering", ""); 71 | 72 | await hoverOut(component, isMobile); 73 | 74 | // Still hovering because hoverend was cancelled 75 | await expect(component).toHaveAttribute("data-is-hovering", ""); 76 | 77 | await component.evaluate((el: HoverVideoPlayer) => { 78 | el.removeEventListener("hoverend", (window as any).hoverEndListener); 79 | }); 80 | 81 | // Re-hover so we can test that hoverend is not prevented 82 | await hoverOver(component, isMobile); 83 | await expect(component).toHaveAttribute("data-is-hovering", ""); 84 | 85 | // Now mousing out should work 86 | await hoverOut(component, isMobile); 87 | await expect(component).not.toHaveAttribute("data-is-hovering", ""); 88 | }); -------------------------------------------------------------------------------- /tests/playbackStartDelay.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { hoverOut, hoverOver } from './utils/hoverEvents'; 3 | 4 | test("playback-start-delay attribute works as expected", async ({ page, isMobile }) => { 5 | await page.goto('/tests/playbackStartDelay.html'); 6 | 7 | const hoverVideoPlayer = await page.locator("hover-video-player"); 8 | const video = await hoverVideoPlayer.locator("video"); 9 | 10 | await Promise.all([ 11 | expect(hoverVideoPlayer).toHaveAttribute("playback-start-delay", "300"), 12 | expect(hoverVideoPlayer).toHaveJSProperty("playbackStartDelay", 300), 13 | ]); 14 | 15 | await hoverOver(hoverVideoPlayer, isMobile); 16 | await expect(video, "the video should still be paused").toHaveJSProperty("paused", true); 17 | await page.waitForTimeout(300); 18 | 19 | await expect(video, "the video should be playing now").toHaveJSProperty("paused", false); 20 | 21 | await hoverOut(hoverVideoPlayer, isMobile); 22 | await expect(video).toHaveJSProperty("paused", true); 23 | 24 | await hoverVideoPlayer.evaluate((_hoverVideoPlayer: HTMLElement & { playbackStartDelay: number }) => { 25 | _hoverVideoPlayer.playbackStartDelay = 500; 26 | }); 27 | 28 | await expect(hoverVideoPlayer).toHaveAttribute("playback-start-delay", "500"); 29 | 30 | await hoverOver(hoverVideoPlayer, isMobile); 31 | await expect(video, "the video should still be paused").toHaveJSProperty("paused", true); 32 | await page.waitForTimeout(500); 33 | 34 | await expect(video, "the video should be playing now").toHaveJSProperty("paused", false); 35 | 36 | await hoverOut(hoverVideoPlayer, isMobile); 37 | await expect(video).toHaveJSProperty("paused", true); 38 | 39 | await hoverVideoPlayer.evaluate((_hoverVideoPlayer: HTMLElement & { playbackStartDelay: number }) => { 40 | _hoverVideoPlayer.setAttribute("playback-start-delay", "0.55s"); 41 | }); 42 | 43 | await expect(hoverVideoPlayer).toHaveJSProperty("playbackStartDelay", 550); 44 | 45 | await expect(video).toHaveJSProperty("paused", true); 46 | 47 | await hoverVideoPlayer.evaluate((_hoverVideoPlayer: HTMLElement & { playbackStartDelay: number }) => { 48 | _hoverVideoPlayer.removeAttribute("playback-start-delay"); 49 | }); 50 | 51 | await expect(hoverVideoPlayer).toHaveJSProperty("playbackStartDelay", 0); 52 | 53 | await hoverVideoPlayer.evaluate((_hoverVideoPlayer: HTMLElement & { playbackStartDelay: number }) => { 54 | _hoverVideoPlayer.playbackStartDelay = 500; 55 | }); 56 | await expect(hoverVideoPlayer).not.toHaveAttribute("playback-start-delay", "500"); 57 | }) 58 | 59 | test("playback timeouts are canceled if the user mouses away before it can run", async ({ page }) => { 60 | await page.goto('/tests/playbackStartDelay.html'); 61 | 62 | const hoverVideoPlayer = await page.locator("hover-video-player"); 63 | const video = await hoverVideoPlayer.locator("video"); 64 | 65 | await Promise.all([ 66 | expect(hoverVideoPlayer).toHaveAttribute("playback-start-delay", "300"), 67 | expect(hoverVideoPlayer).toHaveJSProperty("playbackStartDelay", 300), 68 | ]); 69 | 70 | await hoverOver(hoverVideoPlayer, false); 71 | await expect(video, "the video should still be paused").toHaveJSProperty("paused", true); 72 | await hoverOut(hoverVideoPlayer, false); 73 | await page.waitForTimeout(500); 74 | 75 | await expect(video, "the video should still not be playing").toHaveJSProperty("paused", true); 76 | }) -------------------------------------------------------------------------------- /tests/sizingMode.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test('the sizing-mode attribute updates styles as expected', async ({ page }) => { 4 | await page.goto('/tests/sizingMode.html'); 5 | 6 | const hoverVideoPlayer = await page.locator("hover-video-player"); 7 | const video = await hoverVideoPlayer.locator("video"); 8 | const pausedOverlay = await hoverVideoPlayer.locator("[slot='paused-overlay']"); 9 | const loadingOverlay = await hoverVideoPlayer.locator("[slot='loading-overlay']"); 10 | const hoverOverlay = await hoverVideoPlayer.locator("[slot='hover-overlay']"); 11 | 12 | // Default sizing mode is "video" 13 | await expect(hoverVideoPlayer).toHaveAttribute("sizing-mode", "video"); 14 | 15 | // Get bounding boxes for all elements so we can check that they're all the same size 16 | let [videoBoundingBox, componentBoundingBox, pausedOverlayBoundingBox, loadingOverlayBoundingBox, hoverOverlayBoundingBox] = await Promise.all([ 17 | video.boundingBox(), 18 | hoverVideoPlayer.boundingBox(), 19 | pausedOverlay.boundingBox(), 20 | loadingOverlay.boundingBox(), 21 | hoverOverlay.boundingBox(), 22 | ]); 23 | 24 | if (!videoBoundingBox || !componentBoundingBox || !pausedOverlayBoundingBox || !loadingOverlayBoundingBox || !hoverOverlayBoundingBox) { 25 | throw new Error('Element bounding box is unexpectedly null'); 26 | } 27 | 28 | const windowWidth = await page.evaluate(() => window.innerWidth); 29 | // The video should be its native 1280 width at most, or the window width if the window is smaller 30 | const expectedVideoWidth = Math.min(windowWidth, 1280); 31 | const expectedVideoHeight = expectedVideoWidth * 720 / 1280; 32 | 33 | await expect(videoBoundingBox.width).toBeCloseTo(expectedVideoWidth, 1); 34 | await expect(videoBoundingBox.height).toBeCloseTo(expectedVideoHeight, 1); 35 | 36 | // The component and all overlays should match the video's dimensions 37 | await Promise.all([ 38 | expect(componentBoundingBox).toEqual(videoBoundingBox), 39 | expect(pausedOverlayBoundingBox).toEqual(videoBoundingBox), 40 | expect(loadingOverlayBoundingBox).toEqual(videoBoundingBox), 41 | expect(hoverOverlayBoundingBox).toEqual(videoBoundingBox), 42 | ]); 43 | 44 | // Set the sizing-mode to "container" 45 | await hoverVideoPlayer.evaluate((hvpElement) => { 46 | hvpElement.setAttribute('sizing-mode', 'container'); 47 | hvpElement.style.width = '100px'; 48 | hvpElement.style.height = '200px'; 49 | }); 50 | 51 | // Get the new bounding boxes 52 | [videoBoundingBox, componentBoundingBox, pausedOverlayBoundingBox, loadingOverlayBoundingBox, hoverOverlayBoundingBox] = await Promise.all([ 53 | video.boundingBox(), 54 | hoverVideoPlayer.boundingBox(), 55 | pausedOverlay.boundingBox(), 56 | loadingOverlay.boundingBox(), 57 | hoverOverlay.boundingBox(), 58 | ]); 59 | 60 | if (!videoBoundingBox || !componentBoundingBox || !pausedOverlayBoundingBox || !loadingOverlayBoundingBox || !hoverOverlayBoundingBox) { 61 | throw new Error('Element bounding box is unexpectedly null'); 62 | } 63 | 64 | await expect(componentBoundingBox.width).toBeCloseTo(100); 65 | await expect(componentBoundingBox.height).toBeCloseTo(200); 66 | 67 | // The video and all overlays should match the component's dimensions 68 | await Promise.all([ 69 | expect(videoBoundingBox).toEqual(componentBoundingBox), 70 | expect(pausedOverlayBoundingBox).toEqual(componentBoundingBox), 71 | expect(loadingOverlayBoundingBox).toEqual(componentBoundingBox), 72 | expect(hoverOverlayBoundingBox).toEqual(componentBoundingBox), 73 | ]); 74 | 75 | // Set the sizing-mode to "overlay" 76 | await hoverVideoPlayer.evaluate((hvpElement) => { 77 | hvpElement.setAttribute('sizing-mode', 'overlay'); 78 | hvpElement.style.width = ''; 79 | hvpElement.style.height = ''; 80 | }); 81 | await pausedOverlay.evaluate((overlayElement) => { 82 | overlayElement.style.width = '300px'; 83 | overlayElement.style.height = '600px'; 84 | }); 85 | 86 | // Get the new bounding boxes 87 | [videoBoundingBox, componentBoundingBox, pausedOverlayBoundingBox, loadingOverlayBoundingBox, hoverOverlayBoundingBox] = await Promise.all([ 88 | video.boundingBox(), 89 | hoverVideoPlayer.boundingBox(), 90 | pausedOverlay.boundingBox(), 91 | loadingOverlay.boundingBox(), 92 | hoverOverlay.boundingBox(), 93 | ]); 94 | 95 | if (!videoBoundingBox || !componentBoundingBox || !pausedOverlayBoundingBox || !loadingOverlayBoundingBox || !hoverOverlayBoundingBox) { 96 | throw new Error('Element bounding box is unexpectedly null'); 97 | } 98 | 99 | await expect(pausedOverlayBoundingBox.width).toBeCloseTo(300); 100 | await expect(pausedOverlayBoundingBox.height).toBeCloseTo(600); 101 | 102 | // The component, video, and other overlays should match the component's dimensions 103 | await Promise.all([ 104 | expect(componentBoundingBox).toEqual(pausedOverlayBoundingBox), 105 | expect(videoBoundingBox).toEqual(pausedOverlayBoundingBox), 106 | expect(loadingOverlayBoundingBox).toEqual(pausedOverlayBoundingBox), 107 | expect(hoverOverlayBoundingBox).toEqual(pausedOverlayBoundingBox), 108 | ]); 109 | }); -------------------------------------------------------------------------------- /tests/unloadOnPause.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { hoverOut, hoverOver } from './utils/hoverEvents'; 3 | 4 | const HAVE_NOTHING = 0; 5 | const HAVE_ENOUGH_DATA = 4; 6 | 7 | test("unload-on-pause unloads video sources as expected for videos whose source is set by the src attribute", async ({ page, isMobile }) => { 8 | await page.goto("/tests/unloadOnPause.html"); 9 | 10 | const componentWithSrcAttribute = await page.locator("[data-testid='src-attribute']"); 11 | const videoWithSrcAttribute = await componentWithSrcAttribute.locator("video"); 12 | 13 | await Promise.all([ 14 | expect(componentWithSrcAttribute).toHaveAttribute("unload-on-pause", ""), 15 | expect(componentWithSrcAttribute).toHaveJSProperty("unloadOnPause", true), 16 | // The video should only have metadata loaded at this point 17 | expect(videoWithSrcAttribute).toHaveJSProperty("readyState", HAVE_NOTHING), 18 | ]); 19 | 20 | // Hover to start playback 21 | await hoverOver(componentWithSrcAttribute, isMobile); 22 | // The video's source should be loaded now that it's playing 23 | await expect(videoWithSrcAttribute).toHaveJSProperty("readyState", HAVE_ENOUGH_DATA); 24 | 25 | // Mouse out to pause and unload the video 26 | await hoverOut(componentWithSrcAttribute, isMobile); 27 | // The video's source should be unloaded 28 | await expect(videoWithSrcAttribute).toHaveJSProperty("readyState", HAVE_NOTHING); 29 | 30 | // Disable unloading on pause 31 | await componentWithSrcAttribute.evaluate((componentElement: HTMLElement & { unloadOnPause: boolean }) => componentElement.unloadOnPause = false); 32 | 33 | // Hover to start playback 34 | await hoverOver(componentWithSrcAttribute, isMobile); 35 | // The video's source should be loaded now that it's playing 36 | await expect(videoWithSrcAttribute).toHaveJSProperty("readyState", HAVE_ENOUGH_DATA); 37 | 38 | // Mouse out to pause and unload the video 39 | await hoverOut(componentWithSrcAttribute, isMobile); 40 | // The video should not have been unloaded 41 | await expect(videoWithSrcAttribute).toHaveJSProperty("readyState", HAVE_ENOUGH_DATA); 42 | }); 43 | 44 | test("unload-on-pause unloads video sources as expected for videos whose source is set by source tags", async ({ page }) => { 45 | await page.goto("/tests/unloadOnPause.html"); 46 | 47 | const componentWithSourceTag = await page.locator("[data-testid='source-tag']"); 48 | const videoWithSourceTag = await componentWithSourceTag.locator("video"); 49 | 50 | await Promise.all([ 51 | expect(componentWithSourceTag).toHaveAttribute("unload-on-pause", ""), 52 | expect(componentWithSourceTag).toHaveJSProperty("unloadOnPause", true), 53 | expect(videoWithSourceTag).toHaveJSProperty("preload", "none"), 54 | expect(videoWithSourceTag).toHaveJSProperty("readyState", HAVE_NOTHING), 55 | ]); 56 | 57 | // Hover to start playback 58 | await hoverOver(componentWithSourceTag, false); 59 | // The video's source should be loaded now that it's playing 60 | await expect(videoWithSourceTag).toHaveJSProperty("readyState", HAVE_ENOUGH_DATA); 61 | 62 | // Mouse out to pause and unload the video 63 | await hoverOut(componentWithSourceTag, false); 64 | // The video's source should be unloaded 65 | await expect(videoWithSourceTag).toHaveJSProperty("readyState", HAVE_NOTHING); 66 | 67 | // Disable unloading on pause 68 | await componentWithSourceTag.evaluate((componentElement: HTMLElement & { unloadOnPause: boolean }) => componentElement.unloadOnPause = false); 69 | 70 | // Hover to start playback 71 | await hoverOver(componentWithSourceTag, false); 72 | // The video's source should be loaded now that it's playing 73 | await expect(videoWithSourceTag).toHaveJSProperty("readyState", HAVE_ENOUGH_DATA); 74 | 75 | // Mouse out to pause and unload the video 76 | await hoverOut(componentWithSourceTag, false); 77 | // The video should not have been unloaded 78 | await expect(videoWithSourceTag).toHaveJSProperty("readyState", HAVE_ENOUGH_DATA); 79 | }); 80 | 81 | test("if unload-on-pause is set and the video does not have a preload attribute set, it will default to metadata", async ({ page }) => { 82 | await page.goto("/tests/unloadOnPause.html"); 83 | 84 | const component = await page.locator("[data-testid='no-preload-attribute']"); 85 | const video = await component.locator("video"); 86 | 87 | await expect(video).toHaveJSProperty("preload", "metadata"); 88 | }); 89 | 90 | test("interacts with restart-on-pause as expected", async ({ page }) => { 91 | await page.goto("/tests/unloadOnPause.html"); 92 | 93 | const component = await page.locator("[data-testid='no-preload-attribute']"); 94 | const video = await component.locator("video"); 95 | 96 | await expect(video).toHaveJSProperty("currentTime", 0); 97 | await expect(component).toHaveJSProperty("restartOnPause", false); 98 | 99 | // Hover to start playback 100 | await hoverOver(component, false); 101 | 102 | await expect(video).not.toHaveJSProperty("currentTime", 0); 103 | 104 | // Mouse out to pause and unload the video 105 | await hoverOut(component, false); 106 | 107 | // The video should not have been reset to the beginning 108 | await expect(video).not.toHaveJSProperty("currentTime", 0); 109 | 110 | // Enable restart on pause 111 | await component.evaluate((componentElement: HTMLElement & { restartOnPause: boolean }) => componentElement.restartOnPause = true); 112 | 113 | // Hover to start playback 114 | await hoverOver(component, false); 115 | 116 | await expect(video).not.toHaveJSProperty("currentTime", 0); 117 | 118 | // Mouse out to pause and unload the video 119 | await hoverOut(component, false); 120 | 121 | // The video should have been reset to the bueginning 122 | await expect(video).toHaveJSProperty("currentTime", 0); 123 | }) -------------------------------------------------------------------------------- /tests/overlays.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import { hoverOver, hoverOut } from './utils/hoverEvents'; 3 | 4 | test("contents in the hover-overlay slot work as expected", async ({ page, isMobile }) => { 5 | await page.goto("/tests/overlays.html"); 6 | 7 | const hoverVideoPlayer = await page.locator("hover-video-player"); 8 | const hoverOverlay = await hoverVideoPlayer.locator("[slot='hover-overlay']"); 9 | 10 | await Promise.all([ 11 | expect(hoverOverlay).toHaveCSS("opacity", "0"), 12 | expect(hoverOverlay).toHaveCSS("transition-property", "opacity, visibility"), 13 | expect(hoverOverlay).toHaveCSS("transition-duration", "0.3s, 0s"), 14 | expect(hoverOverlay).toHaveCSS("transition-delay", "0s, 0.3s"), 15 | ]); 16 | 17 | await hoverOver(hoverVideoPlayer, isMobile); 18 | 19 | await expect(hoverOverlay).toHaveCSS("opacity", "1"); 20 | 21 | await hoverOut(hoverVideoPlayer, isMobile); 22 | 23 | await expect(hoverOverlay).toHaveCSS("opacity", "0"); 24 | }); 25 | 26 | test("contents in the paused-overlay slot work as expected", async ({ page, isMobile }) => { 27 | await page.route("**/*.mp4", (route) => new Promise((resolve) => { 28 | // Add a .25 second delay before resolving the request for the video asset so we can 29 | // test that the player enters a loading state while waiting for the video to load. 30 | setTimeout(() => resolve(route.continue()), 250); 31 | })); 32 | await page.goto("/tests/overlays.html"); 33 | 34 | const hoverVideoPlayer = await page.locator("hover-video-player"); 35 | const video = await hoverVideoPlayer.locator("video"); 36 | const pausedOverlay = await hoverVideoPlayer.locator("[slot='paused-overlay']"); 37 | 38 | await Promise.all([ 39 | expect(pausedOverlay).toHaveCSS("opacity", "1"), 40 | // The visibility transition delay should be 0s because the paused-overlay is currently visible 41 | expect(pausedOverlay).toHaveCSS("transition-property", "opacity, visibility"), 42 | expect(pausedOverlay).toHaveCSS("transition-duration", "0.5s, 0s"), 43 | expect(pausedOverlay).toHaveCSS("transition-delay", "0s, 0s"), 44 | ]) 45 | 46 | await hoverOver(hoverVideoPlayer, isMobile); 47 | 48 | await Promise.all([ 49 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "loading"), 50 | expect(pausedOverlay).toHaveCSS("opacity", "1"), 51 | expect(pausedOverlay).toHaveCSS("transition-property", "opacity, visibility"), 52 | expect(pausedOverlay).toHaveCSS("transition-duration", "0.5s, 0s"), 53 | expect(pausedOverlay).toHaveCSS("transition-delay", "0s, 0s"), 54 | ]); 55 | await Promise.all([ 56 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 57 | expect(pausedOverlay).toHaveCSS("opacity", "0"), 58 | expect(pausedOverlay).toHaveCSS("transition-property", "opacity, visibility"), 59 | expect(pausedOverlay).toHaveCSS("transition-duration", "0.5s, 0s"), 60 | expect(pausedOverlay).toHaveCSS("transition-delay", "0s, 0.5s"), 61 | ]); 62 | 63 | await hoverOut(hoverVideoPlayer, isMobile); 64 | 65 | await Promise.all([ 66 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 67 | expect(video).toHaveJSProperty("paused", false), 68 | ]) 69 | 70 | await Promise.all([ 71 | expect(pausedOverlay).toHaveCSS("opacity", "1"), 72 | expect(video).toHaveJSProperty("paused", true), 73 | ]); 74 | }); 75 | 76 | test("contents in the loading-overlay slot work as expected", async ({ page, isMobile }) => { 77 | await page.route("**/*.mp4", (route) => new Promise((resolve) => { 78 | // Add a 0.5 second delay before resolving the request for the video asset so we can 79 | // test that the loading overlay fades in while waiting for the video to load. 80 | setTimeout(() => resolve(route.continue()), 500); 81 | })); 82 | await page.goto("/tests/overlays.html"); 83 | 84 | const hoverVideoPlayer = await page.locator("hover-video-player"); 85 | const video = await hoverVideoPlayer.locator("video"); 86 | const loadingOverlay = await hoverVideoPlayer.locator("[slot='loading-overlay']"); 87 | 88 | await Promise.all([ 89 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 90 | // The loading timeout delay should not be applied to the opacity transition until it's fading in 91 | // The visibility transition should have a delay equal to the overlay transition duration so that 92 | // it doesn't become hidden until the overlay is full faded oug 93 | expect(loadingOverlay).toHaveCSS("transition-property", "opacity, visibility"), 94 | expect(loadingOverlay).toHaveCSS("transition-duration", "0.1s, 0s"), 95 | expect(loadingOverlay).toHaveCSS("transition-delay", "0s, 0.1s"), 96 | expect(loadingOverlay).toHaveCSS("opacity", "0"), 97 | expect(video).toHaveJSProperty("paused", true), 98 | ]); 99 | 100 | await hoverOver(hoverVideoPlayer, isMobile); 101 | 102 | await Promise.all([ 103 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "loading"), 104 | // The loading timeout delay should now be applied to the opacity transition since it's fading in 105 | // The visibility transition should have a delay equal to the loading timeout duration so that 106 | // it doesn't become visibile until the overlay is ready to fade in 107 | expect(loadingOverlay).toHaveCSS("transition-property", "opacity, visibility"), 108 | expect(loadingOverlay).toHaveCSS("transition-duration", "0.1s, 0s"), 109 | expect(loadingOverlay).toHaveCSS("transition-delay", "0.2s, 0.2s"), 110 | expect(loadingOverlay).toHaveCSS("opacity", "1"), 111 | expect(video).toHaveJSProperty("paused", false), 112 | ]); 113 | 114 | await Promise.all([ 115 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 116 | expect(loadingOverlay).toHaveCSS("opacity", "0"), 117 | expect(loadingOverlay).toHaveCSS("transition-property", "opacity, visibility"), 118 | expect(loadingOverlay).toHaveCSS("transition-duration", "0.1s, 0s"), 119 | expect(loadingOverlay).toHaveCSS("transition-delay", "0s, 0.1s"), 120 | ]); 121 | 122 | await hoverOut(hoverVideoPlayer, isMobile); 123 | 124 | await Promise.all([ 125 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 126 | expect(loadingOverlay).toHaveCSS("opacity", "0"), 127 | ]); 128 | }); -------------------------------------------------------------------------------- /tests/controlled.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | import type HoverVideoPlayer from '../src/hover-video-player'; 3 | import { hoverOver, hoverOut } from './utils/hoverEvents'; 4 | 5 | test("hover and blur methods control playback as expected", async ({ page, isMobile }) => { 6 | await page.goto("/tests/controlled.html"); 7 | 8 | const hoverVideoPlayer = await page.locator("hover-video-player"); 9 | const video = await hoverVideoPlayer.locator("video"); 10 | 11 | await expect(hoverVideoPlayer).toHaveJSProperty("controlled", true); 12 | 13 | // Hover interactions are ignored 14 | await hoverOver(hoverVideoPlayer, isMobile); 15 | await Promise.all([ 16 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 17 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 18 | expect(video).toHaveJSProperty("paused", true), 19 | ]); 20 | 21 | // Call the controlled hover() method to start playback 22 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => el.hover()); 23 | 24 | // The component's attributes should be updated to show that the user is hovering and the video is loading 25 | await Promise.all([ 26 | expect(hoverVideoPlayer).toHaveAttribute("data-is-hovering", ""), 27 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 28 | ]); 29 | 30 | // Blur events should also be ignored 31 | await hoverOut(hoverVideoPlayer, isMobile); 32 | 33 | await Promise.all([ 34 | expect(hoverVideoPlayer).toHaveAttribute("data-is-hovering", ""), 35 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 36 | ]); 37 | 38 | // Call the controlled blur() method to stop playback 39 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => el.blur()); 40 | 41 | // The component's state should be updated to show that the user is no longer hovering and the video is paused again 42 | await Promise.all([ 43 | expect(video).toHaveJSProperty("paused", true), 44 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 45 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 46 | ]); 47 | }); 48 | 49 | test("hover events don't update playback state if the component's controlled state changes", async ({ page, isMobile }) => { 50 | await page.goto("/tests/controlled.html"); 51 | 52 | const hoverVideoPlayer = await page.locator("hover-video-player"); 53 | 54 | hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { 55 | (window as any).hoverStartCount = 0; 56 | (window as any).hoverEndCount = 0; 57 | 58 | el.addEventListener("hoverstart", (evt) => { 59 | (window as any).hoverStartCount++; 60 | }); 61 | el.addEventListener("hoverend", (evt) => { 62 | (window as any).hoverEndCount++; 63 | }); 64 | }); 65 | 66 | // Hover interactions are ignored when controlled 67 | await expect(hoverVideoPlayer).toHaveJSProperty("controlled", true); 68 | await hoverOver(hoverVideoPlayer, isMobile); 69 | await Promise.all([ 70 | // The hover/playback state should not be updated, but the hoverstart event did run 71 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 72 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 73 | expect(await page.evaluate(() => (window as any).hoverStartCount)).toBe(1), 74 | expect(await page.evaluate(() => (window as any).hoverEndCount)).toBe(0), 75 | ]); 76 | 77 | // Move mouse away so we can try and hover again 78 | await hoverOut(hoverVideoPlayer, isMobile); 79 | 80 | await Promise.all([ 81 | expect(await page.evaluate(() => (window as any).hoverStartCount)).toBe(1), 82 | expect(await page.evaluate(() => (window as any).hoverEndCount)).toBe(1), 83 | ]); 84 | 85 | // Disabling the controlled state should allow hover interactions to control playback again 86 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.controlled = false }); 87 | await hoverOver(hoverVideoPlayer, isMobile); 88 | await Promise.all([ 89 | expect(hoverVideoPlayer).toHaveAttribute("data-is-hovering", ""), 90 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 91 | expect(await page.evaluate(() => (window as any).hoverStartCount)).toBe(2), 92 | expect(await page.evaluate(() => (window as any).hoverEndCount)).toBe(1), 93 | ]); 94 | 95 | // Mouse out is ignored when controlled again 96 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.controlled = true }); 97 | await hoverOut(hoverVideoPlayer, isMobile); 98 | await Promise.all([ 99 | expect(hoverVideoPlayer).toHaveAttribute("data-is-hovering", ""), 100 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 101 | expect(await page.evaluate(() => (window as any).hoverStartCount)).toBe(2), 102 | expect(await page.evaluate(() => (window as any).hoverEndCount)).toBe(2), 103 | ]); 104 | 105 | // Hover again so we can disable controlled state and hover back out 106 | await hoverOver(hoverVideoPlayer, isMobile); 107 | 108 | await Promise.all([ 109 | expect(await page.evaluate(() => (window as any).hoverStartCount)).toBe(3), 110 | expect(await page.evaluate(() => (window as any).hoverEndCount)).toBe(2), 111 | ]); 112 | 113 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.controlled = false }); 114 | await hoverOut(hoverVideoPlayer, isMobile); 115 | await Promise.all([ 116 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 117 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 118 | expect(await page.evaluate(() => (window as any).hoverStartCount)).toBe(3), 119 | expect(await page.evaluate(() => (window as any).hoverEndCount)).toBe(3), 120 | ]); 121 | }); 122 | 123 | test("controlled attribute is disabled with 'false' value", async ({ page, isMobile }) => { 124 | await page.goto("/tests/controlled.html"); 125 | 126 | const hoverVideoPlayer = await page.locator("hover-video-player"); 127 | 128 | await expect(hoverVideoPlayer).toHaveAttribute("controlled", ""); 129 | await expect(hoverVideoPlayer).toHaveJSProperty("controlled", true); 130 | 131 | await hoverVideoPlayer.evaluateHandle((el: HoverVideoPlayer) => { el.setAttribute("controlled", "false") }); 132 | 133 | // The controlled attribute is set, but its value is "false" so it's disabled 134 | await expect(hoverVideoPlayer).toHaveJSProperty("controlled", false); 135 | 136 | await hoverOver(hoverVideoPlayer, isMobile); 137 | 138 | // The component's attributes should be updated to show that the user is hovering and the video is loading 139 | await Promise.all([ 140 | expect(hoverVideoPlayer).toHaveAttribute("data-is-hovering", ""), 141 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "playing"), 142 | ]); 143 | 144 | await hoverOut(hoverVideoPlayer, isMobile); 145 | 146 | await Promise.all([ 147 | expect(hoverVideoPlayer).not.toHaveAttribute("data-is-hovering", ""), 148 | expect(hoverVideoPlayer).toHaveAttribute("data-playback-state", "paused"), 149 | ]); 150 | }); 151 | -------------------------------------------------------------------------------- /tests/hoverTarget.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, Page, Locator } from '@playwright/test'; 2 | import { hoverOver, hoverOut } from './utils/hoverEvents'; 3 | 4 | const expectComponentHasHoverTarget = async (page: Page, isMobile: boolean, componentLocator: Locator, expectedElementLocator: Locator) => { 5 | // The component's `hoverTarget` property should match the expected element 6 | expect(await page.evaluate(({ 7 | component, expectedHoverTarget, 8 | }) => { 9 | const hoverTarget = (component as any).hoverTarget; 10 | if (Symbol.iterator in hoverTarget) { 11 | return Array.from(hoverTarget).includes(expectedHoverTarget); 12 | } else { 13 | return hoverTarget === expectedHoverTarget; 14 | } 15 | }, { component: await componentLocator.elementHandle(), expectedHoverTarget: await expectedElementLocator.elementHandle() })).toBe(true); 16 | const video = await componentLocator.locator("video"); 17 | // The video should be paused 18 | await expect(video).toHaveJSProperty("paused", true); 19 | // Hovering over the hover target should start playing the video 20 | await hoverOver(expectedElementLocator, isMobile); 21 | await expect(video).toHaveJSProperty("paused", false); 22 | // Mousing out of the hover target should pause the video 23 | await hoverOut(expectedElementLocator, isMobile); 24 | await expect(video).toHaveJSProperty("paused", true); 25 | } 26 | 27 | test("hoverTarget can be updated via attribute for a component with an initial hover target set", async ({ page, isMobile }) => { 28 | await page.goto("/tests/hoverTarget.html"); 29 | 30 | const [componentWithInitialHoverTarget, hoverTarget1, hoverTarget2] = await Promise.all([ 31 | page.locator("[data-testid='has-initial-hover-target']"), 32 | page.locator("#hover-target-1"), 33 | page.locator("#hover-target-2"), 34 | ]); 35 | 36 | // The component with an initial hover target should have hoverTarget1 as its hover target 37 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, hoverTarget1); 38 | 39 | // Update the hover-target attribute to point to hoverTarget2 40 | await componentWithInitialHoverTarget.evaluate((componentElement) => { componentElement.setAttribute("hover-target", "#hover-target-2") }); 41 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, hoverTarget2); 42 | 43 | // Update the component hover target to hoverTarget1 by setting the hoverTarget JS property 44 | await componentWithInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = document.getElementById("hover-target-1"); }); 45 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, hoverTarget1); 46 | // The hover-target attribute should be cleared 47 | await expect(await componentWithInitialHoverTarget.evaluate((componentElement) => componentElement.getAttribute("hover-target"))).toBe(null); 48 | 49 | // Update the hover-target attribute to continue pointing to hoverTarget1 50 | await componentWithInitialHoverTarget.evaluate((componentElement) => { componentElement.setAttribute("hover-target", "#hover-target-1") }); 51 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, hoverTarget1); 52 | 53 | // Remove the hover-target attribute to revert to using the component host element as the hover target 54 | await componentWithInitialHoverTarget.evaluate((componentElement) => { componentElement.removeAttribute("hover-target") }); 55 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, componentWithInitialHoverTarget); 56 | 57 | // Update the component hover target to hoverTarget2 by setting the hoverTarget JS property 58 | await componentWithInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = document.getElementById("hover-target-2"); }); 59 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, hoverTarget2); 60 | 61 | // Set the hoverTarget JS property to null to revert to using the component host element as the hover target 62 | await componentWithInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = null; }); 63 | await expectComponentHasHoverTarget(page, isMobile, componentWithInitialHoverTarget, componentWithInitialHoverTarget); 64 | }); 65 | 66 | test("hoverTarget can be updated for a component without an initial hover target set", async ({ page, isMobile }) => { 67 | await page.goto("/tests/hoverTarget.html"); 68 | 69 | const [componentWithNoInitialHoverTarget, hoverTarget1, hoverTarget2] = await Promise.all([ 70 | page.locator("[data-testid='no-initial-hover-target']"), 71 | page.locator("#hover-target-1"), 72 | page.locator("#hover-target-2"), 73 | ]); 74 | 75 | // The component should have its own host element as the initial hover target 76 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, componentWithNoInitialHoverTarget); 77 | 78 | // Update the hover-target attribute to point to hoverTarget2 79 | await componentWithNoInitialHoverTarget.evaluate((componentElement) => { componentElement.setAttribute("hover-target", "#hover-target-2") }); 80 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, hoverTarget2); 81 | 82 | // Update the component hover target to hoverTarget1 by setting the hoverTarget JS property 83 | await componentWithNoInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = document.getElementById("hover-target-1"); }); 84 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, hoverTarget1); 85 | // The hover-target attribute should be cleared 86 | await expect(await componentWithNoInitialHoverTarget.evaluate((componentElement) => componentElement.getAttribute("hover-target"))).toBe(null); 87 | 88 | // Update the hover-target attribute to point to hoverTarget1 89 | await componentWithNoInitialHoverTarget.evaluate((componentElement) => { componentElement.setAttribute("hover-target", "#hover-target-1") }); 90 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, hoverTarget1); 91 | 92 | // Remove the hover-target attribute to revert to using the component host element as the hover target 93 | await componentWithNoInitialHoverTarget.evaluate((componentElement) => { componentElement.removeAttribute("hover-target") }); 94 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, componentWithNoInitialHoverTarget); 95 | 96 | // Update the component hover target to hoverTarget1 by setting the hoverTarget JS property 97 | await componentWithNoInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = document.getElementById("hover-target-1"); }); 98 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, hoverTarget1); 99 | 100 | // Update the component hover target to both hoverTarget1 and hoverTarget2 by setting the hoverTarget JS property 101 | await componentWithNoInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = document.querySelectorAll(".hover-target"); }); 102 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, hoverTarget1); 103 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, hoverTarget2); 104 | 105 | // Set the hoverTarget JS property to null to revert to using the component host element as the hover target 106 | await componentWithNoInitialHoverTarget.evaluate((componentElement: any) => { componentElement.hoverTarget = null; }); 107 | await expectComponentHasHoverTarget(page, isMobile, componentWithNoInitialHoverTarget, componentWithNoInitialHoverTarget); 108 | }); 109 | 110 | test("Multiple hoverTargets can be set at the same time for a single component", async ({ page, isMobile }) => { 111 | await page.goto("/tests/hoverTarget.html"); 112 | 113 | const [componentWithMultipleHoverTargets, hoverTarget1, hoverTarget2] = await Promise.all([ 114 | page.locator("[data-testid='multiple-hover-targets']"), 115 | page.locator("#hover-target-1"), 116 | page.locator("#hover-target-2"), 117 | ]); 118 | 119 | // The component should have two hover targets at the same time 120 | expect(await componentWithMultipleHoverTargets.evaluate((componentElement: any) => componentElement.hoverTarget.length)).toBe(2); 121 | await expectComponentHasHoverTarget(page, isMobile, componentWithMultipleHoverTargets, hoverTarget1); 122 | await expectComponentHasHoverTarget(page, isMobile, componentWithMultipleHoverTargets, hoverTarget2); 123 | 124 | // Reset the component hover target to the host element 125 | await componentWithMultipleHoverTargets.evaluate((componentElement: any) => { componentElement.hoverTarget = null }); 126 | await expectComponentHasHoverTarget(page, isMobile, componentWithMultipleHoverTargets, componentWithMultipleHoverTargets); 127 | 128 | // Programmatically set the hover target to both hoverTarget1 and hoverTarget2 (they both have a .hover-target class) 129 | await componentWithMultipleHoverTargets.evaluate((componentElement: any) => { componentElement.hoverTarget = document.getElementsByClassName("hover-target"); }); 130 | await expectComponentHasHoverTarget(page, isMobile, componentWithMultipleHoverTargets, hoverTarget1); 131 | await expectComponentHasHoverTarget(page, isMobile, componentWithMultipleHoverTargets, hoverTarget2); 132 | }); -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | "declaration": true, 6 | "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, 7 | 8 | /* Projects */ 9 | // "incremental": true, /* Enable incremental compilation */ 10 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 11 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 12 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 13 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 14 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 15 | 16 | /* Language and Environment */ 17 | "target": "es2017", 18 | "lib": ["es2017", "dom", "dom.iterable"], 19 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 20 | "experimentalDecorators": true /* Enable experimental support for TC39 stage 2 draft decorators. */, 21 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 22 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 23 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 24 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 25 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 26 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 27 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 28 | 29 | /* Modules */ 30 | "module": "es2015", 31 | 32 | // "rootDir": "./", /* Specify the root folder within your source files. */ 33 | "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, 34 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 35 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 36 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 37 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 38 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 39 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 40 | // "resolveJsonModule": true, /* Enable importing .json files */ 41 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 42 | 43 | /* JavaScript Support */ 44 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 45 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 46 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 47 | 48 | /* Emit */ 49 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 50 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 51 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 52 | "removeComments": true /* Disable emitting comments. */, 53 | // "noEmit": true, /* Disable emitting files from a compilation. */ 54 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 55 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 56 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 57 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 58 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 59 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 60 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 61 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 62 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 63 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 64 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 65 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 66 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 67 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 68 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 69 | 70 | /* Interop Constraints */ 71 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 72 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 73 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 74 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 75 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 76 | 77 | /* Type Checking */ 78 | "strict": true /* Enable all strict type-checking options. */, 79 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 80 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 81 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 82 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 83 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 84 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 85 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 86 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 87 | "noUnusedLocals": true /* Enable error reporting when a local variables aren't read. */, 88 | "noUnusedParameters": true /* Raise an error when a function parameter isn't read */, 89 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 90 | "noImplicitReturns": true /* Enable error reporting for codepaths that do not explicitly return in a function. */, 91 | "noFallthroughCasesInSwitch": true /* Enable error reporting for fallthrough cases in switch statements. */ 92 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 93 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 94 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 95 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 96 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 97 | 98 | /* Completeness */ 99 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 100 | // "skipLibCheck": true /* Skip type checking all .d.ts files. */ 101 | }, 102 | "include": ["src/**/*.ts"], 103 | "exclude": ["node_modules", "dist"] 104 | } 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hover-video-player 2 | 3 | A web component that helps make it easy to set up videos which play when the user hovers over them. 4 | 5 | This is particularly useful for the common user experience pattern where a page may have a thumbnail which plays a video preview when the user hovers over it. 6 | 7 | This is a port of the [react-hover-video-player library](https://github.com/Gyanreyer/react-hover-video-player) which should be broadly compatible with Svelte, Vue, vanilla HTML, or any other library/framework which supports web components! 8 | 9 | **[Play with a real working example on CodeSandbox.](https://codesandbox.io/s/hover-video-player-example-pcw27m?file=/index.html)** 10 | 11 | ## Features 12 | 13 | - Support for mouse, touchscreen, and keyboard focus interactions 14 | - Built-in support for thumbnails and loading states 15 | - Adds handling for weird edge cases that can arise when managing video playback, such as gracefully falling back to playing the video without sound if the browser's autoplay policy blocks un-muted playback 16 | - Supports HTMLMediaElement API-compliant custom elements, allowing for use of other media sources like YouTube, Vimeo, and HLS 17 | 18 | ## Installation 19 | 20 | ### package managers 21 | 22 | - `npm install hover-video-player` 23 | - `yarn add hover-video-player` 24 | 25 | ### cdn 26 | 27 | - esm build (recommended): ` 55 | 56 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | ``` 67 | 68 | 69 | 70 |
71 | WebC 72 | 73 | ```js 74 | // .eleventy.js 75 | eleventyConfig.addPlugin(pluginWebc, { 76 | components: [ 77 | "npm:hover-video-player/**/*.webc", 78 | ], 79 | }); 80 | ``` 81 | 82 | ```html 83 | 84 | 85 | 92 | ``` 93 | 94 |
95 | 96 |
97 | Svelte 98 | 99 | ```html 100 | 101 | 104 | 105 | 106 | 113 | 114 | 125 | 126 | ``` 127 | 128 |
129 | 130 |
131 | Vue 132 | 133 | See Vue's _["Using custom elements in Vue"](https://vuejs.org/guide/extras/web-components.html#using-custom-elements-in-vue)_ documentation for details on how to set up your vue/vite config to support using custom elements. 134 | 135 | ```html 136 | 137 | 140 | 141 | 151 | 152 | 163 | ``` 164 | 165 |
166 | 167 | ### Slots 168 | 169 | Custom elements accept slots which can then be displayed as children of the component. `hover-video-player` has 4 slots: 170 | 171 | - **Default slot** (REQUIRED): The default unnamed slot requires a [video element](https://developer.mozilla.org/en-US/docs/Web/API/HTMLVideoElement) which the component will control. This provides a lot of flexibility so that you can configure the video however you see fit. 172 | 173 | Recommended video attributes: 174 | - `loop`: Makes the video loop back to the beginning and keep playing if it reaches the end 175 | - `muted`: Makes sure the video will play without audio. Browsers may block playback with audio, so this can help prevent that from happening from the start 176 | - `playsinline`: Makes sure that the video will be played where it is displayed on the page rather than being opened in fullscreen on iOS Safari 177 | - `preload`: Makes sure that the browser doesn't attempt to aggressively pre-load the video until the user actually starts playing it. You should usually use `preload="metadata"` as this will still load basic metadata such as the video's dimensions, which can be helpful for displaying the player with the right aspect ratio 178 | 179 | ```html 180 | 181 | 182 | 189 | 190 | ``` 191 | 192 | - **"paused-overlay"**: The "paused-overlay" slot is an optional named slot. It accepts contents which you want to display over the video while it is in a paused or loading state; when the video starts playing, this content will be faded out. 193 | 194 | A common use case for this would be displaying a thumbnail image over the video while it is paused. 195 | 196 | ```html 197 | 198 | 201 | ``` 202 | 203 | - **"loading-overlay"**: The "loading-overlay" slot is an optional named slot. It accepts contents which you want to display over the video if it in a loading state, meaning the user is attempting to play the video and it has taken too long to start. 204 | 205 | This is useful if you want to show a loading state while the user is waiting for the video to play. 206 | 207 | Note that the "paused-overlay" slot will still be displayed while the video is in a loading state; this overlay will simply be displayed on top of that one. 208 | 209 | The exact loading state timeout duration can be set on a `--loading-timeout-duration` CSS variable. See [Loading State Timeouts](#loading-state-timeouts) for details. 210 | 211 | ```html 212 | 219 | 220 | 221 |