diff --git a/knip.config.js b/knip.config.js index b4b13eb440..aba86ff867 100644 --- a/knip.config.js +++ b/knip.config.js @@ -160,6 +160,7 @@ export default { entry: [ ...PACKAGE_FILES, ...SOURCE_INDEX, + ...EXAMPLE_ENTRIES, ...TEST_ENTRIES, ...TEST_VARIANT_ENTRIES, ...VITE_CONFIGS, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 425a7d415a..f70003c2ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1244,6 +1244,9 @@ importers: '@nvidia-elements/core': specifier: workspace:^ version: link:../core + '@nvidia-elements/forms': + specifier: workspace:^ + version: link:../forms '@nvidia-elements/themes': specifier: workspace:^ version: link:../themes diff --git a/projects/internals/metadata/package.json b/projects/internals/metadata/package.json index 430c32fb36..82ee71046c 100644 --- a/projects/internals/metadata/package.json +++ b/projects/internals/metadata/package.json @@ -109,6 +109,8 @@ "../../forms/dist/custom-elements.json", "../../markdown/package.json", "../../markdown/dist/custom-elements.json", + "../../media/package.json", + "../../media/dist/custom-elements.json", "../../monaco/package.json", "../../monaco/dist/custom-elements.json", "../../patterns/package.json", @@ -142,6 +144,10 @@ "script": "../../markdown:build", "cascade": false }, + { + "script": "../../media:build", + "cascade": false + }, { "script": "../../monaco:build", "cascade": false @@ -171,6 +177,9 @@ "../../markdown/package.json", "../../markdown/README.md", "../../markdown/CHANGELOG.md", + "../../media/package.json", + "../../media/README.md", + "../../media/CHANGELOG.md", "../../monaco/package.json", "../../monaco/README.md", "../../monaco/CHANGELOG.md", @@ -212,6 +221,11 @@ "../../markdown/coverage/unit/summary.json", "../../markdown/coverage/visual/summary.json", "../../markdown/coverage/ssr/summary.json", + "../../media/coverage/unit/coverage-summary.json", + "../../media/coverage/unit/summary.json", + "../../media/coverage/axe/summary.json", + "../../media/coverage/visual/summary.json", + "../../media/coverage/ssr/summary.json", "../../monaco/coverage/unit/coverage-summary.json", "../../monaco/coverage/unit/summary.json", "../../monaco/coverage/visual/summary.json", @@ -259,6 +273,22 @@ "script": "../../markdown:test:coverage", "cascade": false }, + { + "script": "../../media:test:coverage", + "cascade": false + }, + { + "script": "../../media:test:axe", + "cascade": false + }, + { + "script": "../../media:test:ssr", + "cascade": false + }, + { + "script": "../../media:test:visual", + "cascade": false + }, { "script": "../../monaco:test:coverage", "cascade": false @@ -291,7 +321,8 @@ "../../monaco/dist/**/*.examples.json", "../../code/dist/**/*.examples.json", "../../forms/dist/**/*.examples.json", - "../../markdown/dist/**/*.examples.json" + "../../markdown/dist/**/*.examples.json", + "../../media/dist/**/*.examples.json" ], "output": [ "static/examples.json" @@ -321,6 +352,10 @@ "script": "../../monaco:build", "cascade": false }, + { + "script": "../../media:build", + "cascade": false + }, { "script": "../patterns:build", "cascade": false diff --git a/projects/internals/metadata/src/services/api.service.test.ts b/projects/internals/metadata/src/services/api.service.test.ts index 4aaa30e8f5..4be8ef0d0e 100644 --- a/projects/internals/metadata/src/services/api.service.test.ts +++ b/projects/internals/metadata/src/services/api.service.test.ts @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from 'vitest'; +import MiniSearch from 'minisearch'; +import { describe, expect, it, vi } from 'vitest'; import { ApiService } from './api.service.js'; describe('ApiService', () => { @@ -41,7 +42,12 @@ describe('ApiService', () => { }); it('should prioritize exact matches over fuzzy matches', async () => { + const searchSpy = vi.spyOn(MiniSearch.prototype, 'search').mockReturnValue([ + { id: 'nve-button-group', terms: ['button'], queryTerms: ['button'], score: 2, match: {} }, + { id: 'nve-button', terms: ['button'], queryTerms: ['button'], score: 1, match: {} } + ]); const results = await ApiService.search('nve-button'); + searchSpy.mockRestore(); expect(results[0]?.name).toBe('nve-button'); }); diff --git a/projects/internals/metadata/src/tasks/api.utils.test.ts b/projects/internals/metadata/src/tasks/api.utils.test.ts index 8b35ea1a0b..cd5bbe1047 100644 --- a/projects/internals/metadata/src/tasks/api.utils.test.ts +++ b/projects/internals/metadata/src/tasks/api.utils.test.ts @@ -4,8 +4,65 @@ import { describe, expect, it } from 'vitest'; import { getApi } from './api.utils.js'; +type Api = Awaited>; +type ApiElement = Api['data']['elements'][number]; + +function getElement(api: Api, name: string) { + return api.data.elements.find(element => element.name === name); +} + +function getMember(element: ApiElement | undefined, name: string) { + return element?.manifest?.members.find(member => member.name === name); +} + +function getAttribute(element: ApiElement | undefined, name: string) { + return element?.manifest?.attributes?.find(attribute => attribute.name === name); +} + describe('ApiUtils', () => { it('should return the api json', async () => { expect(getApi).toBeDefined(); }); + + it('should include attribute names for inherited button members', async () => { + const api = await getApi(); + const button = getElement(api, 'nve-button'); + + expect(getMember(button, 'pressed')?.attribute).toBe('pressed'); + expect(getMember(button, 'readOnly')?.attribute).toBe('readonly'); + expect(getMember(button, 'commandForElement')?.attribute).toBe('commandfor'); + expect(button?.markdown).toContain('| readOnly (readonly) |'); + }); + + it('should project checkbox and button mixin APIs onto media mute button', async () => { + const api = await getApi(); + const muteButton = getElement(api, 'nve-media-mute-button'); + + expect(getMember(muteButton, 'pressed')?.attribute).toBe('pressed'); + expect(getMember(muteButton, 'checked')?.attribute).toBe('checked'); + expect(getMember(muteButton, 'readOnly')?.attribute).toBe('readonly'); + expect(getMember(muteButton, 'commandForElement')?.attribute).toBe('commandfor'); + expect(getAttribute(muteButton, 'commandForElement')).toBeUndefined(); + expect(muteButton?.markdown).toContain('| checked |'); + }); + + it('should project slider mixin APIs onto media time range', async () => { + const api = await getApi(); + const timeRange = getElement(api, 'nve-media-time-range'); + + expect(getMember(timeRange, 'min')?.attribute).toBe('min'); + expect(getMember(timeRange, 'valueAsNumber')?.attribute).toBeUndefined(); + expect(getAttribute(timeRange, 'commandForElement')).toBeUndefined(); + expect(timeRange?.markdown).toContain('| valueAsNumber |'); + }); + + it('should project select mixin APIs onto media playback rate select', async () => { + const api = await getApi(); + const playbackRateSelect = getElement(api, 'nve-media-playback-rate-select'); + + expect(getMember(playbackRateSelect, 'selectedIndex')?.attribute).toBeUndefined(); + expect(getMember(playbackRateSelect, 'value')?.attribute).toBe('value'); + expect(getAttribute(playbackRateSelect, 'commandForElement')).toBeUndefined(); + expect(playbackRateSelect?.markdown).toContain('| selectedIndex |'); + }); }); diff --git a/projects/internals/metadata/src/tasks/api.utils.ts b/projects/internals/metadata/src/tasks/api.utils.ts index efc26b4951..0a50e8e3d5 100644 --- a/projects/internals/metadata/src/tasks/api.utils.ts +++ b/projects/internals/metadata/src/tasks/api.utils.ts @@ -133,6 +133,7 @@ export async function getApi(): Promise<{ '../../../../themes', '../../../../code', '../../../../markdown', + '../../../../media', '../../../../monaco' ]; diff --git a/projects/internals/metadata/static/adoption.json b/projects/internals/metadata/static/adoption.json index 174467e5a1..6d67bd31cf 100644 --- a/projects/internals/metadata/static/adoption.json +++ b/projects/internals/metadata/static/adoption.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4ab23ecf33a7e8e2aaf1cb1687342190b4d33edb04d6c6912b29ba2b208a9c0d -size 163868 +oid sha256:dfcfc695c649e81bf293ef3d690bfa7effbc5dd752207c7f74948d2a347731cb +size 196863 diff --git a/projects/internals/metadata/static/releases.json b/projects/internals/metadata/static/releases.json index e95b4bbcac..faf72ad30c 100644 --- a/projects/internals/metadata/static/releases.json +++ b/projects/internals/metadata/static/releases.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e91bd7c9d11912873a0fbcd81f3701ed94535e34bc0de28c77870e6f5a5f883f -size 17357 +oid sha256:31de391d99d1142f68817d75a2e9c64edd01789776ce31230aafe57cded8468c +size 18366 diff --git a/projects/internals/metadata/static/tests.json b/projects/internals/metadata/static/tests.json index 010aeb4d53..96dd6c7ff1 100644 --- a/projects/internals/metadata/static/tests.json +++ b/projects/internals/metadata/static/tests.json @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ec710720a8ed82b67e5254fa8c0d73aef6fc1ca5af3961b534ddca2e656dbf0 -size 2672350 +oid sha256:7194c2fd305221b8ef8b20737fcee9a617a29d096268304461e465d1312502a3 +size 2669405 diff --git a/projects/internals/tools/src/api/service.test.ts b/projects/internals/tools/src/api/service.test.ts index 1daf8eb231..970324624e 100644 --- a/projects/internals/tools/src/api/service.test.ts +++ b/projects/internals/tools/src/api/service.test.ts @@ -67,6 +67,17 @@ describe('ApiService', () => { const result = await ApiService.get({ names: 'nve-button', format: 'markdown' }); expect(typeof result).toBe('string'); expect(result as string).toContain('nve-button'); + expect(result as string).toContain('| readOnly (readonly) |'); + }); + + it('should return projected mixin api for directly mixed components', async () => { + const result = await ApiService.get({ names: 'nve-media-mute-button', format: 'markdown' }); + expect(typeof result).toBe('string'); + expect(result as string).toContain('| pressed |'); + expect(result as string).toContain('| checked |'); + expect(result as string).toContain('| readOnly (readonly) |'); + expect(result as string).toContain('| commandForElement (commandfor) |'); + expect(result as string).not.toContain('| commandForElement (commandForElement) |'); }); it('should return json for a single string name', async () => { @@ -74,6 +85,25 @@ describe('ApiService', () => { expect(Array.isArray(result)).toBe(true); expect(result.length).toBe(1); expect(result[0].name).toBe('nve-button'); + + const button = result[0] as Element; + const readOnly = button.manifest?.members.find(member => member.name === 'readOnly'); + + expect(readOnly?.attribute).toBe('readonly'); + }); + + it('should return json with projected mixin attributes for directly mixed components', async () => { + const result = (await ApiService.get({ names: 'nve-media-mute-button', format: 'json' })) as ( + | Element + | Attribute + )[]; + const muteButton = result[0] as Element; + const checked = muteButton.manifest?.members.find(member => member.name === 'checked'); + const commandForElement = muteButton.manifest?.members.find(member => member.name === 'commandForElement'); + + expect(checked?.attribute).toBe('checked'); + expect(commandForElement?.attribute).toBe('commandfor'); + expect(muteButton.manifest?.attributes?.some(attribute => attribute.name === 'commandForElement')).toBe(false); }); it('should return markdown for an array with one name', async () => { diff --git a/projects/internals/tools/src/playground/utils.ts b/projects/internals/tools/src/playground/utils.ts index 949a126690..af367fa0e1 100644 --- a/projects/internals/tools/src/playground/utils.ts +++ b/projects/internals/tools/src/playground/utils.ts @@ -289,6 +289,8 @@ function createImportMap(framework: 'react' | 'preact' | 'angular' | 'lit' | 'vu '@nvidia-elements/code/': `${CDN_MODULES_URL}/@nvidia-elements/code@latest/`, '@nvidia-elements/forms': `${CDN_MODULES_URL}/@nvidia-elements/forms@latest`, '@nvidia-elements/forms/': `${CDN_MODULES_URL}/@nvidia-elements/forms@latest/`, + '@nvidia-elements/media': `${CDN_MODULES_URL}/@nvidia-elements/media@latest`, + '@nvidia-elements/media/': `${CDN_MODULES_URL}/@nvidia-elements/media@latest/`, ...(frameworkImportMap[framework]?.(CDN_MODULES_URL) ?? {}) }; diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts index 748bbaf60d..3c9aa2da90 100644 --- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts +++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.test.ts @@ -10,7 +10,7 @@ import noInvalidInvokerTriggers from './no-invalid-invoker-triggers.js'; const rule = noInvalidInvokerTriggers as unknown as JSRuleDefinition; const validElements = - 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button'; + 'nve-button, nve-icon-button, nve-menu-item, nve-sort-button, nve-tabs-item, nve-tag, nve-steps-item, nve-copy-button, nve-media-fullscreen-button, nve-media-mute-button, nve-media-pause-button, nve-media-playback-rate-select, nve-media-seek-button, nve-media-time-range, nve-media-volume-range'; describe('noInvalidInvokerTriggers', () => { let tester: RuleTester; @@ -50,7 +50,8 @@ describe('noInvalidInvokerTriggers', () => { 'Tab', 'Tag', 'Step', - '' + '', + '' ], invalid: [] }); @@ -61,7 +62,14 @@ describe('noInvalidInvokerTriggers', () => { valid: [ 'Toggle', '', - 'Tag' + 'Tag', + '', + '', + '', + '', + '', + '', + '' ], invalid: [] }); diff --git a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts index 880200dfa8..770dbb39ee 100644 --- a/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts +++ b/projects/lint/src/eslint/rules/no-invalid-invoker-triggers.ts @@ -24,7 +24,14 @@ const BUTTON_TYPE_ELEMENTS = [ 'nve-tabs-item', 'nve-tag', 'nve-steps-item', - 'nve-copy-button' + 'nve-copy-button', + 'nve-media-fullscreen-button', + 'nve-media-mute-button', + 'nve-media-pause-button', + 'nve-media-playback-rate-select', + 'nve-media-seek-button', + 'nve-media-time-range', + 'nve-media-volume-range' ] as const; const rule = { diff --git a/projects/media/.visual/media-controller.dark.png b/projects/media/.visual/media-controller.dark.png new file mode 100644 index 0000000000..431579c169 --- /dev/null +++ b/projects/media/.visual/media-controller.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ee1dc5253489b68543481ca02d02246e0cb780000d4038a821a60730e4aa0a +size 3451 diff --git a/projects/media/.visual/media-controller.png b/projects/media/.visual/media-controller.png new file mode 100644 index 0000000000..5daeface5c --- /dev/null +++ b/projects/media/.visual/media-controller.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fecc74c8bcce59adb663d90bc010db8a65f7fd120ca355c533dda238c880980 +size 3401 diff --git a/projects/media/.visual/media-fullscreen-button.dark.png b/projects/media/.visual/media-fullscreen-button.dark.png new file mode 100644 index 0000000000..a2043a35b7 --- /dev/null +++ b/projects/media/.visual/media-fullscreen-button.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5bc8b497dba92121a0e33c2447ea2e8b41ab42d8be56d24b1859bcd0d38dcab +size 2016 diff --git a/projects/media/.visual/media-fullscreen-button.png b/projects/media/.visual/media-fullscreen-button.png new file mode 100644 index 0000000000..c8e9af70ba --- /dev/null +++ b/projects/media/.visual/media-fullscreen-button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8f5e00fef0546b5799a6df06bc486e4f2bbdd84dccd090f3e74c1a2674607e2 +size 1960 diff --git a/projects/media/.visual/media-mute-button.dark.png b/projects/media/.visual/media-mute-button.dark.png new file mode 100644 index 0000000000..b02b0e7c0c --- /dev/null +++ b/projects/media/.visual/media-mute-button.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3864d19f3b7ac84d5720678fd1965a9da845aff544dd3fb7fcc8148aef68f0a +size 1326 diff --git a/projects/media/.visual/media-mute-button.png b/projects/media/.visual/media-mute-button.png new file mode 100644 index 0000000000..b5320f3439 --- /dev/null +++ b/projects/media/.visual/media-mute-button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:639a2f225ffbb886f2106057ac7fce4b9deccd06f563b7c73029beba29a730c8 +size 1267 diff --git a/projects/media/.visual/media-pause-button.dark.png b/projects/media/.visual/media-pause-button.dark.png new file mode 100644 index 0000000000..05ecbbd8fd --- /dev/null +++ b/projects/media/.visual/media-pause-button.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cdc7a0bd10c0e57375964d62090976c68dbff13a671270727007657dc5073d3 +size 746 diff --git a/projects/media/.visual/media-pause-button.png b/projects/media/.visual/media-pause-button.png new file mode 100644 index 0000000000..1cf5518019 --- /dev/null +++ b/projects/media/.visual/media-pause-button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:87caa06d18549b367ad47a32c9f4c2251f88452c61a00bbb39b2f6ed494db88b +size 722 diff --git a/projects/media/.visual/media-playback-rate-select.dark.png b/projects/media/.visual/media-playback-rate-select.dark.png new file mode 100644 index 0000000000..63f3c23d83 --- /dev/null +++ b/projects/media/.visual/media-playback-rate-select.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:997fb8ef1d20ce16cf2c0bb4069826960c29d96af3ae8d8f64d4296a076482a6 +size 1498 diff --git a/projects/media/.visual/media-playback-rate-select.png b/projects/media/.visual/media-playback-rate-select.png new file mode 100644 index 0000000000..9f69cc6001 --- /dev/null +++ b/projects/media/.visual/media-playback-rate-select.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:efb79c9da4e4a549c59d9cffd402f9fc5ba8a95277f4a8c8784468b82c9befef +size 1402 diff --git a/projects/media/.visual/media-seek-button.dark.png b/projects/media/.visual/media-seek-button.dark.png new file mode 100644 index 0000000000..9d22e1e0cd --- /dev/null +++ b/projects/media/.visual/media-seek-button.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:744841724ef0a968358bde37b4784601c2d9706d7f595f6a2462a0dd9fa81cb2 +size 2148 diff --git a/projects/media/.visual/media-seek-button.png b/projects/media/.visual/media-seek-button.png new file mode 100644 index 0000000000..92d6cfa232 --- /dev/null +++ b/projects/media/.visual/media-seek-button.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0fc36d5556eec6bc1866984160d10ec4ee279a18d03900e748d86965c4ec1a18 +size 1977 diff --git a/projects/media/.visual/media-time-range.dark.png b/projects/media/.visual/media-time-range.dark.png new file mode 100644 index 0000000000..137f90f825 --- /dev/null +++ b/projects/media/.visual/media-time-range.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:333834048db21e3d1b01adbe80d6c482d5d077050f403a08f56c617520056ce6 +size 1791 diff --git a/projects/media/.visual/media-time-range.png b/projects/media/.visual/media-time-range.png new file mode 100644 index 0000000000..4b864f76f2 --- /dev/null +++ b/projects/media/.visual/media-time-range.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c67166df4eca1efc31837dd833a03fe0cb02088f239db16ca76d7536caadeaf3 +size 1693 diff --git a/projects/media/.visual/media-volume-range.dark.png b/projects/media/.visual/media-volume-range.dark.png new file mode 100644 index 0000000000..979fb3822a --- /dev/null +++ b/projects/media/.visual/media-volume-range.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6819427792253ffe24c89c60cad3a70c49f527e1ae5abcdca791d3770d70acd0 +size 1307 diff --git a/projects/media/.visual/media-volume-range.png b/projects/media/.visual/media-volume-range.png new file mode 100644 index 0000000000..39b6f2b0c1 --- /dev/null +++ b/projects/media/.visual/media-volume-range.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00318528f57e4281be640082cfd9f080ef89f049cf9300b481e2c1a4a244c644 +size 1236 diff --git a/projects/media/package.json b/projects/media/package.json index f789dcc445..197edfe334 100644 --- a/projects/media/package.json +++ b/projects/media/package.json @@ -14,6 +14,11 @@ }, "customElements": "dist/custom-elements.json", "contributes": { + "html": { + "customData": [ + "./dist/data.html.json" + ] + }, "snippets": [ { "language": "html", @@ -31,6 +36,7 @@ "exports": { "./package.json": "./package.json", "./custom-elements.json": "./dist/custom-elements.json", + "./data.html.json": "./dist/data.html.json", "./*.examples.json": "./dist/*.examples.json", ".": { "types": "./dist/index.d.ts", @@ -39,6 +45,102 @@ "./index.js": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./controller": { + "types": "./dist/controller/index.d.ts", + "default": "./dist/controller/index.js" + }, + "./controller/define.js": { + "types": "./dist/controller/define.d.ts", + "default": "./dist/controller/define.js" + }, + "./controller/index.js": { + "types": "./dist/controller/index.d.ts", + "default": "./dist/controller/index.js" + }, + "./fullscreen-button": { + "types": "./dist/fullscreen-button/index.d.ts", + "default": "./dist/fullscreen-button/index.js" + }, + "./fullscreen-button/define.js": { + "types": "./dist/fullscreen-button/define.d.ts", + "default": "./dist/fullscreen-button/define.js" + }, + "./fullscreen-button/index.js": { + "types": "./dist/fullscreen-button/index.d.ts", + "default": "./dist/fullscreen-button/index.js" + }, + "./mute-button": { + "types": "./dist/mute-button/index.d.ts", + "default": "./dist/mute-button/index.js" + }, + "./mute-button/define.js": { + "types": "./dist/mute-button/define.d.ts", + "default": "./dist/mute-button/define.js" + }, + "./mute-button/index.js": { + "types": "./dist/mute-button/index.d.ts", + "default": "./dist/mute-button/index.js" + }, + "./pause-button": { + "types": "./dist/pause-button/index.d.ts", + "default": "./dist/pause-button/index.js" + }, + "./pause-button/define.js": { + "types": "./dist/pause-button/define.d.ts", + "default": "./dist/pause-button/define.js" + }, + "./pause-button/index.js": { + "types": "./dist/pause-button/index.d.ts", + "default": "./dist/pause-button/index.js" + }, + "./playback-rate-select": { + "types": "./dist/playback-rate-select/index.d.ts", + "default": "./dist/playback-rate-select/index.js" + }, + "./playback-rate-select/define.js": { + "types": "./dist/playback-rate-select/define.d.ts", + "default": "./dist/playback-rate-select/define.js" + }, + "./playback-rate-select/index.js": { + "types": "./dist/playback-rate-select/index.d.ts", + "default": "./dist/playback-rate-select/index.js" + }, + "./seek-button": { + "types": "./dist/seek-button/index.d.ts", + "default": "./dist/seek-button/index.js" + }, + "./seek-button/define.js": { + "types": "./dist/seek-button/define.d.ts", + "default": "./dist/seek-button/define.js" + }, + "./seek-button/index.js": { + "types": "./dist/seek-button/index.d.ts", + "default": "./dist/seek-button/index.js" + }, + "./time-range": { + "types": "./dist/time-range/index.d.ts", + "default": "./dist/time-range/index.js" + }, + "./time-range/define.js": { + "types": "./dist/time-range/define.d.ts", + "default": "./dist/time-range/define.js" + }, + "./time-range/index.js": { + "types": "./dist/time-range/index.d.ts", + "default": "./dist/time-range/index.js" + }, + "./volume-range": { + "types": "./dist/volume-range/index.d.ts", + "default": "./dist/volume-range/index.js" + }, + "./volume-range/define.js": { + "types": "./dist/volume-range/define.d.ts", + "default": "./dist/volume-range/define.js" + }, + "./volume-range/index.js": { + "types": "./dist/volume-range/index.d.ts", + "default": "./dist/volume-range/index.js" } }, "sideEffects": [ @@ -53,10 +155,13 @@ "test": "wireit", "test:axe": "wireit", "test:coverage": "wireit", + "test:ssr": "wireit", + "test:visual": "wireit", "test:watch": "wireit", "test:lighthouse": "wireit" }, "dependencies": { + "@nvidia-elements/forms": "workspace:^", "lit": "catalog:publish" }, "peerDependencies": { @@ -85,14 +190,21 @@ "wireit": { "dev": { "command": "NODE_ENV=development vite build --watch", - "service": true + "service": true, + "dependencies": [ + "../core:build", + "../internals/vite:ci" + ] }, "ci": { "dependencies": [ "build", "lint", "publint", - "test:coverage" + "test:axe", + "test:ssr", + "test:coverage", + "test:visual" ] }, "publint": { @@ -110,6 +222,8 @@ "files": [ "../../core/dist/**/*.js", "!../../core/dist/**/*.examples.js", + "../forms/dist/**/*.js", + "!../forms/dist/**/*.examples.js", "src/**", "!src/**/*.test.ts", "!src/**/*.test.lighthouse.ts", @@ -129,6 +243,10 @@ { "script": "../core:build", "cascade": false + }, + { + "script": "../forms:build", + "cascade": false } ] }, @@ -204,6 +322,27 @@ "NODE_ENV": "production" } }, + "test:visual": { + "command": "vitest run --config=vitest.visual.ts", + "clean": false, + "files": [ + "dist/**/*.js", + "src/**/*.test.visual.ts", + "vitest.visual.html", + "vitest.visual.ts" + ], + "output": [ + ".visual/*.png", + "coverage/visual/**" + ], + "dependencies": [ + "../internals/vite:ci", + { + "script": "build", + "cascade": false + } + ] + }, "test:axe": { "command": "vitest run --config=vitest.axe.ts", "files": [ @@ -227,6 +366,33 @@ "NODE_ENV": "production" } }, + "test:ssr": { + "command": "vitest run --config=vitest.ssr.ts", + "files": [ + "dist/**/*.js", + "src/**/*.test.ssr.ts", + "tsconfig.json", + "vitest.ssr.ts", + "../themes/dist/**/*.css" + ], + "output": [ + "coverage/ssr/**" + ], + "dependencies": [ + "../internals/vite:ci", + { + "script": "build", + "cascade": false + }, + { + "script": "../themes:build", + "cascade": false + } + ], + "env": { + "NODE_ENV": "production" + } + }, "lint": { "dependencies": [ "lint:eslint" diff --git a/projects/media/src/controller/controller.css b/projects/media/src/controller/controller.css new file mode 100644 index 0000000000..3932eb5442 --- /dev/null +++ b/projects/media/src/controller/controller.css @@ -0,0 +1,35 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + display: block; + inline-size: 100%; + max-inline-size: 100%; + min-inline-size: 0; +} + +[internal-host] { + display: grid; + gap: var(--gap, 0); + inline-size: 100%; + max-inline-size: 100%; + min-inline-size: 0; +} + +slot { + display: block; + inline-size: 100%; + min-inline-size: 0; +} + +::slotted(video), +::slotted(audio) { + display: block; + inline-size: 100%; + max-inline-size: 100%; +} + +::slotted(*) { + box-sizing: border-box; + max-inline-size: 100%; +} diff --git a/projects/media/src/controller/controller.examples.ts b/projects/media/src/controller/controller.examples.ts new file mode 100644 index 0000000000..3711a5e91f --- /dev/null +++ b/projects/media/src/controller/controller.examples.ts @@ -0,0 +1,143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/Controller', + component: 'nve-media-controller' +}; + +/** + * @summary Controller composition with consumer-owned video and controls. Use this when applications need native media markup with Elements playback controls. + */ +export const Default = { + render: () => html` +
+ + + + + + + + + + + + + + + +
+ ` +}; + +/** + * @summary Controller composition with command-only controls. Use this when applications need to send media commands directly to the controller. + * @tags test-case + */ +export const Commands = { + render: () => html` + + + +
+ + + + + + + + + + + + + + + + +
+ ` +}; + +/** + * @summary Form-associated media controls report values through native FormData. Use this when playback settings should join standard form workflows. + */ +export const FormValues = { + render: () => html` +
+
+ + + + +
+ + + + + +
+ + +
+ + +
+
+

+
+ + ` +}; + +/** + * @summary Media controller composition inside a card. Use this when the media controller should be the primary content of a card. + */ +export const Card = { + render: () => html` + + + + + +
+ + + + + + + + + + + + +
+
+
+ ` +}; diff --git a/projects/media/src/controller/controller.test.axe.ts b/projects/media/src/controller/controller.test.axe.ts new file mode 100644 index 0000000000..ade1045b37 --- /dev/null +++ b/projects/media/src/controller/controller.test.axe.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaController } from './controller.js'; +import './define.js'; +import '../pause-button/define.js'; +import '../time-range/define.js'; + +describe(MediaController.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + +
+ +
+
+ `); + try { + await elementIsStable(fixture.querySelector(MediaController.metadata.tag)); + + const results = await runAxe([MediaController.metadata.tag]); + expect(results.violations.length).toBe(0); + } finally { + removeFixture(fixture); + } + }); +}); diff --git a/projects/media/src/controller/controller.test.lighthouse.ts b/projects/media/src/controller/controller.test.lighthouse.ts new file mode 100644 index 0000000000..d485c516b0 --- /dev/null +++ b/projects/media/src/controller/controller.test.lighthouse.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media controller lighthouse report', () => { + test('media controller should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-controller', /* html */` + + + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(30); + }); +}); diff --git a/projects/media/src/controller/controller.test.ssr.ts b/projects/media/src/controller/controller.test.ssr.ts new file mode 100644 index 0000000000..3b0d498594 --- /dev/null +++ b/projects/media/src/controller/controller.test.ssr.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaController } from './controller.js'; +import './define.js'; + +describe(MediaController.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html` + + + + + `); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaController.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/controller/controller.test.ts b/projects/media/src/controller/controller.test.ts new file mode 100644 index 0000000000..13840fa75d --- /dev/null +++ b/projects/media/src/controller/controller.test.ts @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import { + mediaCommands, + type MediaCommand, + type MediaCommandEvent, + type MediaCommandSource +} from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaStateChangeEvent } from '../internal/media-state.js'; +import type { MediaPauseButton } from '../pause-button/pause-button.js'; +import { MediaController } from './controller.js'; +import '../pause-button/define.js'; +import '../seek-button/define.js'; +import './define.js'; + +interface NativeMediaState { + paused: boolean; + muted: boolean; + ended: boolean; + seeking: boolean; + currentTime: number; + duration: number; + volume: number; + playbackRate: number; +} + +describe(MediaController.metadata.tag, () => { + let fixture: HTMLElement; + let controller: MediaController; + let video: HTMLVideoElement; + + beforeEach(async () => { + fixture = await createFixture(html` + + + + `); + controller = getElement(fixture, MediaController.metadata.tag); + video = getElement(fixture, 'video'); + await elementIsStable(controller); + }); + + afterEach(() => { + removeFixture(fixture); + vi.restoreAllMocks(); + }); + + it('should define element', () => { + expect(customElements.get(MediaController.metadata.tag)).toBeDefined(); + }); + + it('should expose an immutable initial media state snapshot', () => { + expect(controller.mediaState).toEqual(createMediaState()); + expect(controller.value).toBe(controller.mediaState); + expect(Object.isFrozen(controller.mediaState)).toBe(true); + }); + + it('should reflect media state from native media events', async () => { + const state = setupMedia(video, { paused: false, muted: true, currentTime: 12, duration: 90, volume: 0.5 }); + const stateChange = untilEvent(controller, mediaStateChange); + video.dispatchEvent(new Event('timeupdate')); + const event = await stateChange; + video.dispatchEvent(new Event('volumechange')); + await elementIsStable(controller); + + expect(event.detail).toBe(controller.mediaState); + expect(event.detail).toEqual( + createMediaState({ paused: false, muted: true, currentTime: 12, duration: 90, volume: 0.5 }) + ); + expect(controller.hasAttribute('paused')).toBe(false); + expect(controller.hasAttribute('muted')).toBe(true); + expect(controller.getAttribute('current-time')).toBe(`${state.currentTime}`); + expect(controller.getAttribute('duration')).toBe(`${state.duration}`); + expect(controller.getAttribute('volume')).toBe(`${state.volume}`); + expect(controller.getAttribute('playback-rate')).toBe(`${state.playbackRate}`); + }); + + it('should not dispatch media state events when state is unchanged', () => { + const state = setupMedia(video, { currentTime: 12 }); + video.dispatchEvent(new Event('timeupdate')); + const listener = vi.fn(); + controller.addEventListener(mediaStateChange, listener); + + video.dispatchEvent(new Event('timeupdate')); + expect(listener).not.toHaveBeenCalled(); + + state.currentTime = 13; + video.dispatchEvent(new Event('timeupdate')); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('should warn and no-op when media is missing', async () => { + removeFixture(fixture); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + fixture = await createFixture(html``); + controller = getElement(fixture, MediaController.metadata.tag); + await elementIsStable(controller); + + dispatchCommand(controller, mediaCommands.play); + + expect(spy).toHaveBeenCalledWith('nve-media-controller missing media element'); + }); + + it('should warn and use the first media element when multiple media elements are slotted', async () => { + removeFixture(fixture); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + fixture = await createFixture(html` + + + + + `); + controller = getElement(fixture, MediaController.metadata.tag); + await elementIsStable(controller); + + expect(spy).toHaveBeenCalledWith('nve-media-controller multiple media element'); + }); + + it('should fill available inline space with slotted media and controls', async () => { + removeFixture(fixture); + fixture = await createFixture(html` +
+ + +
+
+
+ `); + controller = getElement(fixture, MediaController.metadata.tag); + video = getElement(fixture, 'video'); + await elementIsStable(controller); + + const renderRoot = controller.shadowRoot as ShadowRoot; + const host = getElement(renderRoot, '[internal-host]'); + const controls = getElement(fixture, '[data-controls]'); + + expect(Math.round(controller.getBoundingClientRect().width)).toBe(500); + expect(Math.round(host.getBoundingClientRect().width)).toBe(500); + expect(Math.round(video.getBoundingClientRect().width)).toBe(500); + expect(Math.round(controls.getBoundingClientRect().width)).toBe(500); + }); + + it('should render controls after the media element', async () => { + removeFixture(fixture); + fixture = await createFixture(html` +
+ + +
+
+
+ `); + controller = getElement(fixture, MediaController.metadata.tag); + video = getElement(fixture, 'video'); + await elementIsStable(controller); + + const videoRect = video.getBoundingClientRect(); + const controlsRect = getElement(fixture, '[data-controls]').getBoundingClientRect(); + + expect(Math.round(controlsRect.top)).toBeGreaterThanOrEqual(Math.round(videoRect.bottom)); + }); + + it('should handle playback and muted commands', async () => { + const state = setupMedia(video, { paused: true, muted: false }); + + dispatchCommand(controller, mediaCommands.play); + await elementIsStable(controller); + expect(state.paused).toBe(false); + expect(controller.hasAttribute('paused')).toBe(false); + + dispatchCommand(controller, mediaCommands.pause); + await elementIsStable(controller); + expect(state.paused).toBe(true); + expect(controller.hasAttribute('paused')).toBe(true); + + dispatchCommand(controller, mediaCommands.toggleMuted); + expect(state.muted).toBe(true); + + dispatchCommand(controller, mediaCommands.unmute); + expect(state.muted).toBe(false); + }); + + it('should clamp seek and volume command values', () => { + const state = setupMedia(video, { currentTime: 25, duration: 100, volume: 0.5 }); + + dispatchCommand(controller, mediaCommands.seek, createSource({ valueAsNumber: 120 })); + expect(state.currentTime).toBe(100); + + dispatchCommand(controller, mediaCommands.seekBackward, createSource({ valueAsNumber: 20 })); + expect(state.currentTime).toBe(80); + + dispatchCommand(controller, mediaCommands.seekForward, createSource({ valueAsNumber: 30 })); + expect(state.currentTime).toBe(100); + + dispatchCommand(controller, mediaCommands.seekToStart); + expect(state.currentTime).toBe(0); + + dispatchCommand(controller, mediaCommands.seekToEnd); + expect(state.currentTime).toBe(100); + + dispatchCommand(controller, mediaCommands.setVolume, createSource({ valueAsNumber: 2 })); + expect(state.volume).toBe(1); + }); + + it('should clamp backward seek to zero when duration is unavailable', () => { + const state = setupMedia(video, { currentTime: 5, duration: Number.NaN }); + + dispatchCommand(controller, mediaCommands.seekBackward, createSource({ valueAsNumber: 10 })); + + expect(state.currentTime).toBe(0); + }); + + it('should handle playback-rate command values', () => { + const state = setupMedia(video, { playbackRate: 1 }); + + dispatchCommand(controller, mediaCommands.setPlaybackRate, createSource({ valueAsNumber: 1.5 })); + + expect(state.playbackRate).toBe(1.5); + expect(controller.getAttribute('playback-rate')).toBe('1.5'); + }); + + it('should seek by the source value from seek buttons', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + + + + + + `); + controller = getElement(fixture, MediaController.metadata.tag); + video = getElement(fixture, 'video'); + const backward = getElement(fixture, '#backward'); + const forward = getElement(fixture, '#forward'); + const state = setupMedia(video, { currentTime: 50, duration: 100 }); + await elementIsStable(controller); + await elementIsStable(backward); + await elementIsStable(forward); + + await emulateClick(backward); + expect(state.currentTime).toBe(40); + + await emulateClick(forward); + expect(state.currentTime).toBe(47); + }); + + it('should handle toggle playback commands from pause buttons', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + + + + + `); + controller = getElement(fixture, MediaController.metadata.tag); + video = getElement(fixture, 'video'); + const button = getElement(fixture, 'nve-media-pause-button'); + const state = setupMedia(video, { paused: true }); + await elementIsStable(controller); + await elementIsStable(button); + + await emulateClick(button); + expect(state.paused).toBe(false); + expect(controller.hasAttribute('paused')).toBe(false); + expect(button.checked).toBe(false); + + await emulateClick(button); + expect(state.paused).toBe(true); + expect(controller.hasAttribute('paused')).toBe(true); + expect(button.checked).toBe(true); + }); + + it('should reset pause buttons when playback ends', async () => { + removeFixture(fixture); + fixture = await createFixture(html` + + + + + + `); + controller = getElement(fixture, MediaController.metadata.tag); + video = getElement(fixture, 'video'); + const buttons = [...fixture.querySelectorAll('nve-media-pause-button')]; + const state = setupMedia(video, { paused: false }); + video.dispatchEvent(new Event('play')); + await Promise.all(buttons.map(button => elementIsStable(button))); + expect(buttons).toHaveLength(2); + expect(buttons.every(button => !button.checked)).toBe(true); + + state.ended = true; + video.dispatchEvent(new Event('ended')); + await Promise.all(buttons.map(button => elementIsStable(button))); + + expect(controller.hasAttribute('ended')).toBe(true); + expect(buttons.every(button => button.checked)).toBe(true); + }); + + it('should warn for unsupported commands and invalid command values', () => { + setupMedia(video); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + dispatchCommand(controller, '--unknown' as MediaCommand); + dispatchCommand(controller, mediaCommands.seek, createSource({ valueAsNumber: Number.NaN })); + dispatchCommand(controller, mediaCommands.seekBackward, createSource({ valueAsNumber: Number.NaN })); + dispatchCommand(controller, mediaCommands.setVolume, createSource({ valueAsNumber: Number.NaN })); + dispatchCommand(controller, mediaCommands.setPlaybackRate, createSource({ valueAsNumber: 0 })); + + expect(spy).toHaveBeenCalledWith('nve-media-controller unsupported command', '--unknown'); + expect(spy).toHaveBeenCalledWith('nve-media-controller invalid command value', mediaCommands.seek); + expect(spy).toHaveBeenCalledWith('nve-media-controller invalid command value', mediaCommands.seekBackward); + expect(spy).toHaveBeenCalledWith('nve-media-controller invalid command value', mediaCommands.setVolume); + expect(spy).toHaveBeenCalledWith('nve-media-controller invalid command value', mediaCommands.setPlaybackRate); + }); + + it('should warn when finite command values cannot be applied to missing media', async () => { + removeFixture(fixture); + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + fixture = await createFixture(html``); + controller = getElement(fixture, MediaController.metadata.tag); + await elementIsStable(controller); + + dispatchCommand(controller, mediaCommands.seek, createSource({ valueAsNumber: 10 })); + dispatchCommand(controller, mediaCommands.setVolume, createSource({ valueAsNumber: 0.5 })); + dispatchCommand(controller, mediaCommands.setPlaybackRate, createSource({ valueAsNumber: 1.5 })); + + expect(spy).toHaveBeenCalledWith('nve-media-controller missing media element'); + }); + + it('should warn when seeking to an unavailable duration', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + setupMedia(video, { duration: Number.NaN }); + + dispatchCommand(controller, mediaCommands.seekToEnd); + + expect(spy).toHaveBeenCalledWith('nve-media-controller invalid command value', mediaCommands.seekToEnd); + }); + + it('should handle full-screen commands', async () => { + const requestFullscreen = vi.fn().mockResolvedValue(undefined); + const exitFullscreen = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(controller, 'requestFullscreen', { value: requestFullscreen, configurable: true }); + Object.defineProperty(globalThis.document, 'exitFullscreen', { value: exitFullscreen, configurable: true }); + + dispatchCommand(controller, mediaCommands.enterFullscreen); + expect(requestFullscreen).toHaveBeenCalled(); + + Object.defineProperty(globalThis.document, 'fullscreenElement', { value: controller, configurable: true }); + const stateChange = untilEvent(controller, mediaStateChange); + globalThis.document.dispatchEvent(new Event('fullscreenchange')); + expect((await stateChange).detail.fullscreen).toBe(true); + expect(controller.hasAttribute('fullscreen')).toBe(true); + + dispatchCommand(controller, mediaCommands.exitFullscreen); + expect(exitFullscreen).toHaveBeenCalled(); + + Object.defineProperty(globalThis.document, 'fullscreenElement', { + value: document.createElement('div'), + configurable: true + }); + dispatchCommand(controller, mediaCommands.exitFullscreen); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + + Object.defineProperty(globalThis.document, 'fullscreenElement', { value: null, configurable: true }); + dispatchCommand(controller, mediaCommands.exitFullscreen); + expect(exitFullscreen).toHaveBeenCalledTimes(1); + }); +}); + +function setupMedia(video: HTMLVideoElement, options: Partial = {}) { + const state: NativeMediaState = { + paused: true, + muted: false, + ended: false, + seeking: false, + currentTime: 0, + duration: 100, + volume: 1, + playbackRate: 1, + ...options + }; + + defineMediaProperty({ video, property: 'paused', get: () => state.paused }); + defineMediaProperty({ + video, + property: 'muted', + get: () => state.muted, + set: value => (state.muted = Boolean(value)) + }); + defineMediaProperty({ video, property: 'ended', get: () => state.ended }); + defineMediaProperty({ video, property: 'seeking', get: () => state.seeking }); + defineMediaProperty({ + video, + property: 'currentTime', + get: () => state.currentTime, + set: value => (state.currentTime = Number(value)) + }); + defineMediaProperty({ video, property: 'duration', get: () => state.duration }); + defineMediaProperty({ + video, + property: 'volume', + get: () => state.volume, + set: value => (state.volume = Number(value)) + }); + defineMediaProperty({ + video, + property: 'playbackRate', + get: () => state.playbackRate, + set: value => (state.playbackRate = Number(value)) + }); + Object.defineProperty(video, 'play', { + value: vi.fn(() => { + state.paused = false; + video.dispatchEvent(new Event('play')); + return Promise.resolve(); + }), + configurable: true + }); + Object.defineProperty(video, 'pause', { + value: vi.fn(() => { + state.paused = true; + video.dispatchEvent(new Event('pause')); + }), + configurable: true + }); + + return state; +} + +interface MediaPropertyOptions { + video: HTMLVideoElement; + property: keyof HTMLMediaElement; + get: () => unknown; + set?: (value: unknown) => void; +} + +function defineMediaProperty({ video, property, get, set }: MediaPropertyOptions) { + Object.defineProperty(video, property, { get, set, configurable: true }); +} + +function dispatchCommand(controller: MediaController, command: MediaCommand, source: MediaCommandSource | null = null) { + const event = new Event('command') as MediaCommandEvent; + Object.defineProperties(event, { + command: { value: command }, + source: { value: source } + }); + controller.dispatchEvent(event); +} + +function createSource(values: Partial>) { + return Object.assign(globalThis.document.createElement('button'), values) as MediaCommandSource; +} + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} diff --git a/projects/media/src/controller/controller.test.visual.ts b/projects/media/src/controller/controller.test.visual.ts new file mode 100644 index 0000000000..c978b7a34b --- /dev/null +++ b/projects/media/src/controller/controller.test.visual.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media controller visual', () => { + test('media controller should match visual baseline', async () => { + const report = await visualRunner.render('media-controller', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media controller should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-controller.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + + + + + recording + + + `; +} diff --git a/projects/media/src/controller/controller.ts b/projects/media/src/controller/controller.ts new file mode 100644 index 0000000000..8f9a928aef --- /dev/null +++ b/projects/media/src/controller/controller.ts @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { audit, useStyles } from '@nvidia-elements/core/internal'; +import { + isMediaCommand, + mediaCommands, + type MediaCommand, + type MediaCommandEvent, + type MediaCommandSource +} from '../internal/media-command.js'; +import { createMediaState, mediaStatesEqual, type MediaState } from '../internal/media-state.js'; +import styles from './controller.css?inline'; + +const mediaEventTypes = [ + 'durationchange', + 'ended', + 'loadedmetadata', + 'pause', + 'play', + 'ratechange', + 'seeked', + 'seeking', + 'timeupdate', + 'volumechange' +] as const; + +/** + * @element nve-media-controller + * @description Wraps consumer-owned audio or video and applies media commands from external controls. + * @documentation https://nvidia.github.io/elements/docs/media/controller/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/controller + * @command --play - Play the slotted media element. + * @command --pause - Pause the slotted media element. + * @command --toggle-play - Toggle play and pause. + * @command --mute - Mute the media element. + * @command --unmute - Unmute the media element. + * @command --toggle-mute - Toggle media muted state. + * @command --seek - Move playback to the source valueAsNumber. + * @command --seek-start - Move playback to zero seconds. + * @command --seek-end - Move playback to the finite duration. + * @command --seek-backward - Move playback backward by the source valueAsNumber. + * @command --seek-forward - Move playback forward by the source valueAsNumber. + * @command --set-volume - Set media volume to the source valueAsNumber. + * @command --set-playback-rate - Set media playback rate to the source valueAsNumber. + * @command --enter-fullscreen - Request full-screen mode on the controller. + * @command --exit-fullscreen - Exit full-screen mode. + * @command --toggle-fullscreen - Toggle full-screen mode. + * @property mediaState - The latest immutable media and full-screen state snapshot. + * @event media-state-change - Dispatched with the complete state snapshot when the controller media state changes. + * @slot - The controlled video or audio element and supporting controls or content. + * @aria https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API + */ +@audit() +export class MediaController extends LitElement { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-media-controller', + version: '0.0.0' + }; + + static formAssociated = true; + + #media: HTMLMediaElement | null = null; + + #currentTime = 0; + + #fullscreen = false; + + #mediaState = createMediaState(); + + #commandHandlers = new Map void>([ + [mediaCommands.play, () => this.#play()], + [mediaCommands.pause, () => this.#pause()], + [mediaCommands.togglePlayback, () => this.#togglePlayback()], + [mediaCommands.mute, () => this.#setMuted(true)], + [mediaCommands.unmute, () => this.#setMuted(false)], + [mediaCommands.toggleMuted, () => this.#toggleMuted()], + [mediaCommands.seek, source => this.#seekToSourceValue(source)], + [mediaCommands.seekToStart, () => this.#setCurrentTime(0)], + [mediaCommands.seekToEnd, () => this.#seekToEnd()], + [mediaCommands.seekBackward, source => this.#seekBySourceValue(source, -1, mediaCommands.seekBackward)], + [mediaCommands.seekForward, source => this.#seekBySourceValue(source, 1, mediaCommands.seekForward)], + [mediaCommands.setVolume, source => this.#setVolumeFromSource(source)], + [mediaCommands.setPlaybackRate, source => this.#setPlaybackRateFromSource(source)], + [mediaCommands.enterFullscreen, () => this.#enterFullscreen()], + [mediaCommands.exitFullscreen, () => this.#exitFullscreen()], + [mediaCommands.toggleFullscreen, () => this.#toggleFullscreen()] + ]); + + render() { + return html` +
+ +
+ `; + } + + /** The latest immutable media and full-screen state snapshot. */ + get mediaState(): MediaState { + return this.#mediaState; + } + + get value(): MediaState { + return this.mediaState; + } + + connectedCallback() { + super.connectedCallback(); + this.addEventListener('command', this.#onCommand as EventListener); + globalThis.document?.addEventListener('fullscreenchange', this.#syncFullscreen); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('command', this.#onCommand as EventListener); + globalThis.document?.removeEventListener('fullscreenchange', this.#syncFullscreen); + this.#setMedia(null); + } + + firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + this.#syncMediaSlot(); + } + + #onCommand = (event: MediaCommandEvent) => { + if (!isMediaCommand(event.command)) { + console.warn('nve-media-controller unsupported command', event.command); + return; + } + + this.#commandHandlers.get(event.command)?.(event.source ?? null); + }; + + #syncMediaSlot = () => { + const slot = this.renderRoot.querySelector('slot'); + const media = (slot?.assignedElements({ flatten: true }) ?? []).filter(isSupportedMediaElement); + if (media.length > 1) { + console.warn('nve-media-controller multiple media element'); + } + this.#setMedia(media[0] ?? null); + }; + + #setMedia(media: HTMLMediaElement | null) { + if (media === this.#media) { + this.#syncMediaState(); + return; + } + + this.#media && mediaEventTypes.forEach(type => this.#media?.removeEventListener(type, this.#syncMediaState)); + this.#media = media; + this.#media && mediaEventTypes.forEach(type => this.#media?.addEventListener(type, this.#syncMediaState)); + this.#syncMediaState(); + } + + #syncMediaState = () => { + this.#setMediaState(createMediaState({ ...getMediaState(this.#media), fullscreen: this.#fullscreen })); + }; + + #syncFullscreen = () => { + this.#fullscreen = globalThis.document?.fullscreenElement === this; + this.#syncMediaState(); + }; + + #setMediaState(state: MediaState) { + this.#currentTime = state.currentTime; + setBooleanAttribute(this, 'paused', state.paused); + setBooleanAttribute(this, 'muted', state.muted); + setBooleanAttribute(this, 'ended', state.ended); + setBooleanAttribute(this, 'seeking', state.seeking); + setBooleanAttribute(this, 'fullscreen', state.fullscreen); + setNumberAttribute(this, 'current-time', state.currentTime); + setNumberAttribute(this, 'duration', state.duration); + setNumberAttribute(this, 'volume', state.volume); + setNumberAttribute(this, 'playback-rate', state.playbackRate); + + if (mediaStatesEqual(this.#mediaState, state)) { + return; + } + + this.#mediaState = state; + this.dispatchEvent(new CustomEvent('media-state-change', { detail: state })); + } + + #play() { + const media = this.#getMedia(); + media && void media.play().catch(error => console.warn('nve-media-controller play failed', error)); + } + + #pause() { + this.#getMedia()?.pause(); + } + + #togglePlayback() { + if (this.#getMedia()?.paused) { + this.#play(); + } else { + this.#pause(); + } + } + + #setMuted(muted: boolean) { + const media = this.#getMedia(); + if (media) { + media.muted = muted; + this.#syncMediaState(); + } + } + + #toggleMuted() { + const media = this.#getMedia(); + if (media) { + media.muted = !media.muted; + this.#syncMediaState(); + } + } + + #seekToSourceValue(source: MediaCommandSource | null) { + const value = getSourceNumber(source); + Number.isFinite(value) ? this.#setCurrentTime(value) : this.#warnInvalidCommandValue(mediaCommands.seek); + } + + #seekBySourceValue(source: MediaCommandSource | null, direction: -1 | 1, command: MediaCommand) { + const value = getSourceNumber(source); + Number.isFinite(value) + ? this.#setCurrentTime(this.#getCurrentTime() + value * direction) + : this.#warnInvalidCommandValue(command); + } + + #seekToEnd() { + Number.isFinite(this.#media?.duration) + ? this.#setCurrentTime(this.#media?.duration ?? 0) + : this.#warnInvalidCommandValue(mediaCommands.seekToEnd); + } + + #setCurrentTime(value: number) { + const media = this.#getMedia(); + if (media && Number.isFinite(value)) { + media.currentTime = clamp(value, 0, Number.isFinite(media.duration) ? media.duration : Number.POSITIVE_INFINITY); + this.#syncMediaState(); + } + } + + #getCurrentTime() { + return Number.isFinite(this.#media?.currentTime) ? (this.#media?.currentTime ?? 0) : this.#currentTime; + } + + #setVolumeFromSource(source: MediaCommandSource | null) { + const value = getSourceNumber(source); + Number.isFinite(value) ? this.#setVolume(value) : this.#warnInvalidCommandValue(mediaCommands.setVolume); + } + + #setVolume(value: number) { + const media = this.#getMedia(); + if (media) { + media.volume = clamp(value, 0, 1); + this.#syncMediaState(); + } + } + + #setPlaybackRateFromSource(source: MediaCommandSource | null) { + const value = getSourceNumber(source); + Number.isFinite(value) && value > 0 + ? this.#setPlaybackRate(value) + : this.#warnInvalidCommandValue(mediaCommands.setPlaybackRate); + } + + #setPlaybackRate(value: number) { + const media = this.#getMedia(); + if (media) { + media.playbackRate = value; + this.#syncMediaState(); + } + } + + #enterFullscreen() { + void this.requestFullscreen?.().catch(error => console.warn('nve-media-controller fullscreen failed', error)); + } + + #exitFullscreen() { + const document = globalThis.document; + if (document?.fullscreenElement === this) { + void document.exitFullscreen().catch(error => console.warn('nve-media-controller fullscreen exit failed', error)); + } + } + + #toggleFullscreen() { + this.#fullscreen ? this.#exitFullscreen() : this.#enterFullscreen(); + } + + #getMedia() { + if (!this.#media) { + console.warn('nve-media-controller missing media element'); + } + return this.#media; + } + + #warnInvalidCommandValue(command: MediaCommand) { + console.warn('nve-media-controller invalid command value', command); + } +} + +function isSupportedMediaElement(element: Element): element is HTMLMediaElement { + return Boolean(globalThis.HTMLMediaElement && element instanceof globalThis.HTMLMediaElement); +} + +function getMediaState(media: HTMLMediaElement | null) { + if (!media) { + return { + paused: true, + muted: false, + ended: false, + seeking: false, + currentTime: 0, + duration: 0, + volume: 1, + playbackRate: 1 + }; + } + + return { + paused: media.paused, + muted: media.muted, + ended: media.ended, + seeking: media.seeking, + currentTime: getFiniteNumber(media.currentTime, 0), + duration: getFiniteNumber(media.duration, 0), + volume: getFiniteNumber(media.volume, 1), + playbackRate: getFiniteNumber(media.playbackRate, 1) + }; +} + +function setBooleanAttribute(element: HTMLElement, attribute: string, value: boolean) { + element.toggleAttribute(attribute, value); +} + +function setNumberAttribute(element: HTMLElement, attribute: string, value: number) { + element.setAttribute(attribute, `${value}`); +} + +function getFiniteNumber(value: number, fallback: number) { + return Number.isFinite(value) ? value : fallback; +} + +function getSourceNumber(source: MediaCommandSource | null) { + const value = source?.valueAsNumber ?? source?.value; + return Number(value); +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} diff --git a/projects/media/src/controller/define.ts b/projects/media/src/controller/define.ts new file mode 100644 index 0000000000..7ab0436157 --- /dev/null +++ b/projects/media/src/controller/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaController } from '@nvidia-elements/media/controller'; + +define(MediaController); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-controller': MediaController; + } +} diff --git a/projects/media/src/controller/index.ts b/projects/media/src/controller/index.ts new file mode 100644 index 0000000000..737e84d319 --- /dev/null +++ b/projects/media/src/controller/index.ts @@ -0,0 +1,5 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './controller.js'; +export { mediaStateChange, type MediaState, type MediaStateChangeEvent } from '../internal/media-state.js'; diff --git a/projects/media/src/declarations.d.ts b/projects/media/src/declarations.d.ts new file mode 100644 index 0000000000..5db409c190 --- /dev/null +++ b/projects/media/src/declarations.d.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +declare module '*.css' { + const content: string; + export default content; +} + +declare module '*.css?inline' { + const content: string; + export = content; +} diff --git a/projects/media/src/fullscreen-button/define.ts b/projects/media/src/fullscreen-button/define.ts new file mode 100644 index 0000000000..82a3230db2 --- /dev/null +++ b/projects/media/src/fullscreen-button/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaFullscreenButton } from '@nvidia-elements/media/fullscreen-button'; + +define(MediaFullscreenButton); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-fullscreen-button': MediaFullscreenButton; + } +} diff --git a/projects/media/src/fullscreen-button/fullscreen-button.css b/projects/media/src/fullscreen-button/fullscreen-button.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/fullscreen-button/fullscreen-button.examples.ts b/projects/media/src/fullscreen-button/fullscreen-button.examples.ts new file mode 100644 index 0000000000..36d37a4fd7 --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.examples.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/FullscreenButton', + component: 'nve-media-fullscreen-button' +}; + +/** + * @summary Full-screen button connected to a media controller. Use this command-only control when playback should expand the controller region. + */ +export const Default = { + render: () => html` + + + + + ` +}; diff --git a/projects/media/src/fullscreen-button/fullscreen-button.test.axe.ts b/projects/media/src/fullscreen-button/fullscreen-button.test.axe.ts new file mode 100644 index 0000000000..4b4c7028f9 --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.test.axe.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaFullscreenButton } from './fullscreen-button.js'; +import './define.js'; + +describe(MediaFullscreenButton.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + + `); + await elementIsStable(fixture.querySelector(MediaFullscreenButton.metadata.tag)); + + const results = await runAxe([MediaFullscreenButton.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts b/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts new file mode 100644 index 0000000000..ed40efe7a6 --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media fullscreen button lighthouse report', () => { + test('media fullscreen button should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-fullscreen-button', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(35); + }); +}); diff --git a/projects/media/src/fullscreen-button/fullscreen-button.test.ssr.ts b/projects/media/src/fullscreen-button/fullscreen-button.test.ssr.ts new file mode 100644 index 0000000000..2194f6bcb6 --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaFullscreenButton } from './fullscreen-button.js'; +import './define.js'; + +describe(MediaFullscreenButton.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaFullscreenButton.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/fullscreen-button/fullscreen-button.test.ts b/projects/media/src/fullscreen-button/fullscreen-button.test.ts new file mode 100644 index 0000000000..c5e280783c --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.test.ts @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaFullscreenButton } from './fullscreen-button.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaFullscreenButton.metadata.tag, () => { + let fixture: HTMLElement; + let button: MediaFullscreenButton; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + button = getElement(fixture, MediaFullscreenButton.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { fullscreen: false }); + await elementIsStable(button); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaFullscreenButton.metadata.tag)).toBeDefined(); + expect(button.type).toBe('button'); + }); + + it('should not submit its form by default', async () => { + const formFixture = await createFixture(html` +
+ +
+
+ `); + const form = getElement(formFixture, 'form'); + const formButton = getElement(form, MediaFullscreenButton.metadata.tag); + const submit = vi.fn((event: SubmitEvent) => event.preventDefault()); + form.addEventListener('submit', submit); + + await elementIsStable(formButton); + await emulateClick(formButton); + + expect(submit).not.toHaveBeenCalled(); + removeFixture(formFixture); + }); + + it('should dispatch the full-screen command to the target', async () => { + const event = untilEvent(target, 'command'); + await emulateClick(button); + expect((await event).command).toBe(mediaCommands.toggleFullscreen); + }); + + it('should sync pressed state from target fullscreen state', async () => { + setMediaState(target, { fullscreen: true }); + await elementIsStable(button); + + expect(button.pressed).toBe(true); + expect(getInternals(button).ariaPressed).toBe('true'); + }); + + it('should use i18n strings for the default accessible name', async () => { + expect(getInternals(button).ariaLabel).toBe('enter full screen'); + + button.i18n = { enterFullscreen: 'open full screen', exitFullscreen: 'close full screen' }; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('open full screen'); + + setMediaState(target, { fullscreen: true }); + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('close full screen'); + }); + + it('should ignore missing target state', async () => { + button.commandForElement = null; + await elementIsStable(button); + + expect(button.pressed).toBe(false); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function getInternals(element: HTMLElement) { + return (element as HTMLElement & { _internals: ElementInternals })._internals; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/fullscreen-button/fullscreen-button.test.visual.ts b/projects/media/src/fullscreen-button/fullscreen-button.test.visual.ts new file mode 100644 index 0000000000..b8dd70e246 --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.test.visual.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media fullscreen button visual', () => { + test('media fullscreen button should match visual baseline', async () => { + const report = await visualRunner.render('media-fullscreen-button', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media fullscreen button should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-fullscreen-button.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + +
+ `; +} diff --git a/projects/media/src/fullscreen-button/fullscreen-button.ts b/projects/media/src/fullscreen-button/fullscreen-button.ts new file mode 100644 index 0000000000..34d6aac2a0 --- /dev/null +++ b/projects/media/src/fullscreen-button/fullscreen-button.ts @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { property } from 'lit/decorators/property.js'; +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { ButtonFormControlMixin, type ButtonType } from '@nvidia-elements/forms/mixins'; +import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { Icon } from '@nvidia-elements/core/icon'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import mediaButtonStyles from '../internal/media-button.css?inline'; +import styles from './fullscreen-button.css?inline'; + +/** + * @element nve-media-fullscreen-button + * @description Requests controller full-screen changes without adding form submission state. + * @documentation https://nvidia.github.io/elements/docs/media/fullscreen-button/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/fullscreen-button + * @slot - Optional custom icon content. + * @cssprop --background + * @cssprop --color + * @cssprop --border + * @cssprop --border-radius + * @cssprop --height + * @cssprop --width + * @cssprop --padding + * @csspart icon - The fallback icon element. + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/ + */ +@scopedRegistry() +export class MediaFullscreenButton extends ButtonFormControlMixin(LitElement) { + static styles = useStyles([mediaButtonStyles, styles]); + + static elementDefinitions = { + [Icon.metadata.tag]: Icon + }; + + static readonly metadata = { + tag: 'nve-media-fullscreen-button', + version: '0.0.0' + }; + + override command: string = mediaCommands.toggleFullscreen; + + override type: ButtonType = 'button'; + + @property({ type: Boolean, reflect: true }) pressed = false; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + #mediaStateController = new MediaStateController(this, mediaState => this.#syncPressedState(mediaState)); + + render() { + return html` + + `; + } + + get #iconName() { + return this.pressed ? 'minimize' : 'maximize'; + } + + get #defaultAriaLabel() { + return (this.pressed ? this.i18n.exitFullscreen : this.i18n.enterFullscreen) ?? null; + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.#syncDefaultAriaLabel(); + } + + #syncDefaultAriaLabel() { + attachInternals(this); + this._internals.ariaLabel = this.#defaultAriaLabel; + } + + #syncPressedState = (state: MediaState | null) => { + if (state) { + this.pressed = state.fullscreen; // eslint-disable-line local/stateless-property -- Full-screen button mirrors the target controller state. + } + }; +} diff --git a/projects/media/src/fullscreen-button/index.ts b/projects/media/src/fullscreen-button/index.ts new file mode 100644 index 0000000000..02a6ca8fd5 --- /dev/null +++ b/projects/media/src/fullscreen-button/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './fullscreen-button.js'; diff --git a/projects/media/src/internal/button-form-control-usage.test.ts b/projects/media/src/internal/button-form-control-usage.test.ts new file mode 100644 index 0000000000..a27b12a2b0 --- /dev/null +++ b/projects/media/src/internal/button-form-control-usage.test.ts @@ -0,0 +1,180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html, type TemplateResult } from 'lit'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import type { ButtonFormControlMixinInstance } from '@nvidia-elements/forms/mixins'; +import { MediaFullscreenButton } from '../fullscreen-button/fullscreen-button.js'; +import { MediaMuteButton } from '../mute-button/mute-button.js'; +import { MediaPauseButton } from '../pause-button/pause-button.js'; +import { MediaSeekButton } from '../seek-button/seek-button.js'; +import '../fullscreen-button/define.js'; +import '../mute-button/define.js'; +import '../pause-button/define.js'; +import '../seek-button/define.js'; + +interface ButtonUsage { + tag: string; + checkbox?: boolean; + template: TemplateResult; + submitTemplate: TemplateResult; +} + +type ButtonElement = HTMLElement & ButtonFormControlMixinInstance & { _internals: ElementInternals }; +type CheckboxButtonElement = ButtonElement & { checked: boolean }; + +const usages: ButtonUsage[] = [ + { + tag: MediaFullscreenButton.metadata.tag, + template: html``, + submitTemplate: html`` + }, + { + tag: MediaMuteButton.metadata.tag, + checkbox: true, + template: html``, + submitTemplate: html`` + }, + { + tag: MediaPauseButton.metadata.tag, + checkbox: true, + template: html``, + submitTemplate: html`` + }, + { + tag: MediaSeekButton.metadata.tag, + template: html``, + submitTemplate: html`` + } +]; + +describe('ButtonFormControlMixin media usage', () => { + let fixture: HTMLElement; + + afterEach(() => { + removeFixture(fixture); + }); + + usages.forEach(usage => { + describe(usage.tag, () => { + it('should expose role and focus behavior', async () => { + const button = await createButton(usage); + + expect(button._internals.role).toBe('button'); + expect(button.tabIndex).toBe(0); + }); + + it('should sync disabled and readonly states', async () => { + const button = await createButton(usage); + + button.disabled = true; + await elementIsStable(button); + expect(button._internals.ariaDisabled).toBe('true'); + expect(button.matches(':state(disabled)')).toBe(true); + expect(button.tabIndex).toBe(-1); + + button.disabled = false; + button.readonly = true; + await elementIsStable(button); + expect(button.readOnly).toBe(true); + expect(button.hasAttribute('readonly')).toBe(true); + expect(button._internals.role).toBe('none'); + expect(button._internals.ariaDisabled).toBe(null); + }); + + it('should sync active, pressed, and expanded states', async () => { + const button = await createButton(usage); + + button.dispatchEvent(new MouseEvent('mousedown')); + expect(button.matches(':state(active)')).toBe(true); + button.dispatchEvent(new MouseEvent('mouseup')); + expect(button.matches(':state(active)')).toBe(false); + + button.pressed = true; + button.expanded = true; + await elementIsStable(button); + expect(button._internals.ariaPressed).toBe('true'); + expect(button._internals.ariaExpanded).toBe('true'); + }); + + it('should dispatch commands and suppress interaction while unavailable', async () => { + const button = await createButton(usage); + const target = getElement(fixture, '#target'); + button.command = '--test-command'; + await elementIsStable(button); + + const command = untilEvent(target, 'command'); + emulateClick(button); + expect((await command).source).toBe(button); + + let count = 0; + target.addEventListener('command', () => (count += 1)); + button.disabled = true; + await elementIsStable(button); + emulateClick(button); + button.disabled = false; + button.readOnly = true; + await elementIsStable(button); + emulateClick(button); + expect(count).toBe(0); + }); + + if (usage.checkbox) { + it('should contribute checkbox form data when checked', async () => { + const button = await createSubmitButton(usage); + const form = getElement(fixture, 'form'); + + expect(new FormData(form).get('button-name')).toBe('button-value'); + (button as CheckboxButtonElement).checked = false; + await elementIsStable(button); + expect(new FormData(form).get('button-name')).toBe(null); + }); + + return; + } + + it('should submit with name and value when type is submit', async () => { + const button = await createSubmitButton(usage); + const form = getElement(fixture, 'form'); + form.addEventListener('submit', event => event.preventDefault()); + + const submit = untilEvent(form, 'submit'); + emulateClick(button); + const event = await submit; + + expect(event.submitter?.name).toBe('button-name'); + expect(event.submitter?.value).toBe('button-value'); + expect(event.submitter?.form).toBe(form); + }); + }); + }); + + async function createButton({ template, tag }: ButtonUsage) { + fixture = await createFixture(html` + ${template} +
+ `); + const button = getElement(fixture, tag); + await elementIsStable(button); + return button; + } + + async function createSubmitButton({ submitTemplate, tag }: ButtonUsage) { + fixture = await createFixture(html` +
+ ${submitTemplate} +
+
+ `); + const button = getElement(fixture, tag); + await elementIsStable(button); + return button; + } +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} diff --git a/projects/media/src/internal/command-target.ts b/projects/media/src/internal/command-target.ts new file mode 100644 index 0000000000..b1f66be6ed --- /dev/null +++ b/projects/media/src/internal/command-target.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { getFlattenedDOMTree } from '@nvidia-elements/core/internal'; + +type CommandTargetHost = HTMLElement & { + commandForElement: HTMLElement | null; +}; + +export function getCommandTarget(host: CommandTargetHost) { + if (host.commandForElement) { + return host.commandForElement; + } + + const id = host.getAttribute('commandfor'); + if (!id) { + return null; + } + + return getFlattenedDOMTree(host.getRootNode() as HTMLElement).find(element => element.id === id) ?? null; +} diff --git a/projects/media/src/internal/controllers/media-state.controller.test.ts b/projects/media/src/internal/controllers/media-state.controller.test.ts new file mode 100644 index 0000000000..63e3d78b93 --- /dev/null +++ b/projects/media/src/internal/controllers/media-state.controller.test.ts @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators/custom-element.js'; +import { property } from 'lit/decorators/property.js'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { createMediaState, mediaStateChange, type MediaState } from '../media-state.js'; +import { MediaStateController } from './media-state.controller.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +@customElement('media-state-controller-test-element') +class MediaStateControllerTestElement extends LitElement { + @property({ attribute: false }) commandForElement: HTMLElement | null = null; + + mediaState: MediaState | null = null; + + #mediaStateController = new MediaStateController(this, state => (this.mediaState = state)); +} + +describe(MediaStateController.name, () => { + let element: MediaStateControllerTestElement; + let fixture: HTMLElement; + let target: MediaStateTarget; + + beforeEach(async () => { + element = document.createElement('media-state-controller-test-element') as MediaStateControllerTestElement; + target = createTarget({ volume: 0.5 }); + element.commandForElement = target; + fixture = await createFixture(html`${element}${target}`); + await elementIsStable(element); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should read initial state and listen for state changes', () => { + expect(element.mediaState).toBe(target.mediaState); + + setMediaState(target, { volume: 0.75 }); + expect(element.mediaState).toBe(target.mediaState); + }); + + it('should ignore invalid state events', () => { + const mediaState = element.mediaState; + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: { paused: false } })); + + expect(element.mediaState).toBe(mediaState); + }); + + it('should retarget and disconnect through the host lifecycle', async () => { + const nextTarget = createTarget({ volume: 0.75 }); + fixture.append(nextTarget); + element.commandForElement = nextTarget; + await elementIsStable(element); + + expect(element.mediaState).toBe(nextTarget.mediaState); + + const mediaState = element.mediaState; + setMediaState(target, { volume: 1 }); + expect(element.mediaState).toBe(mediaState); + + setMediaState(nextTarget, { volume: 0.25 }); + expect(element.mediaState).toBe(nextTarget.mediaState); + + element.remove(); + const disconnectedState = element.mediaState; + setMediaState(nextTarget, { volume: 0 }); + expect(element.mediaState).toBe(disconnectedState); + }); +}); + +function createTarget(state: Partial = {}): MediaStateTarget { + return Object.assign(document.createElement('div'), { mediaState: createMediaState(state) }); +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/internal/controllers/media-state.controller.ts b/projects/media/src/internal/controllers/media-state.controller.ts new file mode 100644 index 0000000000..0f6d773797 --- /dev/null +++ b/projects/media/src/internal/controllers/media-state.controller.ts @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ReactiveController, ReactiveElement } from 'lit'; +import { getCommandTarget } from '../command-target.js'; +import { getTargetMediaState, isMediaState, mediaStateChange, type MediaState } from '../media-state.js'; + +type MediaStateHost = ReactiveElement & { + commandForElement: HTMLElement | null; +}; + +type MediaStateCallback = (state: MediaState | null) => void; + +type StateSyncTiming = 'immediate' | 'microtask'; + +export class MediaStateController implements ReactiveController { + #commandTarget: HTMLElement | null = null; + + constructor( + private host: T, + private callback: MediaStateCallback + ) { + this.host.addController(this); + } + + hostConnected() { + this.#syncCommandTarget(); + } + + hostUpdate() { + this.#syncCommandTarget(); + } + + hostUpdated() { + this.#syncCommandTarget('microtask'); + } + + hostDisconnected() { + this.#disconnectCommandTarget(); + } + + #syncCommandTarget(stateSyncTiming: StateSyncTiming = 'immediate') { + const commandTarget = getCommandTarget(this.host); + if (commandTarget === this.#commandTarget) { + return; + } + + this.#disconnectCommandTarget(); + this.#commandTarget = commandTarget; + this.#commandTarget?.addEventListener(mediaStateChange, this.#onMediaStateChange); + + const syncState = () => { + if (commandTarget === this.#commandTarget) { + this.callback(getTargetMediaState(commandTarget)); + } + }; + + if (stateSyncTiming === 'microtask') { + queueMicrotask(syncState); + } else { + syncState(); + } + } + + #disconnectCommandTarget() { + this.#commandTarget?.removeEventListener(mediaStateChange, this.#onMediaStateChange); + this.#commandTarget = null; + } + + #onMediaStateChange = (event: Event) => { + if (event instanceof CustomEvent && isMediaState(event.detail)) { + this.callback(event.detail); + } + }; +} diff --git a/projects/media/src/internal/media-button.css b/projects/media/src/internal/media-button.css new file mode 100644 index 0000000000..35bcc14d34 --- /dev/null +++ b/projects/media/src/internal/media-button.css @@ -0,0 +1,226 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + --nve-sys-interaction-state-base: var(--background, var(--nve-sys-layer-canvas-background)); + --background: transparent; + --color: var(--nve-sys-text-muted-color); + --padding: 0; + --border: 0; + --border-radius: var(--nve-ref-border-radius-xs); + --font-weight: var(--nve-ref-font-weight-semibold); + --font-size: var(--nve-ref-font-size-100); + --text-decoration: none; + --text-align: center; + --cursor: pointer; + --gap: var(--nve-ref-space-xs); + --height: var(--nve-ref-size-800); + --width: var(--nve-ref-size-800); + --min-width: var(--width); + --text-transform: initial; + --line-height: 1.1em; + --_icon-color: var(--color); + display: inline-flex; + position: relative; + user-select: none; + white-space: nowrap; + width: var(--width); + min-width: var(--min-width); +} + +[internal-host] { + color: var(--color); + background: var(--background); + padding: var(--padding); + height: var(--height); + width: 100%; + border: var(--border); + border-radius: var(--border-radius); + text-decoration: var(--text-decoration); + text-align: var(--text-align); + font-weight: var(--font-weight); + font-size: var(--font-size); + cursor: var(--cursor); + gap: var(--gap); + text-transform: var(--text-transform); + display: inline-flex; + place-items: center; + justify-content: center; + white-space: nowrap; + line-height: var(--line-height); + overflow: hidden; +} + +:host([readonly]) { + --text-decoration: none; + --cursor: default; +} + +:host(:state(disabled)) { + --color: var(--nve-sys-interaction-disabled-color); + --background: var(--nve-sys-interaction-disabled-background); + --cursor: not-allowed; +} + +:host(:state(disabled)) ::slotted(a) { + cursor: not-allowed; +} + +:host(:state(pressed)) { + --color: var(--nve-sys-interaction-selected-color); +} + +:host(:hover:not([disabled], [readonly], [pressed])) { + --color: var(--nve-sys-interaction-hover-color); +} + +:host([selected]) [interaction-state], +:host(:state(selected)) [interaction-state], +:host([pressed]) [interaction-state], +:host(:state(pressed)) [interaction-state] { + --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-selected); +} + +:host([container*='inline']) { + --background: transparent; + --text-transform: initial; + --border-radius: var(--nve-ref-border-radius-xs); + --_icon-color: var(--color); +} + +@container style(--nve-sys-color-scheme: dark) { + :host { + --nve-sys-interaction-state-ratio-selected: 16% !important; + --nve-sys-interaction-state-ratio-hover: 12% !important; + } +} + +:host([container*='inline']) { + --padding: 0; + --height: fit-content; + --line-height: 0.8; + + [internal-host] { + overflow: initial; + } +} + +:host([container*='inline']:hover) { + --background: transparent; + --text-decoration: underline; +} + +:host([container*='inline']:hover) [interaction-state] { + --nve-sys-interaction-state-ratio: 0; +} + +:host([container*='inline']:state(active)) { + --background: transparent; + --text-decoration: underline; +} + +:host(:state(disabled)), +:host([container*='inline']:state(disabled)) { + --color: var(--nve-sys-interaction-disabled-color); + --background: transparent; + --text-decoration: none !important; +} + +:host(:state(disabled)) [interaction-state], +:host([container*='inline']:state(disabled)) [interaction-state] { + --nve-sys-interaction-state-ratio: 0; +} + +:host([container*='inline']:state(selected)) { + --color: var(--nve-sys-interaction-selected-color); + --background: transparent; +} + +:host(:state(selected)) { + --color: var(--nve-sys-interaction-selected-color); +} + +:host([container*='inline']:state(selected)) [interaction-state] { + --nve-sys-interaction-state-ratio: 0; +} + +:host([container*='inline'][readonly]:not(:state(anchor))) { + --text-decoration: none; + --cursor: cursor; +} + +@keyframes inline-underline { + from { + transform: translateX(-50%) scaleX(0); + } + + to { + transform: translateX(-50%) scaleX(1); + } +} + +:host([container*='inline']) [internal-host]::after { + content: ''; + position: absolute; + left: 50%; + + /* private API for inline underline border-bottom to customize underline downstream */ + bottom: calc(var(--_border-bottom-offset, -2) * var(--nve-ref-border-width-md)); + width: var(--_border-bottom-width, 100%); + height: var(--nve-ref-border-width-md); + background-color: currentColor; + transform: translateX(-50%) scaleX(0); + transform-origin: center; + transition: transform 0.3s ease; + will-change: transform; + border-radius: var(--nve-ref-border-radius-full); +} + +:host([container*='inline']:hover) [internal-host]::after, +:host([container*='inline']:focus) [internal-host]::after { + transform: translateX(-50%) scaleX(1); +} + +:host([container*='inline']:state(disabled)) [internal-host]::after { + display: none; +} + +:host([container*='inline']) { + --text-decoration: none; +} + +:host([container*='inline']:hover) { + --text-decoration: none; +} + +/* override base button inline style */ +:host([container*='inline']) { + --height: var(--nve-ref-size-800); + --line-height: 0; +} + +:host([size='sm']) { + --padding: 0; + --height: var(--nve-ref-size-700); + --width: var(--nve-ref-size-700); +} + +:host([size='lg']) { + --width: var(--nve-ref-size-1000); + --height: var(--nve-ref-size-1000); + --padding: 0; +} + +[internal-host] { + font-size: var(--font-size); +} + +/* disable pseudo element on base button */ +[internal-host]::after { + display: none !important; +} + +nve-icon { + --color: var(--_icon-color); + pointer-events: none; +} diff --git a/projects/media/src/internal/media-command.ts b/projects/media/src/internal/media-command.ts new file mode 100644 index 0000000000..117555162b --- /dev/null +++ b/projects/media/src/internal/media-command.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const mediaCommands = { + play: '--play', + pause: '--pause', + togglePlayback: '--toggle-play', + mute: '--mute', + unmute: '--unmute', + toggleMuted: '--toggle-mute', + seek: '--seek', + seekToStart: '--seek-start', + seekToEnd: '--seek-end', + seekBackward: '--seek-backward', + seekForward: '--seek-forward', + setVolume: '--set-volume', + setPlaybackRate: '--set-playback-rate', + enterFullscreen: '--enter-fullscreen', + exitFullscreen: '--exit-fullscreen', + toggleFullscreen: '--toggle-fullscreen' +} as const; + +export type MediaCommand = (typeof mediaCommands)[keyof typeof mediaCommands]; + +export type MediaSeekAction = 'start' | 'backward' | 'forward' | 'end'; + +export type MediaCommandSource = HTMLElement & { + valueAsNumber?: number; + value?: string; +}; + +export type MediaCommandEvent = Event & { + command?: string; + source?: MediaCommandSource | null; +}; + +export function isMediaCommand(command: string | undefined): command is MediaCommand { + return Object.values(mediaCommands).includes(command as MediaCommand); +} diff --git a/projects/media/src/internal/media-range.css b/projects/media/src/internal/media-range.css new file mode 100644 index 0000000000..8de3a04208 --- /dev/null +++ b/projects/media/src/internal/media-range.css @@ -0,0 +1,95 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + --background: var(--nve-sys-support-accent-color); + --track-background: var(--nve-sys-interaction-background); + --track-height: var(--nve-ref-size-150); + --track-border-radius: var(--nve-ref-border-radius-md); + --thumb-background: var(--nve-sys-text-muted-color); + --thumb-border: 0; + --thumb-height: var(--nve-ref-size-400); + --thumb-width: var(--nve-ref-size-400); + --height: var(--nve-ref-size-800); + --width: 100%; + --cursor: pointer; + --track-progress: 0; + display: inline-flex; + inline-size: var(--width); + block-size: var(--height); +} + +[internal-host] { + display: inline-flex; + align-items: center; + inline-size: 100%; + block-size: 100%; + position: relative; +} + +[internal-host]::before, +[internal-host]::after { + content: ''; + position: absolute; + inset-inline: calc(var(--thumb-width) / 2); + block-size: var(--track-height); + border-radius: var(--track-border-radius); + pointer-events: none; +} + +[internal-host]::before { + background: var(--track-background); +} + +[internal-host]::after { + inset-inline-end: auto; + inline-size: calc(var(--track-progress) * (100% - var(--thumb-width))); + background: var(--background); +} + +input { + accent-color: var(--background); + appearance: none; + background: transparent; + box-sizing: border-box; + cursor: var(--cursor); + inline-size: 100%; + margin: 0; + padding: 0; + position: relative; + z-index: 1; +} + +input::-webkit-slider-runnable-track { + block-size: var(--track-height); + background: transparent; +} + +input::-moz-range-track { + block-size: var(--track-height); + background: transparent; +} + +input::-webkit-slider-thumb { + appearance: none; + background: var(--thumb-background); + border: var(--thumb-border); + border-radius: var(--nve-ref-border-radius-full); + block-size: var(--thumb-height); + inline-size: var(--thumb-width); + margin-block-start: calc((var(--track-height) - var(--thumb-height)) / 2); +} + +input::-moz-range-thumb { + background: var(--thumb-background); + border: var(--thumb-border); + border-radius: var(--nve-ref-border-radius-full); + block-size: var(--thumb-height); + inline-size: var(--thumb-width); +} + +:host([disabled]), +:host([readonly]) { + --background: var(--nve-sys-interaction-disabled-background); + --cursor: not-allowed; +} diff --git a/projects/media/src/internal/media-state.ts b/projects/media/src/internal/media-state.ts new file mode 100644 index 0000000000..1bad690fa0 --- /dev/null +++ b/projects/media/src/internal/media-state.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export interface MediaState { + readonly currentTime: number; + readonly duration: number; + readonly ended: boolean; + readonly fullscreen: boolean; + readonly muted: boolean; + readonly paused: boolean; + readonly playbackRate: number; + readonly seeking: boolean; + readonly volume: number; +} + +export type MediaStateChangeEvent = CustomEvent; + +export const mediaStateChange = 'media-state-change'; + +const booleanStateKeys = ['ended', 'fullscreen', 'muted', 'paused', 'seeking'] as const; + +const numberStateKeys = ['currentTime', 'duration', 'playbackRate', 'volume'] as const; + +const defaultMediaState: MediaState = { + currentTime: 0, + duration: 0, + ended: false, + fullscreen: false, + muted: false, + paused: true, + playbackRate: 1, + seeking: false, + volume: 1 +}; + +export function createMediaState(state: Partial = {}): MediaState { + return Object.freeze({ ...defaultMediaState, ...state }); +} + +export function isMediaState(value: unknown): value is MediaState { + return ( + isRecord(value) && + booleanStateKeys.every(key => typeof value[key] === 'boolean') && + numberStateKeys.every(key => typeof value[key] === 'number') + ); +} + +export function mediaStatesEqual(a: MediaState, b: MediaState) { + return ( + a.currentTime === b.currentTime && + a.duration === b.duration && + a.ended === b.ended && + a.fullscreen === b.fullscreen && + a.muted === b.muted && + a.paused === b.paused && + a.playbackRate === b.playbackRate && + a.seeking === b.seeking && + a.volume === b.volume + ); +} + +export function getTargetMediaState(target: HTMLElement | null): MediaState | null { + if (!target || !('mediaState' in target)) { + return null; + } + + return isMediaState(target.mediaState) ? target.mediaState : null; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object'; +} diff --git a/projects/media/src/mute-button/define.ts b/projects/media/src/mute-button/define.ts new file mode 100644 index 0000000000..6da91fcb8b --- /dev/null +++ b/projects/media/src/mute-button/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaMuteButton } from '@nvidia-elements/media/mute-button'; + +define(MediaMuteButton); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-mute-button': MediaMuteButton; + } +} diff --git a/projects/media/src/mute-button/index.ts b/projects/media/src/mute-button/index.ts new file mode 100644 index 0000000000..7deb3babba --- /dev/null +++ b/projects/media/src/mute-button/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './mute-button.js'; diff --git a/projects/media/src/mute-button/mute-button.css b/projects/media/src/mute-button/mute-button.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/mute-button/mute-button.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/mute-button/mute-button.examples.ts b/projects/media/src/mute-button/mute-button.examples.ts new file mode 100644 index 0000000000..b3d9f538d9 --- /dev/null +++ b/projects/media/src/mute-button/mute-button.examples.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/MuteButton', + component: 'nve-media-mute-button' +}; + +/** + * @summary Mute button connected to a media controller. Use this pattern to submit muted state with other form values. + */ +export const Default = { + render: () => html` + + + + + ` +}; diff --git a/projects/media/src/mute-button/mute-button.test.axe.ts b/projects/media/src/mute-button/mute-button.test.axe.ts new file mode 100644 index 0000000000..9dde01f7e7 --- /dev/null +++ b/projects/media/src/mute-button/mute-button.test.axe.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaMuteButton } from './mute-button.js'; +import './define.js'; + +describe(MediaMuteButton.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + + `); + await elementIsStable(fixture.querySelector(MediaMuteButton.metadata.tag)); + + const results = await runAxe([MediaMuteButton.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/mute-button/mute-button.test.lighthouse.ts b/projects/media/src/mute-button/mute-button.test.lighthouse.ts new file mode 100644 index 0000000000..d54d78e204 --- /dev/null +++ b/projects/media/src/mute-button/mute-button.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media mute button lighthouse report', () => { + test('media mute button should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-mute-button', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(35); + }); +}); diff --git a/projects/media/src/mute-button/mute-button.test.ssr.ts b/projects/media/src/mute-button/mute-button.test.ssr.ts new file mode 100644 index 0000000000..d0a9178ac7 --- /dev/null +++ b/projects/media/src/mute-button/mute-button.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaMuteButton } from './mute-button.js'; +import './define.js'; + +describe(MediaMuteButton.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaMuteButton.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/mute-button/mute-button.test.ts b/projects/media/src/mute-button/mute-button.test.ts new file mode 100644 index 0000000000..21d7055ef5 --- /dev/null +++ b/projects/media/src/mute-button/mute-button.test.ts @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaMuteButton } from './mute-button.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaMuteButton.metadata.tag, () => { + let fixture: HTMLElement; + let button: MediaMuteButton; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + button = getElement(fixture, MediaMuteButton.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { muted: false }); + await elementIsStable(button); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaMuteButton.metadata.tag)).toBeDefined(); + }); + + it('should dispatch the muted command to the target', async () => { + const event = untilEvent(target, 'command'); + await emulateClick(button); + expect((await event).command).toBe(mediaCommands.toggleMuted); + }); + + it('should ignore missing target state', async () => { + button.commandForElement = null; + await elementIsStable(button); + + expect(button.checked).toBe(false); + }); + + it('should sync checked state from the target controller', async () => { + const input = vi.fn(); + const change = vi.fn(); + button.addEventListener('input', input); + button.addEventListener('change', change); + + setMediaState(target, { muted: true }); + await elementIsStable(button); + expect(button.checked).toBe(true); + + setMediaState(target, { muted: false }); + await elementIsStable(button); + expect(button.checked).toBe(false); + expect(input).not.toHaveBeenCalled(); + expect(change).not.toHaveBeenCalled(); + }); + + it('should use i18n strings for the default accessible name', async () => { + expect(getInternals(button).ariaLabel).toBe('mute media'); + + button.i18n = { muteMedia: 'silence recording', unmuteMedia: 'restore recording' }; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('silence recording'); + + button.checked = true; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('restore recording'); + }); + + it('should validate required checked state', async () => { + button.required = true; + button.checked = false; + await elementIsStable(button); + + expect(button.checkValidity()).toBe(false); + button.checked = true; + await elementIsStable(button); + expect(button.checkValidity()).toBe(true); + }); + + it('should expose form control APIs and restore toggle state', async () => { + const formFixture = await createFixture(html` +
+ +
+ `); + const form = getElement(formFixture, 'form'); + const formButton = getElement(form, MediaMuteButton.metadata.tag); + await elementIsStable(formButton); + + expect(formButton.form).toBe(form); + expect(formButton.willValidate).toBe(true); + expect(formButton.validity.valid).toBe(true); + expect(formButton.validationMessage).toBe(''); + expect(new FormData(form).get('muted')).toBe('true'); + + formButton.checked = false; + await elementIsStable(formButton); + expect(formButton.reportValidity()).toBe(false); + + formButton.formStateRestoreCallback('true'); + expect(formButton.checked).toBe(true); + formButton.formStateRestoreCallback(null); + expect(formButton.checked).toBe(false); + + formButton.formResetCallback(); + expect(formButton.checked).toBe(true); + + formButton.disabled = true; + await elementIsStable(formButton); + expect(new FormData(form).get('muted')).toBe(null); + + removeFixture(formFixture); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function getInternals(element: HTMLElement) { + return (element as HTMLElement & { _internals: ElementInternals })._internals; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/mute-button/mute-button.test.visual.ts b/projects/media/src/mute-button/mute-button.test.visual.ts new file mode 100644 index 0000000000..d701071d05 --- /dev/null +++ b/projects/media/src/mute-button/mute-button.test.visual.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media mute button visual', () => { + test('media mute button should match visual baseline', async () => { + const report = await visualRunner.render('media-mute-button', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media mute button should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-mute-button.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + +
+ `; +} diff --git a/projects/media/src/mute-button/mute-button.ts b/projects/media/src/mute-button/mute-button.ts new file mode 100644 index 0000000000..2bb251c02a --- /dev/null +++ b/projects/media/src/mute-button/mute-button.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { ButtonFormControlMixin, CheckboxFormControlMixin } from '@nvidia-elements/forms/mixins'; +import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { Icon } from '@nvidia-elements/core/icon'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import mediaButtonStyles from '../internal/media-button.css?inline'; +import styles from './mute-button.css?inline'; + +/** + * @element nve-media-mute-button + * @description Requests muted changes and submits a checkbox-style value for muted media. + * @documentation https://nvidia.github.io/elements/docs/media/mute-button/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/mute-button + * @slot - Optional custom icon content. + * @cssprop --background + * @cssprop --color + * @cssprop --border + * @cssprop --border-radius + * @cssprop --height + * @cssprop --width + * @cssprop --padding + * @csspart icon - The fallback icon element. + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/ + */ +@scopedRegistry() +export class MediaMuteButton extends CheckboxFormControlMixin(ButtonFormControlMixin(LitElement)) { + static styles = useStyles([mediaButtonStyles, styles]); + + static elementDefinitions = { + [Icon.metadata.tag]: Icon + }; + + static readonly metadata = { + tag: 'nve-media-mute-button', + version: '0.0.0' + }; + + override command: string = mediaCommands.toggleMuted; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + #mediaStateController = new MediaStateController(this, mediaState => this.#handleMediaState(mediaState)); + + render() { + return html` + + `; + } + + #onClick = () => this.toggle(); + + override connectedCallback() { + super.connectedCallback(); + this.addEventListener('click', this.#onClick, true); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('click', this.#onClick, true); + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.#syncDefaultAriaLabel(); + } + + get #iconName() { + return this.checked ? 'volume-muted' : 'volume'; + } + + get #defaultAriaLabel() { + return (this.checked ? this.i18n.unmuteMedia : this.i18n.muteMedia) ?? null; + } + + #syncDefaultAriaLabel() { + attachInternals(this); + this._internals.ariaLabel = this.#defaultAriaLabel; + } + + #handleMediaState = (state: MediaState | null) => { + if (state) { + this.checked = state.muted; + } + }; +} diff --git a/projects/media/src/pause-button/define.ts b/projects/media/src/pause-button/define.ts new file mode 100644 index 0000000000..cdae05e895 --- /dev/null +++ b/projects/media/src/pause-button/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaPauseButton } from '@nvidia-elements/media/pause-button'; + +define(MediaPauseButton); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-pause-button': MediaPauseButton; + } +} diff --git a/projects/media/src/pause-button/index.ts b/projects/media/src/pause-button/index.ts new file mode 100644 index 0000000000..83f30f636e --- /dev/null +++ b/projects/media/src/pause-button/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './pause-button.js'; diff --git a/projects/media/src/pause-button/pause-button.css b/projects/media/src/pause-button/pause-button.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/pause-button/pause-button.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/pause-button/pause-button.examples.ts b/projects/media/src/pause-button/pause-button.examples.ts new file mode 100644 index 0000000000..90ab96bcb3 --- /dev/null +++ b/projects/media/src/pause-button/pause-button.examples.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/PauseButton', + component: 'nve-media-pause-button' +}; + +/** + * @summary Pause button inside a media controller. Use commandfor to associate controls with the controller. + */ +export const Default = { + render: () => html` + + + + + ` +}; diff --git a/projects/media/src/pause-button/pause-button.test.axe.ts b/projects/media/src/pause-button/pause-button.test.axe.ts new file mode 100644 index 0000000000..0152cecfd3 --- /dev/null +++ b/projects/media/src/pause-button/pause-button.test.axe.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaPauseButton } from './pause-button.js'; +import './define.js'; + +describe(MediaPauseButton.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + + `); + await elementIsStable(fixture.querySelector(MediaPauseButton.metadata.tag)); + + const results = await runAxe([MediaPauseButton.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/pause-button/pause-button.test.lighthouse.ts b/projects/media/src/pause-button/pause-button.test.lighthouse.ts new file mode 100644 index 0000000000..9b850c0343 --- /dev/null +++ b/projects/media/src/pause-button/pause-button.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media pause button lighthouse report', () => { + test('media pause button should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-pause-button', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(35); + }); +}); diff --git a/projects/media/src/pause-button/pause-button.test.ssr.ts b/projects/media/src/pause-button/pause-button.test.ssr.ts new file mode 100644 index 0000000000..84318ae636 --- /dev/null +++ b/projects/media/src/pause-button/pause-button.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaPauseButton } from './pause-button.js'; +import './define.js'; + +describe(MediaPauseButton.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaPauseButton.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/pause-button/pause-button.test.ts b/projects/media/src/pause-button/pause-button.test.ts new file mode 100644 index 0000000000..536b660c8e --- /dev/null +++ b/projects/media/src/pause-button/pause-button.test.ts @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaPauseButton } from './pause-button.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaPauseButton.metadata.tag, () => { + let fixture: HTMLElement; + let button: MediaPauseButton; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + button = getElement(fixture, MediaPauseButton.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { paused: true }); + await elementIsStable(button); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaPauseButton.metadata.tag)).toBeDefined(); + expect(getInternals(button).role).toBe('button'); + }); + + it('should dispatch the playback command to the target', async () => { + const event = untilEvent(target, 'command'); + await emulateClick(button); + const command = await event; + + expect(command.command).toBe(mediaCommands.togglePlayback); + expect(command.source).toBe(button); + }); + + it('should toggle checked state on user activation', async () => { + expect(button.checked).toBe(true); + + emulateClick(button); + await elementIsStable(button); + expect(button.checked).toBe(false); + + emulateClick(button); + await elementIsStable(button); + expect(button.checked).toBe(true); + }); + + it('should sync checked state from target playback state', async () => { + const input = vi.fn(); + const change = vi.fn(); + button.addEventListener('input', input); + button.addEventListener('change', change); + expect(button.checked).toBe(true); + + setMediaState(target, { paused: false }); + await elementIsStable(button); + expect(button.checked).toBe(false); + + setMediaState(target, { ended: true, paused: false }); + await elementIsStable(button); + expect(button.checked).toBe(true); + expect(input).not.toHaveBeenCalled(); + expect(change).not.toHaveBeenCalled(); + }); + + it('should use i18n strings for the default accessible name', async () => { + expect(getInternals(button).ariaLabel).toBe('play media'); + + button.i18n = { pauseMedia: 'stop recording', playMedia: 'start recording' }; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('start recording'); + + button.checked = false; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('stop recording'); + }); + + it('should resolve commandForElement targets', async () => { + button.commandForElement = null; + button.commandForElement = target; + await elementIsStable(button); + + const event = untilEvent(target, 'command'); + await emulateClick(button); + const command = await event; + + expect(command.command).toBe(mediaCommands.togglePlayback); + expect(command.source).toBe(button); + }); + + it('should retarget and remove media state listeners', async () => { + const nextTarget: MediaStateTarget = Object.assign(document.createElement('div'), { + mediaState: createMediaState({ paused: false }) + }); + fixture.append(nextTarget); + button.commandForElement = null; + button.commandForElement = nextTarget; + await elementIsStable(button); + + expect(button.checked).toBe(false); + + setMediaState(target, { paused: true }); + expect(button.checked).toBe(false); + + setMediaState(nextTarget, { paused: true }); + expect(button.checked).toBe(true); + + button.remove(); + setMediaState(nextTarget, { paused: false }); + expect(button.checked).toBe(true); + }); + + it('should no-op for missing or empty commands', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let count = 0; + target.addEventListener('command', () => (count += 1)); + + button.commandForElement = null; + await elementIsStable(button); + await emulateClick(button); + + button.commandForElement = document.createElement('div'); + await elementIsStable(button); + await emulateClick(button); + + button.commandForElement = null; + button.setAttribute('commandfor', 'missing'); + await elementIsStable(button); + await emulateClick(button); + + button.setAttribute('commandfor', 'target'); + button.command = ''; + await elementIsStable(button); + await emulateClick(button); + + expect(count).toBe(0); + expect(warn).toHaveBeenCalledWith('commandForElement', 'missing', 'not found'); + warn.mockRestore(); + }); + + it('should submit checkbox-style form data', async () => { + button.checked = false; + await elementIsStable(button); + const formFixture = await createFixture(html` +
+ +
+ `); + const form = getElement(formFixture, 'form'); + + expect(new FormData(form).get('paused')).toBe('true'); + getElement(form, MediaPauseButton.metadata.tag).checked = false; + await elementIsStable(getElement(form, MediaPauseButton.metadata.tag)); + expect(new FormData(form).get('paused')).toBe(null); + removeFixture(formFixture); + }); + + it('should not dispatch commands when disabled or readonly', async () => { + let count = 0; + target.addEventListener('command', () => (count += 1)); + + button.disabled = true; + await elementIsStable(button); + expect(getInternals(button).ariaDisabled).toBe('true'); + expect(button.tabIndex).toBe(-1); + await emulateClick(button); + + button.disabled = false; + button.readOnly = true; + await elementIsStable(button); + expect(getInternals(button).role).toBe('none'); + expect(getInternals(button).ariaDisabled).toBe(null); + await emulateClick(button); + + expect(count).toBe(0); + }); + + it('should support keyboard activation and default-prevented clicks', async () => { + let count = 0; + target.addEventListener('command', () => (count += 1)); + + button.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'ArrowLeft' })); + button.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, code: 'Enter' })); + const click = new MouseEvent('click', { bubbles: true, cancelable: true }); + click.preventDefault(); + button.dispatchEvent(click); + + expect(count).toBe(1); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function getInternals(element: HTMLElement) { + return (element as HTMLElement & { _internals: ElementInternals })._internals; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/pause-button/pause-button.test.visual.ts b/projects/media/src/pause-button/pause-button.test.visual.ts new file mode 100644 index 0000000000..fd8c098417 --- /dev/null +++ b/projects/media/src/pause-button/pause-button.test.visual.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media pause button visual', () => { + test('media pause button should match visual baseline', async () => { + const report = await visualRunner.render('media-pause-button', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media pause button should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-pause-button.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + +
+ `; +} diff --git a/projects/media/src/pause-button/pause-button.ts b/projects/media/src/pause-button/pause-button.ts new file mode 100644 index 0000000000..0b5c7bab5e --- /dev/null +++ b/projects/media/src/pause-button/pause-button.ts @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { ButtonFormControlMixin, CheckboxFormControlMixin } from '@nvidia-elements/forms/mixins'; +import { I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { Icon } from '@nvidia-elements/core/icon'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import mediaButtonStyles from '../internal/media-button.css?inline'; +import styles from './pause-button.css?inline'; + +/** + * @element nve-media-pause-button + * @description Requests playback changes and submits paused media as a checkbox-style value. + * @documentation https://nvidia.github.io/elements/docs/media/pause-button/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/pause-button + * @slot - Optional custom icon content. + * @cssprop --background + * @cssprop --color + * @cssprop --border + * @cssprop --border-radius + * @cssprop --height + * @cssprop --width + * @cssprop --padding + * @csspart icon - The fallback icon element. + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/ + */ +@scopedRegistry() +export class MediaPauseButton extends CheckboxFormControlMixin(ButtonFormControlMixin(LitElement)) { + static styles = useStyles([mediaButtonStyles, styles]); + + static elementDefinitions = { + [Icon.metadata.tag]: Icon + }; + + static readonly metadata = { + tag: 'nve-media-pause-button', + version: '0.0.0' + }; + + override command: string = mediaCommands.togglePlayback; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + #mediaStateController = new MediaStateController(this, mediaState => this.#handleMediaState(mediaState)); + + render() { + return html` + + `; + } + + #onClick = () => this.toggle(); + + override connectedCallback() { + super.connectedCallback(); + this.addEventListener('click', this.#onClick, true); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.removeEventListener('click', this.#onClick, true); + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this._internals.ariaLabel = (this.checked ? this.i18n.playMedia : this.i18n.pauseMedia) ?? null; + } + + #handleMediaState = (state: MediaState | null) => { + if (state) { + this.checked = state.paused || state.ended; + } + }; +} diff --git a/projects/media/src/playback-rate-select/define.ts b/projects/media/src/playback-rate-select/define.ts new file mode 100644 index 0000000000..0df025344b --- /dev/null +++ b/projects/media/src/playback-rate-select/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaPlaybackRateSelect } from '@nvidia-elements/media/playback-rate-select'; + +define(MediaPlaybackRateSelect); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-playback-rate-select': MediaPlaybackRateSelect; + } +} diff --git a/projects/media/src/playback-rate-select/index.ts b/projects/media/src/playback-rate-select/index.ts new file mode 100644 index 0000000000..4571ad65aa --- /dev/null +++ b/projects/media/src/playback-rate-select/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './playback-rate-select.js'; diff --git a/projects/media/src/playback-rate-select/playback-rate-select.css b/projects/media/src/playback-rate-select/playback-rate-select.css new file mode 100644 index 0000000000..2ee0ba1666 --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.css @@ -0,0 +1,118 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ + +:host { + --nve-sys-interaction-state-base: var(--background, var(--nve-sys-layer-canvas-background)); + --background: transparent; + --color: var(--nve-sys-text-muted-color); + --border: 0; + --border-radius: var(--nve-ref-border-radius-xs); + --height: var(--nve-ref-size-800); + --padding: 0 var(--nve-ref-size-200); + --picker-background: var(--nve-sys-layer-overlay-background); + --picker-border: var(--nve-ref-border-width-sm) solid var(--nve-ref-border-color-muted); + --width: 32px; + --cursor: pointer; + display: block; + width: var(--width); +} + +[internal-host] { + --_picker-border-radius: var(--nve-ref-border-radius-md); + --_picker-box-shadow: var(--nve-ref-shadow-400); + --_picker-color: var(--nve-sys-layer-overlay-color); + --_picker-min-width: 170px; + --_picker-padding: var(--nve-ref-size-200); + --_option-border-radius: var(--nve-ref-border-radius-xs); + --_option-font-size: var(--nve-ref-font-size-100); + --_option-font-weight: var(--nve-ref-font-weight-medium); + --_option-min-height: var(--nve-ref-size-800); + --_option-padding: var(--nve-ref-size-200); + background: var(--background); + border-radius: var(--border-radius); + width: var(--width); +} + +select { + background-color: transparent; + border: var(--border); + border-radius: var(--border-radius); + block-size: var(--height); + line-height: var(--height) !important; + box-shadow: none; + color: var(--color); + cursor: var(--cursor); + font: inherit; + inline-size: 100%; + margin: 0; + max-inline-size: 100%; + min-inline-size: 0; + padding: var(--padding); +} + +:host(:hover:not([disabled], [readonly])) { + --color: var(--nve-sys-interaction-hover-color); +} + +@supports (appearance: base-select) { + select, + select::picker(select) { + appearance: base-select; + } + + select::picker(select) { + background: var(--picker-background); + border: var(--picker-border); + border-radius: var(--_picker-border-radius); + box-shadow: var(--_picker-box-shadow); + color: var(--_picker-color); + margin-block-start: var(--nve-ref-space-xxs); + min-inline-size: var(--_picker-min-width); + padding: var(--_picker-padding); + } + + select::picker-icon { + color: var(--color); + } + + select:open { + color: var(--nve-sys-interaction-selected-color); + } + + select::picker-icon { + display: none; + } +} + +option { + background: transparent; + border-radius: var(--_option-border-radius); + color: var(--_picker-color); + cursor: var(--cursor); + font-size: var(--_option-font-size); + font-weight: var(--_option-font-weight); + min-block-size: var(--_option-min-height); + padding: var(--_option-padding); + text-transform: initial; +} + +option:hover, +option:checked { + color: var(--nve-sys-interaction-selected-color); +} + +option::checkmark { + display: none; +} + +:host([disabled]), +:host([readonly]) { + --background: var(--nve-sys-interaction-disabled-background); + --color: var(--nve-sys-interaction-disabled-color); + --cursor: not-allowed; +} + +:host(:hover:not([disabled], [readonly], [pressed])) { + --color: var(--nve-sys-interaction-hover-color); + --nve-sys-interaction-state-ratio: var(--nve-sys-interaction-state-ratio-hover); +} diff --git a/projects/media/src/playback-rate-select/playback-rate-select.examples.ts b/projects/media/src/playback-rate-select/playback-rate-select.examples.ts new file mode 100644 index 0000000000..9d12700ed7 --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.examples.ts @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/PlaybackRateSelect', + component: 'nve-media-playback-rate-select' +}; + +/** + * @summary Playback rate select connected to a media controller. Use when playback speed should submit as form data and send media commands. + */ +export const Default = { + render: () => html` + + + + + + ` +}; + +/** + * @summary Disabled playback rate select. Use when playback speed should not be editable. + * @tags test-case + */ +export const Disabled = { + render: () => html` + + ` +}; diff --git a/projects/media/src/playback-rate-select/playback-rate-select.test.axe.ts b/projects/media/src/playback-rate-select/playback-rate-select.test.axe.ts new file mode 100644 index 0000000000..800e0b119f --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.test.axe.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaPlaybackRateSelect } from './playback-rate-select.js'; +import './define.js'; + +describe(MediaPlaybackRateSelect.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + + `); + await elementIsStable(fixture.querySelector(MediaPlaybackRateSelect.metadata.tag)); + + const results = await runAxe([MediaPlaybackRateSelect.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts b/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts new file mode 100644 index 0000000000..faa65806bc --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media playback rate select lighthouse', () => { + test('should pass lighthouse check', async () => { + const report = await lighthouseRunner.getReport('media-playback-rate-select', /* html */ ` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(35); + }); +}); diff --git a/projects/media/src/playback-rate-select/playback-rate-select.test.ssr.ts b/projects/media/src/playback-rate-select/playback-rate-select.test.ssr.ts new file mode 100644 index 0000000000..29a1fa1641 --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaPlaybackRateSelect } from './playback-rate-select.js'; +import './define.js'; + +describe(MediaPlaybackRateSelect.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaPlaybackRateSelect.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/playback-rate-select/playback-rate-select.test.ts b/projects/media/src/playback-rate-select/playback-rate-select.test.ts new file mode 100644 index 0000000000..aefe100379 --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.test.ts @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaPlaybackRateSelect } from './playback-rate-select.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaPlaybackRateSelect.metadata.tag, () => { + let fixture: HTMLElement; + let select: MediaPlaybackRateSelect; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + select = getElement(fixture, MediaPlaybackRateSelect.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { playbackRate: 1 }); + await elementIsStable(select); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaPlaybackRateSelect.metadata.tag)).toBeDefined(); + expect(select.tabIndex).toBe(-1); + expect(getElement(select.shadowRoot as ShadowRoot, 'select').tabIndex).toBe(0); + }); + + it('should default to normal playback speed', async () => { + const defaultFixture = await createFixture(html``); + const defaultSelect = getElement(defaultFixture, MediaPlaybackRateSelect.metadata.tag); + await elementIsStable(defaultSelect); + + expect(defaultSelect.value).toBe('1'); + expect(defaultSelect.selectedIndex).toBe(1); + + removeFixture(defaultFixture); + }); + + it('should expose native select form APIs', () => { + expect(select.type).toBe('select-one'); + expect(select.length).toBe(4); + expect(select.options.map(option => option.value)).toEqual(['0.5', '1', '1.5', '2']); + expect(select.value).toBe('1'); + expect(select.valueAsNumber).toBe(1); + expect(select.selectedIndex).toBe(1); + expect(select.selectedOptions.map(option => option.value)).toEqual(['1']); + }); + + it('should render array rates', async () => { + select.rates = [0.25, 0.5, 1, 2]; + await elementIsStable(select); + + expect(select.options.map(option => option.value)).toEqual(['0.25', '0.5', '1', '2']); + }); + + it('should use i18n strings for its accessible name and option text', async () => { + let nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + expect(nativeSelect.getAttribute('aria-label')).toBe('playback rate'); + expect(nativeSelect.options[0].text).toBe('0.5x'); + + select.i18n = { playbackRate: 'speed', playbackRateOption: '{rate} times' }; + await elementIsStable(select); + nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + + expect(nativeSelect.getAttribute('aria-label')).toBe('speed'); + expect(nativeSelect.options[0].text).toBe('0.5 times'); + }); + + it('should render transparent closed control styles', () => { + const nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + + expect(getComputedStyle(nativeSelect).backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(getComputedStyle(nativeSelect).borderTopWidth).toBe('0px'); + }); + + it('should sync value from the target controller', async () => { + const input = vi.fn(); + const change = vi.fn(); + select.addEventListener('input', input); + select.addEventListener('change', change); + + setMediaState(target, { playbackRate: 1.5 }); + await elementIsStable(select); + + expect(select.value).toBe('1.5'); + expect(input).not.toHaveBeenCalled(); + expect(change).not.toHaveBeenCalled(); + }); + + it('should dispatch playback-rate command for committed user changes', async () => { + const commandEvent = untilEvent(target, 'command'); + const nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + + nativeSelect.value = '2'; + nativeSelect.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + + const command = await commandEvent; + expect(command.command).toBe(mediaCommands.setPlaybackRate); + expect(command.source.value).toBe('2'); + expect(command.source.valueAsNumber).toBe(2); + }); + + it('should dispatch host input and change events for user changes', async () => { + const inputEvent = untilEvent(select, 'input'); + const changeEvent = untilEvent(select, 'change'); + const nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + + nativeSelect.value = '1.5'; + nativeSelect.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + nativeSelect.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + + expect(await inputEvent).toBeDefined(); + expect(await changeEvent).toBeDefined(); + }); + + it('should submit selected form data', async () => { + const formFixture = await createFixture(html` +
+ +
+ `); + const form = getElement(formFixture, 'form'); + const formSelect = getElement(form, MediaPlaybackRateSelect.metadata.tag); + await elementIsStable(formSelect); + + expect(formSelect.options.map(option => option.value)).toEqual(['0.25', '0.5', '1.5', '2']); + expect(new FormData(form).get('rate')).toBe('1.5'); + + formSelect.selectedIndex = 3; + await elementIsStable(formSelect); + expect(new FormData(form).get('rate')).toBe('2'); + + formSelect.formStateRestoreCallback('0.5'); + expect(formSelect.value).toBe('0.5'); + + formSelect.formStateRestoreCallback(null); + expect(formSelect.value).toBe('1.5'); + + formSelect.value = '2'; + formSelect.formResetCallback(); + expect(formSelect.value).toBe('1.5'); + + removeFixture(formFixture); + }); + + it('should validate required missing values', async () => { + select.required = true; + select.value = '3'; + await elementIsStable(select); + + expect(select.value).toBe(''); + expect(select.checkValidity()).toBe(false); + expect(select.validity.valueMissing).toBe(true); + }); + + it('should render readonly and disabled native select states', async () => { + select.readOnly = true; + await elementIsStable(select); + let nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + + expect(nativeSelect.disabled).toBe(true); + expect(nativeSelect.getAttribute('aria-readonly')).toBe('true'); + + select.readOnly = false; + select.disabled = true; + await elementIsStable(select); + nativeSelect = getElement(select.shadowRoot as ShadowRoot, 'select'); + + expect(nativeSelect.disabled).toBe(true); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/playback-rate-select/playback-rate-select.test.visual.ts b/projects/media/src/playback-rate-select/playback-rate-select.test.visual.ts new file mode 100644 index 0000000000..d0426a9535 --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.test.visual.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media playback rate select visual', () => { + test('media playback rate select should match visual baseline', async () => { + const report = await visualRunner.render('media-playback-rate-select', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media playback rate select should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-playback-rate-select.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + +
+ `; +} diff --git a/projects/media/src/playback-rate-select/playback-rate-select.ts b/projects/media/src/playback-rate-select/playback-rate-select.ts new file mode 100644 index 0000000000..35d8d47f97 --- /dev/null +++ b/projects/media/src/playback-rate-select/playback-rate-select.ts @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { ifDefined } from 'lit/directives/if-defined.js'; +import { I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { TypeCommandController } from '@nvidia-elements/forms/internal'; +import { SelectFormControlMixin } from '@nvidia-elements/forms/mixins'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import styles from './playback-rate-select.css?inline'; + +const defaultRates = [0.5, 1.0, 1.5, 2.0]; + +/** + * @element nve-media-playback-rate-select + * @description Selects native media playback rate and submits the selected rate as a string form value. + * @documentation https://nvidia.github.io/elements/docs/media/playback-rate-select/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/playback-rate-select + * @cssprop --background + * @cssprop --border + * @cssprop --border-radius + * @cssprop --color + * @cssprop --height + * @cssprop --padding + * @cssprop --picker-background + * @cssprop --picker-border + * @cssprop --width + * @aria https://developer.mozilla.org/en-US/docs/Web/HTML/Element/select + */ +export class MediaPlaybackRateSelect extends SelectFormControlMixin(LitElement) { + static styles = useStyles([styles]); + + static readonly metadata = { + tag: 'nve-media-playback-rate-select', + valueSchema: { + type: 'string' as const + }, + version: '0.0.0' + }; + + @property({ type: String, reflect: true }) command: string = mediaCommands.setPlaybackRate; + + @property({ type: String, attribute: 'commandfor', reflect: true }) commandfor: string | null = null; + + @property({ attribute: false }) commandForElement: HTMLElement | null = null; + + @property({ type: Array }) rates: Array = [...defaultRates]; // eslint-disable-line local/primitive-property -- Playback rate lists map directly to native select options. + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + protected readonly commandController = new TypeCommandController(this, { events: ['change'] }); + + #mediaStateController = new MediaStateController(this, mediaState => this.#handleMediaState(mediaState)); + + override get value() { + return this.hasAttribute('value') ? super.value : '1'; + } + + override set value(value: string) { + super.value = value; + } + + render() { + return html` +
+ +
+ `; + } + + override connectedCallback() { + super.connectedCallback(); + this.tabIndex = -1; + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.updateSelectState(); + this.checkValidity(); + } + + #onInput = (event: InputEvent) => { + event.stopPropagation(); + if (this.disabled || this.readOnly) { + return; + } + + this.value = (event.currentTarget as HTMLSelectElement).value; + this.dispatchInputEvent(); + }; + + #onChange = (event: Event) => { + event.stopPropagation(); + if (this.disabled || this.readOnly) { + return; + } + + this.value = (event.currentTarget as HTMLSelectElement).value; + this.dispatchChangeEvent(); + }; + + #handleMediaState = (state: MediaState | null) => { + if (!state) { + return; + } + + const playbackRate = state.playbackRate; + if (Number.isFinite(playbackRate) && playbackRate > 0) { + this.value = `${playbackRate}`; + } + }; + + get #label() { + return this.ariaLabel ?? this.i18n.playbackRate; + } +} + +function formatI18n(template: string | undefined, placeholder: string, value: string) { + return template?.replace(`{${placeholder}}`, value); +} + +function getRateOptions(rates: readonly unknown[] | null | undefined) { + const options = (Array.isArray(rates) ? rates : defaultRates) + .map(rate => Number(rate)) + .filter(rate => Number.isFinite(rate) && rate > 0) + .map(rate => `${rate}`); + + return Array.from(new Set(options.length ? options : ['1'])); +} diff --git a/projects/media/src/seek-button/define.ts b/projects/media/src/seek-button/define.ts new file mode 100644 index 0000000000..c6c3579097 --- /dev/null +++ b/projects/media/src/seek-button/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaSeekButton } from '@nvidia-elements/media/seek-button'; + +define(MediaSeekButton); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-seek-button': MediaSeekButton; + } +} diff --git a/projects/media/src/seek-button/index.ts b/projects/media/src/seek-button/index.ts new file mode 100644 index 0000000000..9b31c71cb1 --- /dev/null +++ b/projects/media/src/seek-button/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './seek-button.js'; diff --git a/projects/media/src/seek-button/seek-button.css b/projects/media/src/seek-button/seek-button.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/seek-button/seek-button.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/seek-button/seek-button.examples.ts b/projects/media/src/seek-button/seek-button.examples.ts new file mode 100644 index 0000000000..ba619f7ab0 --- /dev/null +++ b/projects/media/src/seek-button/seek-button.examples.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/SeekButton', + component: 'nve-media-seek-button' +}; + +/** + * @summary Seek buttons for common transport actions. Use separate buttons for predictable start, backward, forward, and end commands. + */ +export const Default = { + render: () => html` + + +
+ + + + +
+
+ ` +}; diff --git a/projects/media/src/seek-button/seek-button.test.axe.ts b/projects/media/src/seek-button/seek-button.test.axe.ts new file mode 100644 index 0000000000..3513717caa --- /dev/null +++ b/projects/media/src/seek-button/seek-button.test.axe.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaSeekButton } from './seek-button.js'; +import './define.js'; + +describe(MediaSeekButton.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + + + `); + await elementIsStable(fixture.querySelector(MediaSeekButton.metadata.tag)); + + const results = await runAxe([MediaSeekButton.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/seek-button/seek-button.test.lighthouse.ts b/projects/media/src/seek-button/seek-button.test.lighthouse.ts new file mode 100644 index 0000000000..0c316ae66c --- /dev/null +++ b/projects/media/src/seek-button/seek-button.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media seek button lighthouse report', () => { + test('media seek button should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-seek-button', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(35); + }); +}); diff --git a/projects/media/src/seek-button/seek-button.test.ssr.ts b/projects/media/src/seek-button/seek-button.test.ssr.ts new file mode 100644 index 0000000000..e073703f95 --- /dev/null +++ b/projects/media/src/seek-button/seek-button.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaSeekButton } from './seek-button.js'; +import './define.js'; + +describe(MediaSeekButton.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaSeekButton.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/seek-button/seek-button.test.ts b/projects/media/src/seek-button/seek-button.test.ts new file mode 100644 index 0000000000..25552b7c08 --- /dev/null +++ b/projects/media/src/seek-button/seek-button.test.ts @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, emulateClick, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { MediaSeekButton } from './seek-button.js'; +import './define.js'; + +describe(MediaSeekButton.metadata.tag, () => { + let fixture: HTMLElement; + let button: MediaSeekButton; + let target: HTMLElement; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + button = getElement(fixture, MediaSeekButton.metadata.tag); + target = getElement(fixture, '#target'); + await elementIsStable(button); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaSeekButton.metadata.tag)).toBeDefined(); + }); + + it('should derive command from action and expose valueAsNumber on the source', async () => { + const event = untilEvent(target, 'command'); + await emulateClick(button); + const command = await event; + + expect(command.command).toBe(mediaCommands.seekBackward); + expect(command.source.valueAsNumber).toBe(10); + }); + + it('should derive backward command when existing markup upgrades', async () => { + const tag = `late-media-seek-button-${crypto.randomUUID()}`; + removeFixture(fixture); + fixture = await createFixture(html`
`); + button = document.createElement(tag) as MediaSeekButton; + button.setAttribute('commandfor', 'late-target'); + button.setAttribute('action', 'backward'); + button.setAttribute('value', '10'); + fixture.prepend(button); + + customElements.define(tag, class extends MediaSeekButton {}); + target = getElement(fixture, '#late-target'); + await elementIsStable(button); + + expect(button.action).toBe('backward'); + + const event = untilEvent(target, 'command'); + await emulateClick(button); + const command = await event; + + expect(command.command).toBe(mediaCommands.seekBackward); + expect(command.source.valueAsNumber).toBe(10); + }); + + it('should sync valueAsNumber with value', async () => { + button.valueAsNumber = 12; + await elementIsStable(button); + + expect(button.value).toBe('12'); + expect(button.getAttribute('value')).toBe('12'); + expect(button.valueAsNumber).toBe(12); + }); + + it('should update derived command when action changes', async () => { + button.action = 'end'; + await elementIsStable(button); + + const event = untilEvent(target, 'command'); + await emulateClick(button); + expect((await event).command).toBe(mediaCommands.seekToEnd); + }); + + it('should preserve an explicit command attribute', async () => { + const commandFixture = await createFixture(html` + +
+ `); + const explicitButton = getElement(commandFixture, MediaSeekButton.metadata.tag); + const explicitTarget = getElement(commandFixture, '#seek-target'); + await elementIsStable(explicitButton); + + const event = untilEvent(explicitTarget, 'command'); + await emulateClick(explicitButton); + expect((await event).command).toBe(mediaCommands.seek); + removeFixture(commandFixture); + }); + + it('should update labels for every seek action', async () => { + button.action = 'start'; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('seek to start'); + expect(getElement(getShadowRoot(button), '[part="icon"]').getAttribute('name')).toBe('start'); + expect(getElement(getShadowRoot(button), '[part="icon"]').hasAttribute('direction')).toBe(false); + + button.action = 'backward'; + button.value = '5'; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('seek backward 5 seconds'); + + button.action = 'forward'; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('seek forward 5 seconds'); + + button.value = '10'; + await elementIsStable(button); + expect(getElement(getShadowRoot(button), '[part="icon"]').getAttribute('name')).toBe('fast-forward-10'); + expect(getElement(getShadowRoot(button), '[part="icon"]').hasAttribute('direction')).toBe(false); + + button.action = 'end'; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('seek to end'); + expect(getElement(getShadowRoot(button), '[part="icon"]').getAttribute('name')).toBe('start'); + expect(getElement(getShadowRoot(button), '[part="icon"]').getAttribute('direction')).toBe('down'); + + button.action = 'forward'; + await elementIsStable(button); + expect(getElement(getShadowRoot(button), '[part="icon"]').getAttribute('name')).toBe('fast-forward-10'); + expect(getElement(getShadowRoot(button), '[part="icon"]').hasAttribute('direction')).toBe(false); + }); + + it('should use i18n strings for the default accessible name', async () => { + button.i18n = { + seekBackward: 'rewind {value} seconds', + seekForward: 'advance {value} seconds', + seekToEnd: 'jump to finish', + seekToStart: 'jump to beginning' + }; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('rewind 10 seconds'); + + button.action = 'start'; + await elementIsStable(button); + expect(getInternals(button).ariaLabel).toBe('jump to beginning'); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function getInternals(element: HTMLElement) { + return (element as HTMLElement & { _internals: ElementInternals })._internals; +} + +function getShadowRoot(element: HTMLElement) { + return element.shadowRoot as ShadowRoot; +} diff --git a/projects/media/src/seek-button/seek-button.test.visual.ts b/projects/media/src/seek-button/seek-button.test.visual.ts new file mode 100644 index 0000000000..a019138a06 --- /dev/null +++ b/projects/media/src/seek-button/seek-button.test.visual.ts @@ -0,0 +1,32 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media seek button visual', () => { + test('media seek button should match visual baseline', async () => { + const report = await visualRunner.render('media-seek-button', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media seek button should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-seek-button.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + + +
+ `; +} diff --git a/projects/media/src/seek-button/seek-button.ts b/projects/media/src/seek-button/seek-button.ts new file mode 100644 index 0000000000..950451a20b --- /dev/null +++ b/projects/media/src/seek-button/seek-button.ts @@ -0,0 +1,169 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { ButtonFormControlMixin, type ButtonType } from '@nvidia-elements/forms/mixins'; +import { attachInternals, I18nController, scopedRegistry, useStyles } from '@nvidia-elements/core/internal'; +import { Icon } from '@nvidia-elements/core/icon'; +import { mediaCommands, type MediaCommand, type MediaSeekAction } from '../internal/media-command.js'; +import mediaButtonStyles from '../internal/media-button.css?inline'; +import styles from './seek-button.css?inline'; + +/** + * @element nve-media-seek-button + * @description Sends absolute or relative seek commands without adding form submission state. + * @documentation https://nvidia.github.io/elements/docs/media/seek-button/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/seek-button + * @slot - Optional custom icon content. + * @cssprop --background + * @cssprop --color + * @cssprop --border + * @cssprop --border-radius + * @cssprop --height + * @cssprop --width + * @cssprop --padding + * @csspart icon - The fallback icon element. + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/button/ + */ +@scopedRegistry() +export class MediaSeekButton extends ButtonFormControlMixin(LitElement) { + static styles = useStyles([mediaButtonStyles, styles]); + + static elementDefinitions = { + [Icon.metadata.tag]: Icon + }; + + static readonly metadata = { + tag: 'nve-media-seek-button', + version: '0.0.0' + }; + + @property({ type: String, reflect: true }) action: MediaSeekAction = 'forward'; + + override value = '10'; + + #hasExplicitCommand = false; + + #syncingDerivedCommand = false; + + #derivedCommand: MediaCommand = mediaCommands.seekForward; + + get valueAsNumber() { + return Number(this.value); + } + + set valueAsNumber(value: number) { + this.value = `${value}`; + } + + override type: ButtonType = 'button'; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + render() { + return html` + + `; + } + + get #iconName() { + return getSeekIcon(this.action, this.valueAsNumber); + } + + get #iconDirection() { + return this.action === 'end' ? 'down' : undefined; + } + + get #defaultAriaLabel() { + return getSeekLabel(this.action, this.valueAsNumber, this.i18n); + } + + override connectedCallback() { + this.#derivedCommand = getSeekCommand(this.action); + this.#hasExplicitCommand ||= + this.hasAttribute('command') || Boolean(this.command && this.command !== this.#derivedCommand); + if (!this.#hasExplicitCommand) { + this.#syncDerivedCommand(); + } + super.connectedCallback(); + } + + protected override willUpdate(changedProperties: PropertyValues) { + super.willUpdate(changedProperties); + + if ( + changedProperties.has('command') && + !changedProperties.has('action') && + !this.#syncingDerivedCommand && + this.command !== this.#derivedCommand + ) { + this.#hasExplicitCommand = true; + } + + if ( + !this.#hasExplicitCommand && + (changedProperties.has('action') || this.command !== getSeekCommand(this.action)) + ) { + this.#syncDerivedCommand(); + } + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.#syncDefaultAriaLabel(); + } + + #syncDefaultAriaLabel() { + attachInternals(this); + this._internals.ariaLabel = this.#defaultAriaLabel; + } + + #syncDerivedCommand() { + this.#derivedCommand = getSeekCommand(this.action); + this.#syncingDerivedCommand = true; + this.command = this.#derivedCommand; + this.#syncingDerivedCommand = false; + } +} + +function getSeekCommand(action: MediaSeekAction) { + return { + start: mediaCommands.seekToStart, + backward: mediaCommands.seekBackward, + forward: mediaCommands.seekForward, + end: mediaCommands.seekToEnd + }[action]; +} + +function getSeekIcon(action: MediaSeekAction, value: number) { + if (action === 'backward') { + return value === 10 ? 'rewind-10' : 'rewind'; + } + + if (action === 'forward') { + return value === 10 ? 'fast-forward-10' : 'fast-forward'; + } + + return 'start'; +} + +function getSeekLabel(action: MediaSeekAction, value: number, i18n: MediaSeekButton['i18n']) { + return ( + { + start: i18n.seekToStart, + backward: i18n.seekBackward?.replace('{value}', `${value}`), + forward: i18n.seekForward?.replace('{value}', `${value}`), + end: i18n.seekToEnd + }[action] ?? null + ); +} diff --git a/projects/media/src/time-range/define.ts b/projects/media/src/time-range/define.ts new file mode 100644 index 0000000000..964a6e27a2 --- /dev/null +++ b/projects/media/src/time-range/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaTimeRange } from '@nvidia-elements/media/time-range'; + +define(MediaTimeRange); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-time-range': MediaTimeRange; + } +} diff --git a/projects/media/src/time-range/index.ts b/projects/media/src/time-range/index.ts new file mode 100644 index 0000000000..9916593ece --- /dev/null +++ b/projects/media/src/time-range/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './time-range.js'; diff --git a/projects/media/src/time-range/time-range.css b/projects/media/src/time-range/time-range.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/time-range/time-range.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/time-range/time-range.examples.ts b/projects/media/src/time-range/time-range.examples.ts new file mode 100644 index 0000000000..ab60705981 --- /dev/null +++ b/projects/media/src/time-range/time-range.examples.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/TimeRange', + component: 'nve-media-time-range' +}; + +/** + * @summary Time range connected to a media controller. Use this control for finite recorded media that supports scrubbing. + */ +export const Default = { + render: () => html` + + + + + ` +}; diff --git a/projects/media/src/time-range/time-range.test.axe.ts b/projects/media/src/time-range/time-range.test.axe.ts new file mode 100644 index 0000000000..7fac21b1ae --- /dev/null +++ b/projects/media/src/time-range/time-range.test.axe.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaTimeRange } from './time-range.js'; +import './define.js'; + +describe(MediaTimeRange.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + `); + await elementIsStable(fixture.querySelector(MediaTimeRange.metadata.tag)); + + const results = await runAxe([MediaTimeRange.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/time-range/time-range.test.lighthouse.ts b/projects/media/src/time-range/time-range.test.lighthouse.ts new file mode 100644 index 0000000000..13eae50bef --- /dev/null +++ b/projects/media/src/time-range/time-range.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media time range lighthouse report', () => { + test('media time range should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-time-range', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(25); + }); +}); diff --git a/projects/media/src/time-range/time-range.test.ssr.ts b/projects/media/src/time-range/time-range.test.ssr.ts new file mode 100644 index 0000000000..11a94b444f --- /dev/null +++ b/projects/media/src/time-range/time-range.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaTimeRange } from './time-range.js'; +import './define.js'; + +describe(MediaTimeRange.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaTimeRange.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/time-range/time-range.test.ts b/projects/media/src/time-range/time-range.test.ts new file mode 100644 index 0000000000..03d6721643 --- /dev/null +++ b/projects/media/src/time-range/time-range.test.ts @@ -0,0 +1,166 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaTimeRange } from './time-range.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaTimeRange.metadata.tag, () => { + let fixture: HTMLElement; + let range: MediaTimeRange; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` + +
+ `); + range = getElement(fixture, MediaTimeRange.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { currentTime: 10, duration: 100 }); + await elementIsStable(range); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaTimeRange.metadata.tag)).toBeDefined(); + expect(range.tabIndex).toBe(-1); + expect(getElement(range.shadowRoot as ShadowRoot, 'input').tabIndex).toBe(0); + }); + + it('should sync value and bounds from the target controller', async () => { + expect(range.value).toBe(10); + expect(range.max).toBe(100); + + setMediaState(target, { currentTime: 0, duration: 0 }); + await elementIsStable(range); + expect(range.value).toBe(0); + expect(range.max).toBe(0); + + setMediaState(target, { currentTime: 25, duration: 120 }); + await elementIsStable(range); + + expect(range.value).toBe(25); + expect(range.max).toBe(120); + }); + + it('should use i18n strings for the default accessible name', async () => { + let input = getElement(range.shadowRoot as ShadowRoot, 'input'); + expect(input.getAttribute('aria-label')).toBe('current time'); + + range.i18n = { currentTime: 'recording position' }; + await elementIsStable(range); + input = getElement(range.shadowRoot as ShadowRoot, 'input'); + + expect(input.getAttribute('aria-label')).toBe('recording position'); + }); + + it('should show the duration when target media has ended', async () => { + setMediaState(target, { currentTime: 99, duration: 100, ended: true }); + await elementIsStable(range); + + expect(range.value).toBe(100); + }); + + it('should ignore missing target state', async () => { + range.commandfor = null; + await elementIsStable(range); + + expect(range.value).toBe(10); + expect(range.max).toBe(100); + }); + + it('should dispatch input, change, and seek command for user input', async () => { + const inputEvent = untilEvent(range, 'input'); + const changeEvent = untilEvent(range, 'change'); + const commandEvent = untilEvent(target, 'command'); + const input = getElement(range.shadowRoot as ShadowRoot, 'input'); + + input.value = '40'; + input.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + input.dispatchEvent(new Event('change', { bubbles: true, composed: true })); + + expect((await inputEvent).data).toBe('40'); + expect(await changeEvent).toBeTruthy(); + expect((await commandEvent).command).toBe(mediaCommands.seek); + }); + + it('should reach the duration when user input moves to the end', async () => { + setMediaState(target, { currentTime: 0, duration: 5.653333 }); + await elementIsStable(range); + const input = getElement(range.shadowRoot as ShadowRoot, 'input'); + + input.value = `${range.max}`; + input.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + await elementIsStable(range); + + expect(input.valueAsNumber).toBe(range.max); + expect(range.value).toBe(range.max); + expect(range.style.getPropertyValue('--track-progress')).toBe('1'); + const internalHost = getElement(range.shadowRoot as ShadowRoot, '[internal-host]'); + expect(getComputedStyle(internalHost, '::after').width).toBe(getComputedStyle(internalHost, '::before').width); + }); + + it('should not dispatch input or change during target synchronization', async () => { + let events = 0; + range.addEventListener('input', () => (events += 1)); + range.addEventListener('change', () => (events += 1)); + + setMediaState(target, { currentTime: 55, duration: 100 }); + await elementIsStable(range); + + expect(events).toBe(0); + }); + + it('should ignore user input while target media is unavailable', async () => { + let events = 0; + let commands = 0; + range.addEventListener('input', () => (events += 1)); + target.addEventListener('command', () => (commands += 1)); + + setMediaState(target, { duration: 0 }); + await elementIsStable(range); + const input = getElement(range.shadowRoot as ShadowRoot, 'input'); + input.valueAsNumber = 20; + input.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + + expect(events).toBe(0); + expect(commands).toBe(0); + }); + + it('should submit numeric form data and validate bounds', async () => { + const formFixture = await createFixture(html` +
+ +
+ `); + const form = getElement(formFixture, 'form'); + const formRange = getElement(form, MediaTimeRange.metadata.tag); + await elementIsStable(formRange); + expect(new FormData(form).get('currentTime')).toBe('5'); + formRange.value = 11; + await elementIsStable(formRange); + expect(formRange.checkValidity()).toBe(false); + removeFixture(formFixture); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/time-range/time-range.test.visual.ts b/projects/media/src/time-range/time-range.test.visual.ts new file mode 100644 index 0000000000..a17f49bdd0 --- /dev/null +++ b/projects/media/src/time-range/time-range.test.visual.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media time range visual', () => { + test('media time range should match visual baseline', async () => { + const report = await visualRunner.render('media-time-range', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media time range should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-time-range.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + + +
+ `; +} diff --git a/projects/media/src/time-range/time-range.ts b/projects/media/src/time-range/time-range.ts new file mode 100644 index 0000000000..35d80d50fd --- /dev/null +++ b/projects/media/src/time-range/time-range.ts @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { state } from 'lit/decorators/state.js'; +import { I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { TypeCommandController } from '@nvidia-elements/forms/internal'; +import { SliderFormControlMixin } from '@nvidia-elements/forms/mixins'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import mediaRangeStyles from '../internal/media-range.css?inline'; +import styles from './time-range.css?inline'; + +/** + * @element nve-media-time-range + * @description Scrubs finite media time and submits the current time as a numeric form value. + * @documentation https://nvidia.github.io/elements/docs/media/time-range/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/time-range + * @cssprop --background + * @cssprop --track-background + * @cssprop --track-height + * @cssprop --track-border-radius + * @cssprop --thumb-background + * @cssprop --thumb-border + * @cssprop --thumb-height + * @cssprop --thumb-width + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/slider/ + */ +export class MediaTimeRange extends SliderFormControlMixin(LitElement) { + static styles = useStyles([mediaRangeStyles, styles]); + + static override readonly sliderDefaults = { + max: 1, + step: 0.1 + }; + + static readonly metadata = { + tag: 'nve-media-time-range', + valueSchema: { + type: 'number' as const + }, + version: '0.0.0' + }; + + @property({ type: String, reflect: true }) command: string = mediaCommands.seek; + + @property({ type: String, attribute: 'commandfor', reflect: true }) commandfor: string | null = null; + + @property({ attribute: false }) commandForElement: HTMLElement | null = null; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + protected readonly commandController = new TypeCommandController(this, { events: ['input'] }); + + @state() protected mediaDisabled = false; + + #mediaStateController = new MediaStateController(this, mediaState => this.#handleMediaState(mediaState)); + + override connectedCallback() { + super.connectedCallback(); + this.tabIndex = -1; + } + + render() { + return html` +
+ +
+ `; + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.#syncTrackProgress(); + } + + override updateSliderState() { + super.updateSliderState(); + if (this.mediaDisabled) { + this._internals.setFormValue(null); + } + } + + #onInput = (event: InputEvent) => { + event.stopPropagation(); + if (this.mediaDisabled) { + return; + } + + this.valueAsNumber = (event.currentTarget as HTMLInputElement).valueAsNumber; + this.dispatchInputEvent(); + }; + + #onChange = (event: Event) => { + event.stopPropagation(); + this.dispatchChangeEvent(); + }; + + #syncTrackProgress() { + const range = this.max - this.min; + const progress = range > 0 ? (this.value - this.min) / range : 0; + this.style.setProperty('--track-progress', `${Math.min(1, Math.max(0, progress))}`); + } + + #handleMediaState = (mediaState: MediaState | null) => { + if (!mediaState) { + return; + } + + const duration = mediaState.duration; + this.mediaDisabled = !Number.isFinite(duration) || duration <= 0; + this.min = 0; + this.max = this.mediaDisabled ? 0 : duration; + this.valueAsNumber = mediaState.ended ? this.max : clamp(mediaState.currentTime, this.min, this.max); + }; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +function getInputStep(min: number, max: number, step: number) { + const range = max - min; + const intervals = Math.ceil(range / step); + return range > 0 && Number.isFinite(intervals) && intervals > 0 ? (range / intervals) * (1 - Number.EPSILON) : step; +} diff --git a/projects/media/src/volume-range/define.ts b/projects/media/src/volume-range/define.ts new file mode 100644 index 0000000000..9694f7ea5f --- /dev/null +++ b/projects/media/src/volume-range/define.ts @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { define } from '@nvidia-elements/core/internal'; +import { MediaVolumeRange } from '@nvidia-elements/media/volume-range'; + +define(MediaVolumeRange); + +declare global { + interface HTMLElementTagNameMap { + 'nve-media-volume-range': MediaVolumeRange; + } +} diff --git a/projects/media/src/volume-range/index.ts b/projects/media/src/volume-range/index.ts new file mode 100644 index 0000000000..efb64cc3b3 --- /dev/null +++ b/projects/media/src/volume-range/index.ts @@ -0,0 +1,4 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export * from './volume-range.js'; diff --git a/projects/media/src/volume-range/volume-range.css b/projects/media/src/volume-range/volume-range.css new file mode 100644 index 0000000000..4fc0cb99ef --- /dev/null +++ b/projects/media/src/volume-range/volume-range.css @@ -0,0 +1,2 @@ +/* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ +/* SPDX-License-Identifier: Apache-2.0 */ diff --git a/projects/media/src/volume-range/volume-range.examples.ts b/projects/media/src/volume-range/volume-range.examples.ts new file mode 100644 index 0000000000..4c8a677102 --- /dev/null +++ b/projects/media/src/volume-range/volume-range.examples.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; + +export default { + title: 'Media/VolumeRange', + component: 'nve-media-volume-range' +}; + +/** + * @summary Volume range connected to a media controller. Use the native media volume scale from 0 to 1 for form submission. + */ +export const Default = { + render: () => html` + + + + + ` +}; diff --git a/projects/media/src/volume-range/volume-range.test.axe.ts b/projects/media/src/volume-range/volume-range.test.axe.ts new file mode 100644 index 0000000000..51ae5464c0 --- /dev/null +++ b/projects/media/src/volume-range/volume-range.test.axe.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { createFixture, elementIsStable, removeFixture } from '@internals/testing'; +import { runAxe } from '@internals/testing/axe'; +import { MediaVolumeRange } from './volume-range.js'; +import './define.js'; + +describe(MediaVolumeRange.metadata.tag, () => { + it('should pass axe check', async () => { + const fixture = await createFixture(html` + + + `); + await elementIsStable(fixture.querySelector(MediaVolumeRange.metadata.tag)); + + const results = await runAxe([MediaVolumeRange.metadata.tag]); + expect(results.violations.length).toBe(0); + removeFixture(fixture); + }); +}); diff --git a/projects/media/src/volume-range/volume-range.test.lighthouse.ts b/projects/media/src/volume-range/volume-range.test.lighthouse.ts new file mode 100644 index 0000000000..e10001bd1c --- /dev/null +++ b/projects/media/src/volume-range/volume-range.test.lighthouse.ts @@ -0,0 +1,21 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { lighthouseRunner } from '@internals/vite'; + +describe('media volume range lighthouse report', () => { + test('media volume range should meet lighthouse benchmarks', async () => { + const report = await lighthouseRunner.getReport('nve-media-volume-range', /* html */` + + + `); + + expect(report.scores.performance).toBe(100); + expect(report.scores.accessibility).toBe(100); + expect(report.scores.bestPractices).toBe(100); + expect(report.payload.javascript.kb).toBeLessThan(25); + }); +}); diff --git a/projects/media/src/volume-range/volume-range.test.ssr.ts b/projects/media/src/volume-range/volume-range.test.ssr.ts new file mode 100644 index 0000000000..1322ad8b03 --- /dev/null +++ b/projects/media/src/volume-range/volume-range.test.ssr.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { describe, expect, it } from 'vitest'; +import { ssrRunner } from '@internals/vite'; +import { MediaVolumeRange } from './volume-range.js'; +import './define.js'; + +describe(MediaVolumeRange.metadata.tag, () => { + it('should pass baseline ssr check', async () => { + const result = await ssrRunner.render(html``); + expect(result.includes('shadowroot="open"')).toBe(true); + expect(result.includes(MediaVolumeRange.metadata.tag)).toBe(true); + }); +}); diff --git a/projects/media/src/volume-range/volume-range.test.ts b/projects/media/src/volume-range/volume-range.test.ts new file mode 100644 index 0000000000..8f75c45998 --- /dev/null +++ b/projects/media/src/volume-range/volume-range.test.ts @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { html } from 'lit'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createFixture, elementIsStable, removeFixture, untilEvent } from '@internals/testing'; +import { mediaCommands } from '../internal/media-command.js'; +import { createMediaState, mediaStateChange, type MediaState } from '../internal/media-state.js'; +import { MediaVolumeRange } from './volume-range.js'; +import './define.js'; + +type MediaStateTarget = HTMLElement & { mediaState: MediaState }; + +describe(MediaVolumeRange.metadata.tag, () => { + let fixture: HTMLElement; + let range: MediaVolumeRange; + let target: MediaStateTarget; + + beforeEach(async () => { + fixture = await createFixture(html` +
+ +
+
+ `); + range = getElement(fixture, MediaVolumeRange.metadata.tag); + target = getElement(fixture, '#target'); + setMediaState(target, { volume: 0.4 }); + await elementIsStable(range); + }); + + afterEach(() => { + removeFixture(fixture); + }); + + it('should define element', () => { + expect(customElements.get(MediaVolumeRange.metadata.tag)).toBeDefined(); + expect(range.tabIndex).toBe(-1); + expect(getElement(range.shadowRoot as ShadowRoot, 'input').tabIndex).toBe(0); + }); + + it('should sync value from the target controller', async () => { + const input = vi.fn(); + const change = vi.fn(); + range.addEventListener('input', input); + range.addEventListener('change', change); + expect(range.value).toBe(0.4); + + setMediaState(target, { volume: 1 }); + await elementIsStable(range); + expect(range.value).toBe(1); + + setMediaState(target, { volume: 0.8 }); + await elementIsStable(range); + expect(range.value).toBe(0.8); + expect(input).not.toHaveBeenCalled(); + expect(change).not.toHaveBeenCalled(); + }); + + it('should sync disabled state from the target controller', async () => { + const form = getElement(fixture, 'form'); + let input = getElement(range.shadowRoot as ShadowRoot, 'input'); + expect(input.disabled).toBe(false); + expect(new FormData(form).get('volume')).toBe('0.4'); + + setMediaState(target, { volume: Number.POSITIVE_INFINITY }); + await elementIsStable(range); + input = getElement(range.shadowRoot as ShadowRoot, 'input'); + expect(input.disabled).toBe(true); + expect(new FormData(form).get('volume')).toBe(null); + + setMediaState(target, { volume: 1 }); + await elementIsStable(range); + input = getElement(range.shadowRoot as ShadowRoot, 'input'); + expect(input.disabled).toBe(false); + expect(range.value).toBe(1); + expect(new FormData(form).get('volume')).toBe('1'); + }); + + it('should use i18n strings for the default accessible name', async () => { + let input = getElement(range.shadowRoot as ShadowRoot, 'input'); + expect(input.getAttribute('aria-label')).toBe('volume'); + + range.i18n = { volume: 'recording volume' }; + await elementIsStable(range); + input = getElement(range.shadowRoot as ShadowRoot, 'input'); + + expect(input.getAttribute('aria-label')).toBe('recording volume'); + }); + + it('should ignore missing target state', async () => { + range.commandfor = null; + await elementIsStable(range); + + expect(range.value).toBe(0.4); + }); + + it('should dispatch set-volume command for user input', async () => { + const commandEvent = untilEvent(target, 'command'); + const input = getElement(range.shadowRoot as ShadowRoot, 'input'); + + input.valueAsNumber = 0.6; + input.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })); + + const command = await commandEvent; + expect(command.command).toBe(mediaCommands.setVolume); + expect(command.source.valueAsNumber).toBe(0.6); + }); + + it('should submit numeric form data', async () => { + const formFixture = await createFixture(html` +
+ +
+ `); + const form = getElement(formFixture, 'form'); + const formRange = getElement(form, MediaVolumeRange.metadata.tag); + await elementIsStable(formRange); + expect(new FormData(form).get('volume')).toBe('0.7'); + removeFixture(formFixture); + }); + + it('should expose numeric form APIs and validation states', async () => { + const formFixture = await createFixture(html` +
+ +
+ `); + const form = getElement(formFixture, 'form'); + const formRange = getElement(form, MediaVolumeRange.metadata.tag); + await elementIsStable(formRange); + + expect(formRange.form).toBe(form); + expect(formRange.willValidate).toBe(true); + expect(formRange.validity.valid).toBe(true); + expect(formRange.validationMessage).toBe(''); + + formRange.valueAsNumber = 0.75; + await elementIsStable(formRange); + expect(new FormData(form).get('volume')).toBe('0.75'); + + formRange.valueAsNumber = -1; + await elementIsStable(formRange); + expect(formRange.checkValidity()).toBe(false); + expect(formRange.validity.rangeUnderflow).toBe(true); + + formRange.valueAsNumber = 2; + await elementIsStable(formRange); + expect(formRange.checkValidity()).toBe(false); + expect(formRange.validity.rangeOverflow).toBe(true); + + formRange.valueAsNumber = 0.3; + await elementIsStable(formRange); + expect(formRange.checkValidity()).toBe(false); + expect(formRange.validity.stepMismatch).toBe(true); + + formRange.valueAsNumber = Number.NaN; + await elementIsStable(formRange); + expect(formRange.checkValidity()).toBe(false); + expect(formRange.validity.valueMissing).toBe(true); + + formRange.step = 0; + formRange.valueAsNumber = 0.3; + await elementIsStable(formRange); + expect(formRange.reportValidity()).toBe(true); + + formRange.formStateRestoreCallback('0.25'); + expect(formRange.valueAsNumber).toBe(0.25); + formRange.formStateRestoreCallback(null); + expect(formRange.valueAsNumber).toBe(0.5); + + formRange.valueAsNumber = 0.75; + formRange.formResetCallback(); + expect(formRange.valueAsNumber).toBe(0.5); + + formRange.disabled = true; + await elementIsStable(formRange); + expect(getInternals(formRange).ariaDisabled).toBe('true'); + expect(new FormData(form).get('volume')).toBe(null); + + removeFixture(formFixture); + }); + + it('should render readonly range state', async () => { + range.readOnly = true; + await elementIsStable(range); + const input = getElement(range.shadowRoot as ShadowRoot, 'input'); + + expect(input.disabled).toBe(true); + expect(input.getAttribute('aria-readonly')).toBe('true'); + expect(getInternals(range).ariaDisabled).toBe(null); + }); +}); + +function getElement(root: ParentNode, selector: string) { + const element = root.querySelector(selector); + expect(element).toBeTruthy(); + return element as T; +} + +function getInternals(element: HTMLElement) { + return (element as HTMLElement & { _internals: ElementInternals })._internals; +} + +function setMediaState(target: MediaStateTarget, state: Partial) { + target.mediaState = createMediaState(state); + target.dispatchEvent(new CustomEvent(mediaStateChange, { detail: target.mediaState })); +} diff --git a/projects/media/src/volume-range/volume-range.test.visual.ts b/projects/media/src/volume-range/volume-range.test.visual.ts new file mode 100644 index 0000000000..175f06d241 --- /dev/null +++ b/projects/media/src/volume-range/volume-range.test.visual.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, test } from 'vitest'; +import { visualRunner } from '@internals/vite'; + +describe('media volume range visual', () => { + test('media volume range should match visual baseline', async () => { + const report = await visualRunner.render('media-volume-range', template()); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('media volume range should match visual baseline dark theme', async () => { + const report = await visualRunner.render('media-volume-range.dark', template('dark')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); +}); + +function template(theme: '' | 'dark' = '') { + return /* html */ ` + +
+ + +
+ `; +} diff --git a/projects/media/src/volume-range/volume-range.ts b/projects/media/src/volume-range/volume-range.ts new file mode 100644 index 0000000000..6c2e9e0d61 --- /dev/null +++ b/projects/media/src/volume-range/volume-range.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { PropertyValues } from 'lit'; +import { html, LitElement, nothing } from 'lit'; +import { property } from 'lit/decorators/property.js'; +import { state } from 'lit/decorators/state.js'; +import { I18nController, useStyles } from '@nvidia-elements/core/internal'; +import { TypeCommandController } from '@nvidia-elements/forms/internal'; +import { SliderFormControlMixin } from '@nvidia-elements/forms/mixins'; +import { MediaStateController } from '../internal/controllers/media-state.controller.js'; +import { mediaCommands } from '../internal/media-command.js'; +import type { MediaState } from '../internal/media-state.js'; +import mediaRangeStyles from '../internal/media-range.css?inline'; +import styles from './volume-range.css?inline'; + +/** + * @element nve-media-volume-range + * @description Sets native media volume and submits the current volume as a numeric form value. + * @documentation https://nvidia.github.io/elements/docs/media/volume-range/ + * @since 0.0.0 + * @entrypoint \@nvidia-elements/media/volume-range + * @cssprop --background + * @cssprop --track-background + * @cssprop --track-height + * @cssprop --track-border-radius + * @cssprop --thumb-background + * @cssprop --thumb-border + * @cssprop --thumb-height + * @cssprop --thumb-width + * @aria https://www.w3.org/WAI/ARIA/apg/patterns/slider/ + */ +export class MediaVolumeRange extends SliderFormControlMixin(LitElement) { + static styles = useStyles([mediaRangeStyles, styles]); + + static override readonly sliderDefaults = { + max: 1, + step: 0.01, + value: 1 + }; + + static readonly metadata = { + tag: 'nve-media-volume-range', + valueSchema: { + type: 'number' as const + }, + version: '0.0.0' + }; + + @property({ type: String, reflect: true }) command: string = mediaCommands.setVolume; + + @property({ type: String, attribute: 'commandfor', reflect: true }) commandfor: string | null = null; + + @property({ attribute: false }) commandForElement: HTMLElement | null = null; + + #i18nController: I18nController = new I18nController(this); + + /** Enables updating internal string values for internationalization. */ + @property({ type: Object }) i18n = this.#i18nController.i18n; + + protected readonly commandController = new TypeCommandController(this, { events: ['input'] }); + + @state() protected mediaDisabled = false; + + #mediaStateController = new MediaStateController(this, mediaState => this.#handleMediaState(mediaState)); + + override connectedCallback() { + super.connectedCallback(); + this.tabIndex = -1; + } + + render() { + return html` +
+ +
+ `; + } + + override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + this.#syncTrackProgress(); + } + + override updateSliderState() { + super.updateSliderState(); + if (this.mediaDisabled) { + this._internals.setFormValue(null); + } + } + + #onInput = (event: InputEvent) => { + event.stopPropagation(); + if (this.mediaDisabled) { + return; + } + + this.valueAsNumber = (event.currentTarget as HTMLInputElement).valueAsNumber; + this.dispatchInputEvent(); + }; + + #onChange = (event: Event) => { + event.stopPropagation(); + this.dispatchChangeEvent(); + }; + + #syncTrackProgress() { + const range = this.max - this.min; + const progress = range > 0 ? (this.value - this.min) / range : 0; + this.style.setProperty('--track-progress', `${Math.min(1, Math.max(0, progress))}`); + } + + #handleMediaState = (mediaState: MediaState | null) => { + if (!mediaState) { + return; + } + + this.mediaDisabled = !Number.isFinite(mediaState.volume); + this.valueAsNumber = clamp(mediaState.volume, this.min, this.max); + this.updateSliderState(); + }; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} diff --git a/projects/media/tsconfig.lib.json b/projects/media/tsconfig.lib.json index 7edb78e7ea..af21f6f61c 100644 --- a/projects/media/tsconfig.lib.json +++ b/projects/media/tsconfig.lib.json @@ -4,5 +4,12 @@ "rootDir": "./src", "types": [] }, - "exclude": ["**/*.test.ts", "**/*.test.axe.ts", "**/*.test.ssr.ts", "**/*.test.lighthouse.ts", "**/*.examples.ts"] + "exclude": [ + "**/*.test.ts", + "**/*.test.axe.ts", + "**/*.test.ssr.ts", + "**/*.test.visual.ts", + "**/*.test.lighthouse.ts", + "**/*.examples.ts" + ] } diff --git a/projects/media/vite.config.ts b/projects/media/vite.config.ts index 9df06e4f00..cfa90c95a1 100644 --- a/projects/media/vite.config.ts +++ b/projects/media/vite.config.ts @@ -1,4 +1,13 @@ -import { mergeConfig } from 'vite'; +import { resolve } from 'path'; +import { UserConfig, defineConfig, mergeConfig } from 'vite'; import { libraryBuildConfig } from '@internals/vite'; -export default mergeConfig(libraryBuildConfig, {}); +export default defineConfig(() => { + const config: UserConfig = { + resolve: { + alias: { '@nvidia-elements/media': resolve(import.meta.dirname, './src') } + } + }; + + return mergeConfig(libraryBuildConfig, config); +}); diff --git a/projects/media/vitest.ssr.ts b/projects/media/vitest.ssr.ts new file mode 100644 index 0000000000..150f88e443 --- /dev/null +++ b/projects/media/vitest.ssr.ts @@ -0,0 +1,15 @@ +import { resolve } from 'path'; +import { mergeConfig } from 'vitest/config'; +import { libraryLitSSRTestConfig } from '@internals/vite/configs/ssr.js'; + +export default mergeConfig(libraryLitSSRTestConfig, { + resolve: { + alias: { '@nvidia-elements/media': resolve(import.meta.dirname, './dist') } + }, + test: { + include: ['./src/**/*.test.ssr.ts'], + outputFile: { + junit: './coverage/ssr/junit.xml' + } + } +}); diff --git a/projects/media/vitest.visual.html b/projects/media/vitest.visual.html new file mode 100644 index 0000000000..9f772650a6 --- /dev/null +++ b/projects/media/vitest.visual.html @@ -0,0 +1,35 @@ + + + + + + + + Visual + + + + diff --git a/projects/media/vitest.visual.ts b/projects/media/vitest.visual.ts new file mode 100644 index 0000000000..58fed0d07f --- /dev/null +++ b/projects/media/vitest.visual.ts @@ -0,0 +1,12 @@ +import { mergeConfig } from 'vitest/config'; +import { libraryVisualTestConfig } from '@internals/vite/configs/visual.js'; + +export default mergeConfig(libraryVisualTestConfig, { + root: import.meta.dirname, + test: { + include: ['src/**/*.test.visual.ts'], + outputFile: { + junit: './coverage/visual/junit.xml' + } + } +}); diff --git a/projects/site/public/static/video/particle.mp4 b/projects/site/public/static/video/particle.mp4 new file mode 100644 index 0000000000..82c1189aef Binary files /dev/null and b/projects/site/public/static/video/particle.mp4 differ diff --git a/projects/site/src/_11ty/layouts/common.js b/projects/site/src/_11ty/layouts/common.js index de8027b741..af03d7be3d 100644 --- a/projects/site/src/_11ty/layouts/common.js +++ b/projects/site/src/_11ty/layouts/common.js @@ -342,6 +342,18 @@ export const renderDocsNav = data => /* html */ ` Trend + + Media + Controller + Pause Button + Playback Rate Select + Mute Button + Seek Button + Time Range + Volume Range + Full-screen Button + + Code Codeblock diff --git a/projects/site/src/_11ty/layouts/docs.css b/projects/site/src/_11ty/layouts/docs.css index 67fc187e3a..39770ca711 100644 --- a/projects/site/src/_11ty/layouts/docs.css +++ b/projects/site/src/_11ty/layouts/docs.css @@ -415,3 +415,7 @@ hr { display: block; } } + +video[src*='particle.mp4'] { + max-width: 600px; +} diff --git a/projects/site/src/docs/media/controller.md b/projects/site/src/docs/media/controller.md new file mode 100644 index 0000000000..6c3a057719 --- /dev/null +++ b/projects/site/src/docs/media/controller.md @@ -0,0 +1,47 @@ +--- +{ + title: 'Media Controller', + layout: 'docs.11ty.js', + tag: 'nve-media-controller', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-controller' %} + +## Usage + +`nve-media-controller` is the command target for a consumer-owned `video` or `audio` element. Put the media element in the default slot and keep sources, tracks, posters, captions, and framework bindings on the native element. + +```html + + + +``` + +## Form Values + +{% example '@nvidia-elements/media/controller/controller.examples.json', 'FormValues' %} + +## Card + +{% example '@nvidia-elements/media/controller/controller.examples.json', 'Card' %} + +## State + +Read the latest immutable state from `mediaState`. The state includes `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `currentTime`, `duration`, `volume`, and `playbackRate`. + +The controller dispatches `media-state-change` when that snapshot changes. The event detail contains the complete state snapshot and does not bubble. Listen on the controller directly. + +```js +const controller = document.querySelector('nve-media-controller'); + +console.log(controller.mediaState); +controller.addEventListener('media-state-change', event => console.log(event.detail)); +``` + +The controller continues to reflect `paused`, `muted`, `ended`, `seeking`, `fullscreen`, `current-time`, `duration`, `volume`, and `playback-rate` for styling and inspection. The native media element still emits events such as `play`, `pause`, `timeupdate`, `durationchange`, `volumechange`, and `ratechange`. + +The controller does not include `src`, `poster`, `autoplay`, track, or caption APIs. Use native media markup for media ownership and compose the Elements controls around it. diff --git a/projects/site/src/docs/media/fullscreen-button.md b/projects/site/src/docs/media/fullscreen-button.md new file mode 100644 index 0000000000..50456f89b2 --- /dev/null +++ b/projects/site/src/docs/media/fullscreen-button.md @@ -0,0 +1,29 @@ +--- +{ + title: 'Media Full-screen Button', + layout: 'docs.11ty.js', + tag: 'nve-media-fullscreen-button', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-fullscreen-button' %} + +## Usage + +`nve-media-fullscreen-button` sends `--toggle-fullscreen` by default. It syncs `pressed` from the target controller's `mediaState` events. + +```html + + + + +``` + +## Behavior + +The button requests full-screen mode on the controller element, not on the media element. This keeps composed controls inside the full-screen region. + +This control is command-only. It does not submit form values. diff --git a/projects/site/src/docs/media/mute-button.md b/projects/site/src/docs/media/mute-button.md new file mode 100644 index 0000000000..9ff139917c --- /dev/null +++ b/projects/site/src/docs/media/mute-button.md @@ -0,0 +1,29 @@ +--- +{ + title: 'Media Mute Button', + layout: 'docs.11ty.js', + tag: 'nve-media-mute-button', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-mute-button' %} + +## Usage + +`nve-media-mute-button` sends `--toggle-mute` by default. It syncs `checked` from the target controller's `mediaState` events. + +```html + + + + +``` + +## Form Behavior + +The mute button follows checkbox form submission. When `checked`, it submits its `value`. When unchecked, it contributes no form value. + +Use this control when a form should capture the current muted state with other media review or playback settings. diff --git a/projects/site/src/docs/media/pause-button.md b/projects/site/src/docs/media/pause-button.md new file mode 100644 index 0000000000..84c551e961 --- /dev/null +++ b/projects/site/src/docs/media/pause-button.md @@ -0,0 +1,29 @@ +--- +{ + title: 'Media Pause Button', + layout: 'docs.11ty.js', + tag: 'nve-media-pause-button', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-pause-button' %} + +## Usage + +`nve-media-pause-button` sends `--toggle-play` by default. It syncs `checked` from the target controller's `mediaState` events. Paused or ended media sets the button to its checked play state. + +```html + + + + +``` + +## Form Behavior + +The pause button follows checkbox form submission. When `checked`, it submits its `value`. When unchecked, it contributes no form value. + +Use `required` when the form accepts paused playback only. Use `readonly` when the button should display playback state without sending commands. diff --git a/projects/site/src/docs/media/playback-rate-select.md b/projects/site/src/docs/media/playback-rate-select.md new file mode 100644 index 0000000000..0b8c98b6fc --- /dev/null +++ b/projects/site/src/docs/media/playback-rate-select.md @@ -0,0 +1,45 @@ +--- +{ + title: 'Media Playback Rate Select', + layout: 'docs.11ty.js', + tag: 'nve-media-playback-rate-select', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-playback-rate-select' %} + +## Usage + +`nve-media-playback-rate-select` sends `--set-playback-rate` when the user commits a selected rate. It uses an internal native `select` and syncs from the target controller's `mediaState` events. + +```html + + +
+ + + +
+
+``` + +## Rates + +Use the `rates` property to provide positive numeric options. In HTML, pass a JSON array attribute. + +```html + +``` + +## Form Behavior + +The playback rate select submits the selected string value when it has a `name`. Use `readonly` to display the current rate without allowing user changes or command dispatch. + +Controller-driven synchronization does not dispatch `input` or `change`. Those events come from user interaction. diff --git a/projects/site/src/docs/media/seek-button.md b/projects/site/src/docs/media/seek-button.md new file mode 100644 index 0000000000..c3d4d3b3b7 --- /dev/null +++ b/projects/site/src/docs/media/seek-button.md @@ -0,0 +1,41 @@ +--- +{ + title: 'Media Seek Button', + layout: 'docs.11ty.js', + tag: 'nve-media-seek-button', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-seek-button' %} + +## Usage + +`nve-media-seek-button` sends absolute or relative seek commands. Use `action` for the seek intent and `value` for relative movement. +The `value` property follows native button string semantics. `valueAsNumber` provides the numeric command payload. + +```html + + + +
+ + + + +
+
+``` + +## Actions + +| Action | Command | Behavior | +| ---------- | ----------------- | --------------------------------- | +| `start` | `--seek-start` | Move playback to zero seconds. | +| `backward` | `--seek-backward` | Move playback earlier. | +| `forward` | `--seek-forward` | Move playback later. | +| `end` | `--seek-end` | Move playback to finite duration. | + +This control is command-only. It does not submit form values. diff --git a/projects/site/src/docs/media/time-range.md b/projects/site/src/docs/media/time-range.md new file mode 100644 index 0000000000..aa1def5832 --- /dev/null +++ b/projects/site/src/docs/media/time-range.md @@ -0,0 +1,29 @@ +--- +{ + title: 'Media Time Range', + layout: 'docs.11ty.js', + tag: 'nve-media-time-range', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-time-range' %} + +## Usage + +`nve-media-time-range` sends `--seek` on user input and syncs its value and bounds from the target controller's `mediaState` events. + +```html + + + + +``` + +## Form Behavior + +The time range submits its current numeric value when it has a `name`. It exposes `valueAsNumber`, `min`, `max`, and `step` using range-control semantics. + +Controller-driven synchronization does not dispatch `input` or `change`. Those events come from user interaction. diff --git a/projects/site/src/docs/media/volume-range.md b/projects/site/src/docs/media/volume-range.md new file mode 100644 index 0000000000..e65fdf34d9 --- /dev/null +++ b/projects/site/src/docs/media/volume-range.md @@ -0,0 +1,29 @@ +--- +{ + title: 'Media Volume Range', + layout: 'docs.11ty.js', + tag: 'nve-media-volume-range', + hideExamplesTab: true +} +--- + +## Installation + +{% install 'nve-media-volume-range' %} + +## Usage + +`nve-media-volume-range` sends `--set-volume` on user input and syncs its value from the target controller's `mediaState` events. The value uses the native media volume scale from `0` to `1`. + +```html + + + + +``` + +## Form Behavior + +The volume range submits its current numeric value when it has a `name`. Use `readonly` to display the current volume without letting the user send commands. + +Controller-driven synchronization does not dispatch `input` or `change`. Those events come from user interaction.