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 |
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): ``
28 | - iife build: ``
29 |
30 | ## Usage
31 |
32 | All you need to do is import this library into your site/app and it will register a `hover-video-player` custom element which you can now use.
33 |
34 | ### Examples
35 |
36 |
37 | Vanilla HTML
38 |
39 | ```html
40 |
41 |
42 |
43 |
54 |
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 |
86 |
91 |
92 | ```
93 |
94 |
95 |
96 |
97 | Svelte
98 |
99 | ```html
100 |
101 |
104 |
105 |
106 |
107 |
112 |
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 |
142 |
143 |
144 |
149 |
150 |
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 |
199 |
200 |
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 |
222 |
223 |
224 |
225 |
226 | ```
227 |
228 | - **"hover-overlay"**: The "hover-overlay" slot is an optional named slot. It accepts contents which you wnat to display over the video while the user is hovering on the player's hover target.
229 |
230 | This is useful if you want to reveal content to the user when the user is hovering on the player's hover target while still allowing the video to play underneath.
231 |
232 | Note that this overlay takes highest ordering priority and will be displayed on top of both the "paused-overlay" and "loading-overlay" slots if they are set.
233 |
234 | ```html
235 |
236 |
237 |
The user is hovering!
238 |
239 | ```
240 |
241 | #### Overlay customization
242 |
243 | ##### Overlay transition durations
244 |
245 | The time it takes for the component's overlays to fade in/out is dictated by the `--overlay-transition-duration` CSS variable. By default, its value is `0.4s`.
246 |
247 | If you wish, you may customize the transition duration by setting your own value for this CSS variable.
248 |
249 | You may set it on the root `hover-video-player` element's level to set the transition duration for all overlays, or you can target a specific overlay slot if you wish to have different transition durations for different overlays.
250 |
251 | ```html
252 |
263 |
264 |
265 |
266 |
267 |
Loading...
268 |
269 | ```
270 |
271 | ###### Loading state timeouts
272 |
273 | The time that the component will wait before fading in the loading overlay if the video is taking a while to start is dictated by the `--loading-timeout-duration` CSS variable. By default, its value is `0.2s`.
274 |
275 | If you wish, you may customize this timeout duration by setting your own value for this CSS variable either on the root `hover-video-player` element's level or directly on the loading overlay slot element.
276 |
277 | ```html
278 |
284 |
285 |
286 |
287 |
Loading...
288 |
289 | ```
290 |
291 | ### Element API
292 |
293 | #### hover-target
294 |
295 | The optional `"hover-target"` attribute can be used to provide a selector string for element(s) which the component should watch for hover interactions. If a hover target is not set, the component will use its root element as the hover target.
296 |
297 | Note that if you provide a selector which matches multiple elements in the document, they will all be added as hover targets.
298 |
299 | The component's hover target can also be accessed and updated in JS with the `hoverTarget` property.
300 | This property may be a single `Element` instance, or an iterable of `Element` instances; a manually constructed array, a `NodeList` returned by `querySelectorAll`, or an HTMLCollection returned by `getElementsByClassName` are all acceptable.
301 |
302 | ```html
303 |
304 |
Hover on me to start playing!
305 |
306 |
307 |
308 |
309 |
310 |
You can hover on me to play
311 |
You can also hover on me!
312 |
313 |
314 |
315 | ```
316 |
317 | Setting with JS:
318 |
319 | ```js
320 | const player = document.querySelector("hover-video-player");
321 | // Setting a single hover target element
322 | player.hoverTarget = document.getElementById("hover-on-me");
323 |
324 | // Setting multiple hover targets
325 | player.hoverTarget = document.querySelectorAll(".hover-target");
326 | ```
327 |
328 | #### restart-on-pause
329 |
330 | The optional boolean `"restart-on-pause"` attribute will cause the component to reset the video to the beginning when the user ends their hover interaction. Otherwise, the video will remain at whatever time it was at when the user stopped hovering, and start from there if they hover to play it again.
331 |
332 | This can also be accessed and updated in JS with the `restartOnPause` property.
333 |
334 | ```html
335 |
336 |
337 |
338 | ```
339 |
340 | Setting with JS:
341 |
342 | ```js
343 | const player = document.querySelector("hover-video-player");
344 | player.restartOnPause = true;
345 | ```
346 |
347 | #### sizing-mode
348 |
349 | The optional `"sizing-mode"` attribute has no effects on the component's behavior, but it provides a set of helpful style presets which can be applied to the player.
350 |
351 | Valid sizing mode options are:
352 |
353 | - `"video"` (default): Everything should be sized based on the video element's dimensions; overlays will expand to cover the video.
354 | - Note that this mode comes with a caveat: The video element may briefly display with different dimensions until it finishes loading the metadata containing the video's actual dimensions. This is usually fine when the metadata is loaded immediately, so it is recommended that you avoid using this mode in combination with the [unload-on-pause](#unload-on-pause) setting described below, as it will cause the video's metadata to be unloaded frequently.
355 | - `"overlay"`: Everything should be sized relative to the paused overlay slot's dimensions and the video will expand to fill that space.
356 | - `"container"`: The video and all overlays should be sized to cover the dimensions of the outermost `` host element.
357 | - `"manual"`: All preset sizing mode styles are disabled, leaving it up to you.
358 |
359 | ```html
360 |
361 |
362 |
363 |
364 |
365 |
366 |
367 |
368 |
369 |
370 |
371 | ```
372 |
373 | #### playback-start-delay
374 |
375 | The optional `"playback-start-delay"` attribute can be used to apply a delay between when the user starts hovering and when the video starts playing.
376 |
377 | This can be useful as an optimization if you have a page with a large number of `hover-video-player` instances and want to avoid making unnecessary requests to load video assets which may occur as the user browses arund the page and passes their mouse over videos that they don't intend to watch.
378 |
379 | This attribute accepts times in the format of seconds like "0.5s", or milliseconds like "100ms" or simply "100".
380 |
381 | This can also be accessed and updated in JS with the `playbackStartDelay` property.
382 |
383 | ```html
384 |
385 |
386 |
387 | ```
388 |
389 | Setting with JS:
390 |
391 | ```js
392 | const player = document.querySelector("hover-video-player");
393 | player.playbackStartDelay = 500;
394 | ```
395 |
396 | #### unload-on-pause
397 |
398 | `hover-video-player` accepts an optional boolean `"unload-on-pause"` attribute which, if present, will cause the component to fully unload the video's sources when the video is not playing in an effort to reduce the amount of memory and network usage on the page.
399 |
400 | This setting is necessary because when you pause a video after playing it for the first time, it remains loaded in memory and browsers will often continue loading more of the video in case the user goes back to play more of it. This is fine on a small scale, but in cases where you have a page with a very large number of `hover-video-player` instances, it may become necessary in order to prevent all of the videos on the page from eating up a ton of network bandwidth and memory all at once, causing significant performance degradation for the user.
401 |
402 | Although this setting can provide some performance benefits, it also has notable drawbacks:
403 |
404 | The video's metadata will be (at least temporarily) fully unloaded when the video is paused, which could cause content jumps to occur when the video starts/stops playing.
405 |
406 | As a result, it is recommended that you set the [`"sizing-mode"`](#sizing-mode) attribute to "overlay" or "container", or provide your own custom styles to set a fixed dimensions for the component.
407 |
408 | Additionally, the video may not show a thumbnail/first frame, or if it does, it may flash in ways that are undesired. As a result, it is recommended to provide overlay contents for the "paused-overlay" slot which will hide the video element while it is paused and unloaded.
409 |
410 | This setting must be paired with setting either `preload="metadata"` or `preload="none"` on the video element to make sure that the browser does not try to preload every video asset while it isn't playing. If a `preload` attribute is not set on the video, the component will set `preload="metadata"` on it automatically.
411 |
412 | This can also be accessed and updated in JS with the `unloadOnPause` property.
413 |
414 | ```html
415 |
416 |
417 |
418 |
419 | ```
420 |
421 | Setting with JS:
422 |
423 | ```js
424 | const player = document.querySelector("hover-video-player");
425 | player.unloadOnPause = true;
426 | ```
427 |
428 | #### controlled
429 |
430 | The optional boolean `"controlled"` attribute will disable standard event handling on the hover target so playback can be fully manually managed programmatically for more complex custom behavior.
431 |
432 | You can programmatically start and stop playback on a controlled component by using the `.hover()` and `.blur()` methods, or manipulating the [`"data-playback-state"`](#data-playback-state) attribute.
433 |
434 | This option is essentially a shorthand for calling `event.preventDefault()` on both [`"hoverstart"`](#hoverstart) and [`"hoverend"`](#hoverend) events.
435 |
436 | This can also be accessed and updated in JS with the `controlled` property.
437 |
438 | ```html
439 |
440 |
441 |
442 | ```
443 |
444 | Setting with JS:
445 |
446 | ```js
447 | const player = document.querySelector("hover-video-player");
448 | player.controlled = true;
449 | ```
450 |
451 | Programmatically starting/stopping playback:
452 |
453 | ```js
454 | const player = document.querySelector("hover-video-player");
455 | // Start playback
456 | player.hover();
457 | // Stop playback
458 | player.blur();
459 |
460 | // Start playback
461 | player.dataset.playbackState = "playing";
462 | ```
463 |
464 | ### Data attributes
465 |
466 | The component sets some data attributes on its element which expose some of the component's internal state for custom styling.
467 |
468 | It is recommended that you do not tamper with these attributes by manually modifying them yourself, as the component
469 | will likely overwrite your changes and these attributes are used internally for the component's styling.
470 |
471 | #### data-is-hovering
472 |
473 | `data-is-hovering` is a boolean data attribute which is present when the player is hovered, meaning it is actively playing or trying to play.
474 |
475 | It will look like this in the DOM:
476 |
477 | ```html
478 |
479 |
480 |
481 |
482 |
483 |
484 |
485 |
486 |
487 | ```
488 |
489 | ```css
490 | hover-video-player[data-is-hovering] {
491 | /* When the player is being hovered, add custom styles to shift it up and add a box shadow */
492 | transform: translateY(-5%);
493 | box-shadow: 0px 0px 10px black;
494 | }
495 | ```
496 |
497 | #### `data-playback-state`
498 |
499 | `data-playback-state` is an enum data attribute which reflects the video's internal playback state. The attribute can have one of the following values:
500 |
501 | - `"paused"`: The video is paused and not attempting to play
502 | - `"loading"`: The video is attempting to play, but still loading
503 | - `"playing"`: The video is playing
504 |
505 | If you manipulate the value of the attribute, the component will attempt to transition to that playback state.
506 |
507 | It will look like this in the DOM:
508 |
509 | ```html
510 |
511 |
512 |
513 |
514 |
515 |
516 |
517 |
518 |
519 |
520 |
521 |
522 |
523 |
524 | ```
525 |
526 | ```css
527 | hover-video-player[data-playback-state="paused"] {
528 | /* Red background when the player is paused */
529 | background: red;
530 | }
531 |
532 | hover-video-player[data-playback-state="loading"] {
533 | /* Yellow background when the player is loading */
534 | background: yellow;
535 | }
536 |
537 | hover-video-player[data-playback-state="playing"] {
538 | /* Green background when the player is playing */
539 | background: green;
540 | }
541 | ```
542 |
543 | The attribute can be manipulated to trigger playback state updates. This could be combined with the `controlled` attribute
544 | as another way to manually control playback state via JavaScript.
545 |
546 | ```js
547 | const player = document.querySelector("hover-video-player");
548 | console.log(player.dataset.playbackState); // "paused"
549 | // The player will attempt to play; it will revert back to a "loading" state until playback succeeds
550 | player.dataset.playbackState = "playing";
551 | console.log(player.dataset.playbackState); // "loading"
552 | // Once the video starts playing...
553 | console.log(player.dataset.playbackState); // "playing"
554 |
555 | // Removing the attribute or setting it to an invalid value will reset the player to "paused" state
556 | player.removeAttribute("data-playback-state");
557 | console.log(player.dataset.playbackState); // "paused"
558 | ```
559 |
560 | ### Events
561 |
562 | #### `hoverstart`
563 |
564 | The player component will emit a `"hoverstart"` event when a user hovers over the player's hover target to start playback. `"hoverstart"` events are cancelable `CustomEvents`.
565 |
566 | Each event will include the `Event` object from the originating hover target event on `event.detail`.
567 |
568 | If you wish to intercept a `"hoverstart"` event and prevent the component from proceeding to start playback,
569 | you can call `event.preventDefault()` in a `hoverend` listener. If the component is [controlled](#controlled),
570 | both `"hoverstart"` and `"hoverend"` events will automatically be canceled without requiring any further action from you.
571 |
572 | ```js
573 | const player = document.querySelector("hover-video-player");
574 | player.addEventListener("hoverstart", (evt) => {
575 | console.log("The user hovered!");
576 | if (shouldPreventPlay) {
577 | // Call preventDefault to prevent the component from proceeding to start playback in response to this `hoverstart` event
578 | evt.preventDefault();
579 | }
580 |
581 | console.log("The hoverstart interaction originated on this element:", evt.detail.currentTarget);
582 | });
583 | ```
584 |
585 | #### `hoverend`
586 |
587 | The player component will emit a `"hoverend"` event when a user stops hovering over the player's hover target to stop playback. `"hoverend"` events are cancelable `CustomEvents`.
588 |
589 | Each event will include the `Event` object from the originating hover target event on `event.detail`.
590 |
591 | If you wish to intercept a `"hoverend"` event and prevent the component from proceeding to pause playback,
592 | you can call `event.preventDefault()` in a `hoverend` listener. If the component is [controlled](#controlled),
593 | both `"hoverstart"` and `"hoverend"` events will automatically be canceled without requiring any further action from you.
594 |
595 | ```js
596 | const player = document.querySelector("hover-video-player");
597 | player.addEventListener("hoverend", (evt) => {
598 | console.log("The user is no longer hovering!");
599 |
600 | if (shouldPreventPause) {
601 | // Call preventDefault to prevent the component from proceeding to pause playback in response to this `hoverend` event
602 | evt.preventDefault();
603 | }
604 |
605 | console.log("The hoverend interaction originated on this element:", evt.detail.currentTarget);
606 | });
607 | ```
608 |
609 | #### `playbackstatechange`
610 |
611 | The player component will emit a custom `"playbackstatechange"` event when the player's playback state changes.
612 | This is a `CustomEvent` where the `detail` will be the new playback state, matching the [`data-playback-state`](#data-playback-state) attribute.
613 |
614 | ```js
615 | const player = document.querySelector("hover-video-player");
616 | player.addEventListener("playbackstatechange", (evt) => {
617 | console.log("The new playback state is...", evt.detail);
618 | });
619 | ```
620 |
621 |
622 | ### Alternative media sources
623 |
624 | The native `