From be4d5a11550d6cc599a7ac491a9422ee923511b4 Mon Sep 17 00:00:00 2001 From: Sam Potts Date: Wed, 1 Jul 2026 12:27:27 +1000 Subject: [PATCH] feat(packages): add time display toggle (#1669) --- .../src/core/ui/time/tests/time-core.test.ts | 85 ++++- packages/core/src/core/ui/time/time-core.ts | 36 ++- .../src/define/audio/minimal-skin.tailwind.ts | 2 +- .../html/src/define/audio/minimal-skin.ts | 2 +- .../html/src/define/audio/skin.tailwind.ts | 2 +- packages/html/src/define/audio/skin.ts | 2 +- .../src/define/video/minimal-skin.tailwind.ts | 2 +- .../html/src/define/video/minimal-skin.ts | 2 +- .../html/src/define/video/skin.tailwind.ts | 2 +- packages/html/src/define/video/skin.ts | 2 +- .../src/ui/time/tests/time-element.test.ts | 299 ++++++++++++++++++ packages/html/src/ui/time/time-element.ts | 88 +++++- .../presets/audio/minimal-skin.tailwind.tsx | 2 +- .../react/src/presets/audio/minimal-skin.tsx | 2 +- .../react/src/presets/audio/skin.tailwind.tsx | 2 +- packages/react/src/presets/audio/skin.tsx | 2 +- .../presets/video/minimal-skin.tailwind.tsx | 2 +- .../react/src/presets/video/minimal-skin.tsx | 2 +- .../react/src/presets/video/skin.tailwind.tsx | 2 +- packages/react/src/presets/video/skin.tsx | 2 +- .../react/src/ui/time/tests/time.test.tsx | 185 +++++++++++ packages/react/src/ui/time/time-value.tsx | 44 ++- .../skins/src/default/css/components/time.css | 15 + .../src/default/tailwind/components/time.ts | 3 +- .../skins/src/minimal/css/components/time.css | 20 ++ .../src/minimal/tailwind/components/time.ts | 9 +- site/src/content/docs/reference/time.mdx | 22 ++ 27 files changed, 790 insertions(+), 48 deletions(-) create mode 100644 packages/html/src/ui/time/tests/time-element.test.ts create mode 100644 packages/react/src/ui/time/tests/time.test.tsx diff --git a/packages/core/src/core/ui/time/tests/time-core.test.ts b/packages/core/src/core/ui/time/tests/time-core.test.ts index 7122ede5..190803fb 100644 --- a/packages/core/src/core/ui/time/tests/time-core.test.ts +++ b/packages/core/src/core/ui/time/tests/time-core.test.ts @@ -20,6 +20,7 @@ describe('TimeCore', () => { core.setMedia(createMediaState()); const state = core.getState(); expect(state.type).toBe('current'); + expect(TimeCore.defaultProps.toggle).toBe(false); }); it('accepts custom props', () => { @@ -103,21 +104,21 @@ describe('TimeCore', () => { const core = new TimeCore({ type: 'current' }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('Current time'); + expect(core.getLabel(state)).toBe('1 minute, 30 seconds'); }); it('returns default label for duration', () => { const core = new TimeCore({ type: 'duration' }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('Duration'); + expect(core.getLabel(state)).toBe('5 minutes'); }); it('returns default label for remaining', () => { const core = new TimeCore({ type: 'remaining' }); core.setMedia(createMediaState()); const state = core.getState(); - expect(core.getLabel(state)).toBe('Remaining'); + expect(core.getLabel(state)).toBe('3 minutes, 30 seconds remaining'); }); it('returns custom string label', () => { @@ -136,27 +137,93 @@ describe('TimeCore', () => { const state = core.getState(); expect(core.getLabel(state)).toBe('Time: 1:30'); }); + + it('returns toggle label for current', () => { + const core = new TimeCore({ type: 'current', toggle: true }); + core.setMedia(createMediaState()); + const state = core.getState(); + expect(core.getLabel(state)).toBe('1 minute, 30 seconds. Show remaining time.'); + }); + + it('returns toggle label for remaining', () => { + const core = new TimeCore({ type: 'remaining', toggle: true }); + core.setMedia(createMediaState()); + const state = core.getState(); + expect(core.getLabel(state)).toBe('3 minutes, 30 seconds remaining. Show duration.'); + }); + + it('returns elapsed action when remaining toggles from current', () => { + const core = new TimeCore({ type: 'remaining', toggle: true }); + core.setMedia(createMediaState()); + const state = core.getState(); + expect(core.getLabel(state, 'current')).toBe('3 minutes, 30 seconds remaining. Show elapsed time.'); + }); }); describe('getAttrs', () => { - it('returns aria attributes', () => { + it('returns aria-label', () => { const core = new TimeCore({ type: 'current' }); core.setMedia(createMediaState({ currentTime: 90 })); const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-label']).toBe('Current time'); - expect(attrs['aria-valuetext']).toBe('1 minute, 30 seconds'); + expect(attrs['aria-label']).toBe('1 minute, 30 seconds'); + expect(attrs).not.toHaveProperty('aria-valuetext'); }); - it('includes remaining suffix in valuetext', () => { + it('includes remaining suffix in label', () => { const core = new TimeCore({ type: 'remaining' }); core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); const state = core.getState(); const attrs = core.getAttrs(state); - expect(attrs['aria-label']).toBe('Remaining'); - expect(attrs['aria-valuetext']).toBe('3 minutes, 30 seconds remaining'); + expect(attrs['aria-label']).toBe('3 minutes, 30 seconds remaining'); + expect(attrs).not.toHaveProperty('aria-valuetext'); + }); + + it('returns button attributes when current time is toggleable', () => { + const core = new TimeCore({ type: 'current', toggle: true }); + core.setMedia(createMediaState({ currentTime: 90 })); + const state = core.getState(); + const attrs = core.getAttrs(state); + + expect(attrs.role).toBe('button'); + expect(attrs.tabIndex).toBe(0); + expect(attrs['aria-label']).toBe('1 minute, 30 seconds. Show remaining time.'); + expect(attrs).not.toHaveProperty('aria-valuetext'); + }); + + it('returns button attributes when remaining time is toggleable', () => { + const core = new TimeCore({ type: 'remaining', toggle: true }); + core.setMedia(createMediaState({ currentTime: 90, duration: 300 })); + const state = core.getState(); + const attrs = core.getAttrs(state, 'current'); + + expect(attrs.role).toBe('button'); + expect(attrs.tabIndex).toBe(0); + expect(attrs['aria-label']).toBe('3 minutes, 30 seconds remaining. Show elapsed time.'); + expect(attrs).not.toHaveProperty('aria-valuetext'); + }); + + it('returns button attributes when duration is toggleable', () => { + const core = new TimeCore({ type: 'duration', toggle: true }); + core.setMedia(createMediaState({ duration: 300 })); + const state = core.getState(); + const attrs = core.getAttrs(state); + + expect(attrs.role).toBe('button'); + expect(attrs.tabIndex).toBe(0); + expect(attrs['aria-label']).toBe('5 minutes. Show remaining time.'); + }); + + it('does not return button attributes without toggle', () => { + const core = new TimeCore({ type: 'duration' }); + core.setMedia(createMediaState({ duration: 300 })); + const state = core.getState(); + const attrs = core.getAttrs(state); + + expect(attrs.role).toBeUndefined(); + expect(attrs.tabIndex).toBeUndefined(); }); }); }); diff --git a/packages/core/src/core/ui/time/time-core.ts b/packages/core/src/core/ui/time/time-core.ts index 818c402e..de2b17b1 100644 --- a/packages/core/src/core/ui/time/time-core.ts +++ b/packages/core/src/core/ui/time/time-core.ts @@ -15,6 +15,8 @@ export interface TimeProps { negativeSign?: string | undefined; /** Custom label for accessibility. */ label?: string | ((state: TimeState) => string) | undefined; + /** Whether the time display can be toggled. */ + toggle?: boolean | undefined; } export interface TimeState { @@ -32,10 +34,10 @@ export interface TimeState { datetime: string; } -const DEFAULT_LABELS: Record = { - current: 'Current time', - duration: 'Duration', - remaining: 'Remaining', +const TOGGLE_LABELS: Record = { + current: 'Show elapsed time', + duration: 'Show duration', + remaining: 'Show remaining time', }; export class TimeCore { @@ -43,6 +45,7 @@ export class TimeCore { type: 'current', negativeSign: '-', label: '', + toggle: false, }; #props = { ...TimeCore.defaultProps }; @@ -98,7 +101,15 @@ export class TimeCore { return secondsToIsoDuration(Math.abs(seconds)); } - getLabel(state: TimeState): string { + #getToggleType(type: TimeType, currentType: TimeType): TimeType { + if (type === 'current') { + return currentType === 'remaining' ? 'current' : 'remaining'; + } + + return currentType === 'duration' ? 'remaining' : 'duration'; + } + + getLabel(state: TimeState, type = this.#props.type): string { const { label } = this.#props; if (isFunction(label)) { @@ -108,13 +119,20 @@ export class TimeCore { return label; } - return DEFAULT_LABELS[this.#props.type]; + if (!this.#props.toggle) { + return state.phrase; + } + + const toggleType = this.#getToggleType(type, state.type); + + return `${state.phrase}. ${TOGGLE_LABELS[toggleType]}.`; } - getAttrs(state: TimeState) { + getAttrs(state: TimeState, type = this.#props.type) { return { - 'aria-label': this.getLabel(state), - 'aria-valuetext': state.phrase, + 'aria-label': this.getLabel(state, type), + role: this.#props.toggle ? 'button' : undefined, + tabIndex: this.#props.toggle ? 0 : undefined, }; } diff --git a/packages/html/src/define/audio/minimal-skin.tailwind.ts b/packages/html/src/define/audio/minimal-skin.tailwind.ts index b95b00cd..082a45ec 100644 --- a/packages/html/src/define/audio/minimal-skin.tailwind.ts +++ b/packages/html/src/define/audio/minimal-skin.tailwind.ts @@ -89,7 +89,7 @@ function getTemplateHTML() {
- + diff --git a/packages/html/src/define/audio/minimal-skin.ts b/packages/html/src/define/audio/minimal-skin.ts index 08560616..91bf64cd 100644 --- a/packages/html/src/define/audio/minimal-skin.ts +++ b/packages/html/src/define/audio/minimal-skin.ts @@ -71,7 +71,7 @@ function getTemplateHTML() {
- + diff --git a/packages/html/src/define/audio/skin.tailwind.ts b/packages/html/src/define/audio/skin.tailwind.ts index a846fd53..f7be44e6 100644 --- a/packages/html/src/define/audio/skin.tailwind.ts +++ b/packages/html/src/define/audio/skin.tailwind.ts @@ -99,7 +99,7 @@ function getTemplateHTML() { - +
diff --git a/packages/html/src/define/audio/skin.ts b/packages/html/src/define/audio/skin.ts index 1cf9d510..e6c1c14f 100644 --- a/packages/html/src/define/audio/skin.ts +++ b/packages/html/src/define/audio/skin.ts @@ -81,7 +81,7 @@ function getTemplateHTML() { - +
diff --git a/packages/html/src/define/video/minimal-skin.tailwind.ts b/packages/html/src/define/video/minimal-skin.tailwind.ts index ab52c727..7e347955 100644 --- a/packages/html/src/define/video/minimal-skin.tailwind.ts +++ b/packages/html/src/define/video/minimal-skin.tailwind.ts @@ -97,7 +97,7 @@ function getTemplateHTML() {
- + diff --git a/packages/html/src/define/video/minimal-skin.ts b/packages/html/src/define/video/minimal-skin.ts index 33007780..16cecf30 100644 --- a/packages/html/src/define/video/minimal-skin.ts +++ b/packages/html/src/define/video/minimal-skin.ts @@ -74,7 +74,7 @@ function getTemplateHTML() {
- + diff --git a/packages/html/src/define/video/skin.tailwind.ts b/packages/html/src/define/video/skin.tailwind.ts index 10688540..4bc7ef7f 100644 --- a/packages/html/src/define/video/skin.tailwind.ts +++ b/packages/html/src/define/video/skin.tailwind.ts @@ -113,7 +113,7 @@ function getTemplateHTML() { - +
diff --git a/packages/html/src/ui/time/tests/time-element.test.ts b/packages/html/src/ui/time/tests/time-element.test.ts new file mode 100644 index 00000000..d5977ee6 --- /dev/null +++ b/packages/html/src/ui/time/tests/time-element.test.ts @@ -0,0 +1,299 @@ +import type { MediaTimeState } from '@videojs/core'; +import type { AnyPlayerStore } from '@videojs/core/dom'; +import { ContextProvider } from '@videojs/element/context'; +import { createStore } from '@videojs/store'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { playerContext } from '../../../player/context'; +import { MediaElement } from '../../media-element'; +import { TimeElement } from '../time-element'; + +let tagCounter = 0; + +function uniqueTag(base: string): string { + return `${base}-${tagCounter++}`; +} + +function createElement(Base: abstract new () => Element): Element { + const tag = uniqueTag('test-el'); + customElements.define(tag, class extends (Base as unknown as typeof HTMLElement) {}); + return document.createElement(tag) as Element; +} + +function defineElement(tagName: string, Base: CustomElementConstructor): void { + if (!customElements.get(tagName)) { + customElements.define(tagName, Base); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} + +async function waitForAssertion(assertion: () => void): Promise { + let error: unknown; + + for (let index = 0; index < 10; index++) { + try { + assertion(); + return; + } catch (caught) { + error = caught; + await nextFrame(); + } + } + + throw error; +} + +function createTimeStore(): AnyPlayerStore { + return createStore()({ + name: 'time', + state: () => ({ + currentTime: 90, + duration: 300, + seeking: false, + seek: vi.fn(), + }), + }) as unknown as AnyPlayerStore; +} + +class TestPlayerProviderElement extends MediaElement { + store: AnyPlayerStore = createTimeStore(); + + readonly #provider = new ContextProvider(this, { context: playerContext }); + + setStore(store: AnyPlayerStore): void { + this.store = store; + this.#provider.setValue(store); + } + + clearStore(): void { + this.#provider.setValue(undefined as unknown as AnyPlayerStore); + } + + override connectedCallback(): void { + this.#provider.setValue(this.store); + super.connectedCallback(); + } +} + +defineElement('test-time-player', TestPlayerProviderElement); + +async function setup(props: Partial = {}) { + const provider = document.createElement('test-time-player') as TestPlayerProviderElement; + const time = createElement(TimeElement); + + Object.assign(time, props); + document.body.append(provider); + provider.append(time); + await time.updateComplete; + await waitForAssertion(() => expect(time.textContent).toBeTruthy()); + + return { provider, time }; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('TimeElement', () => { + it('reflects toggle from the attribute', async () => { + const { time } = await setup(); + + time.setAttribute('toggle', ''); + await time.updateComplete; + + expect(time.toggle).toBe(true); + }); + + it('toggles current time to remaining time on click', async () => { + const { time } = await setup({ toggle: true }); + + expect(time.getAttribute('role')).toBe('button'); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + expect(time.getAttribute('aria-label')).toBe('3 minutes, 30 seconds remaining. Show elapsed time.'); + expect(time.hasAttribute('aria-valuetext')).toBe(false); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + expect(time.getAttribute('aria-label')).toBe('1 minute, 30 seconds. Show remaining time.'); + }); + + it('does not toggle before media state is available', async () => { + const provider = document.createElement('test-time-player') as TestPlayerProviderElement; + const time = createElement(TimeElement); + + time.toggle = true; + document.body.append(time); + await time.updateComplete; + + time.click(); + + document.body.append(provider); + provider.append(time); + await time.updateComplete; + await waitForAssertion(() => expect(time.textContent).toBeTruthy()); + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); + + it('toggles remaining time to duration on click', async () => { + const { time } = await setup({ toggle: true, type: 'remaining' }); + + expect(time.getAttribute('aria-label')).toBe('3 minutes, 30 seconds remaining. Show duration.'); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('5:00'); + expect(time.getAttribute('data-type')).toBe('duration'); + expect(time.getAttribute('role')).toBe('button'); + expect(time.getAttribute('aria-label')).toBe('5 minutes. Show remaining time.'); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + }); + + it('toggles with Enter and Space', async () => { + const { time } = await setup({ toggle: true }); + + time.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + + time.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })); + await time.updateComplete; + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); + + it('does not toggle on repeated keydown events', async () => { + const { time } = await setup({ toggle: true }); + + time.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })); + await time.updateComplete; + + time.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', repeat: true, bubbles: true, cancelable: true })); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + }); + + it('does not cancel keyboard events when toggle is turned off', async () => { + const { time } = await setup({ toggle: true }); + + time.toggle = false; + await time.updateComplete; + + const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true }); + + expect(time.dispatchEvent(event)).toBe(true); + expect(event.defaultPrevented).toBe(false); + }); + + it('clears toggle attributes when media state is unavailable', async () => { + const { provider, time } = await setup({ toggle: true }); + + expect(time.getAttribute('role')).toBe('button'); + expect(time.getAttribute('tabindex')).toBe('0'); + expect(time.hasAttribute('aria-label')).toBe(true); + expect(time.getAttribute('data-type')).toBe('current'); + + provider.clearStore(); + time.requestUpdate(); + await time.updateComplete; + + expect(time.hasAttribute('role')).toBe(false); + expect(time.hasAttribute('tabindex')).toBe(false); + expect(time.hasAttribute('aria-label')).toBe(false); + expect(time.hasAttribute('aria-valuetext')).toBe(false); + expect(time.hasAttribute('data-type')).toBe(false); + }); + + it('changing type resets the default display mode', async () => { + const { time } = await setup({ toggle: true }); + + time.click(); + await time.updateComplete; + + time.type = 'duration'; + await time.updateComplete; + + expect(time.textContent).toBe('5:00'); + expect(time.getAttribute('data-type')).toBe('duration'); + + time.type = 'remaining'; + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + }); + + it('resets to the default type when toggle is turned off', async () => { + const { time } = await setup({ toggle: true }); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + + time.toggle = false; + await time.updateComplete; + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + + time.toggle = true; + await time.updateComplete; + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); + + it('toggles after toggle is enabled later', async () => { + const { time } = await setup(); + + time.toggle = true; + await time.updateComplete; + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + }); + + it('toggles duration to remaining time on click', async () => { + const { time } = await setup({ toggle: true, type: 'duration' }); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + + time.click(); + await time.updateComplete; + + expect(time.textContent).toBe('5:00'); + expect(time.getAttribute('data-type')).toBe('duration'); + }); +}); diff --git a/packages/html/src/ui/time/time-element.ts b/packages/html/src/ui/time/time-element.ts index 43b7babe..0b4d96d4 100644 --- a/packages/html/src/ui/time/time-element.ts +++ b/packages/html/src/ui/time/time-element.ts @@ -1,7 +1,7 @@ import { TimeCore, TimeDataAttrs, type TimeType } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, logMissingFeature, selectTime } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; - +import { isInteractiveActivation } from '@videojs/utils/dom'; import { playerContext } from '../../player/context'; import { PlayerController } from '../../player/player-controller'; import { MediaElement } from '../media-element'; @@ -13,11 +13,13 @@ export class TimeElement extends MediaElement { type: { type: String }, negativeSign: { type: String, attribute: 'negative-sign' }, label: { type: String }, + toggle: { type: Boolean }, } satisfies PropertyDeclarationMap; type: TimeType = TimeCore.defaultProps.type; negativeSign = TimeCore.defaultProps.negativeSign; label = TimeCore.defaultProps.label; + toggle = TimeCore.defaultProps.toggle; readonly #core = new TimeCore(); readonly #state = new PlayerController(this, playerContext, selectTime); @@ -25,9 +27,16 @@ export class TimeElement extends MediaElement { readonly #signSpan = document.createElement('span'); readonly #textNode = document.createTextNode(''); + #disconnect: AbortController | null = null; + #listening = false; + #activeType: TimeType = TimeCore.defaultProps.type; + override connectedCallback(): void { super.connectedCallback(); + this.#disconnect = new AbortController(); + this.#syncListeners(); + if (!this.#signSpan.parentNode) { this.#signSpan.setAttribute('aria-hidden', 'true'); this.#signSpan.hidden = true; @@ -40,18 +49,39 @@ export class TimeElement extends MediaElement { } } + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#disconnect?.abort(); + this.#disconnect = null; + this.#listening = false; + } + protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); - this.#core.setProps(this); + if (changed.has('type') || changed.has('toggle')) { + this.#activeType = this.type; + } } protected override update(changed: PropertyValues): void { super.update(changed); + if (changed.has('toggle')) { + this.#syncListeners(); + } + const media = this.#state.value; + if (!media) { + this.#clearAttrs(); + return; + } - if (!media) return; - + this.#core.setProps({ + type: this.toggle ? this.#activeType : this.type, + negativeSign: this.negativeSign, + label: this.label, + toggle: this.toggle, + }); this.#core.setMedia(media); const state = this.#core.getState(); @@ -59,7 +89,55 @@ export class TimeElement extends MediaElement { this.#signSpan.textContent = state.negative ? this.negativeSign : ''; this.#textNode.textContent = state.text; - applyElementProps(this, this.#core.getAttrs(state)); + applyElementProps(this, this.#core.getAttrs(state, this.type)); applyStateDataAttrs(this, state, TimeDataAttrs); } + + #handleClick = (event: MouseEvent): void => { + if (event.defaultPrevented || !this.toggle || !this.#state.value) return; + this.#toggleType(); + }; + + #handleKeyDown = (event: KeyboardEvent): void => { + if (event.defaultPrevented || !isInteractiveActivation(event)) return; + if (!this.toggle || !this.#state.value) return; + // Prevent space from scrolling page. + event.preventDefault(); + if (event.repeat) return; + this.#toggleType(); + }; + + #toggleType(): void { + if (this.type === 'current') { + this.#activeType = this.#activeType === 'remaining' ? 'current' : 'remaining'; + } else { + this.#activeType = this.#activeType === 'duration' ? 'remaining' : 'duration'; + } + + this.requestUpdate(); + } + + #syncListeners(): void { + if (!this.toggle || !this.#disconnect || this.#listening) return; + + this.#listening = true; + applyElementProps( + this, + { + onClick: this.#handleClick, + onKeyDown: this.#handleKeyDown, + }, + { signal: this.#disconnect.signal } + ); + } + + #clearAttrs(): void { + applyElementProps(this, { + 'aria-label': undefined, + 'aria-valuetext': undefined, + role: undefined, + tabIndex: undefined, + 'data-type': undefined, + }); + } } diff --git a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx index ed9fc45c..d2ae3370 100644 --- a/packages/react/src/presets/audio/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tailwind.tsx @@ -244,7 +244,7 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
- + diff --git a/packages/react/src/presets/audio/minimal-skin.tsx b/packages/react/src/presets/audio/minimal-skin.tsx index 4017a8ae..4a8f3152 100644 --- a/packages/react/src/presets/audio/minimal-skin.tsx +++ b/packages/react/src/presets/audio/minimal-skin.tsx @@ -190,7 +190,7 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
- + diff --git a/packages/react/src/presets/audio/skin.tailwind.tsx b/packages/react/src/presets/audio/skin.tailwind.tsx index ea828ec1..b585905c 100644 --- a/packages/react/src/presets/audio/skin.tailwind.tsx +++ b/packages/react/src/presets/audio/skin.tailwind.tsx @@ -256,7 +256,7 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode { - +
diff --git a/packages/react/src/presets/audio/skin.tsx b/packages/react/src/presets/audio/skin.tsx index 50f2a2e0..b143d49b 100644 --- a/packages/react/src/presets/audio/skin.tsx +++ b/packages/react/src/presets/audio/skin.tsx @@ -200,7 +200,7 @@ export function AudioSkin(props: AudioSkinProps): ReactNode { - +
diff --git a/packages/react/src/presets/video/minimal-skin.tailwind.tsx b/packages/react/src/presets/video/minimal-skin.tailwind.tsx index bbd28b1e..48c445d9 100644 --- a/packages/react/src/presets/video/minimal-skin.tailwind.tsx +++ b/packages/react/src/presets/video/minimal-skin.tailwind.tsx @@ -503,7 +503,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
- + diff --git a/packages/react/src/presets/video/minimal-skin.tsx b/packages/react/src/presets/video/minimal-skin.tsx index 0472ba9e..b417e192 100644 --- a/packages/react/src/presets/video/minimal-skin.tsx +++ b/packages/react/src/presets/video/minimal-skin.tsx @@ -435,7 +435,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
- + diff --git a/packages/react/src/presets/video/skin.tailwind.tsx b/packages/react/src/presets/video/skin.tailwind.tsx index 3398a273..aa875841 100644 --- a/packages/react/src/presets/video/skin.tailwind.tsx +++ b/packages/react/src/presets/video/skin.tailwind.tsx @@ -518,7 +518,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode { - +
diff --git a/packages/react/src/presets/video/skin.tsx b/packages/react/src/presets/video/skin.tsx index 1502a8bb..7942bf97 100644 --- a/packages/react/src/presets/video/skin.tsx +++ b/packages/react/src/presets/video/skin.tsx @@ -451,7 +451,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode { - +
diff --git a/packages/react/src/ui/time/tests/time.test.tsx b/packages/react/src/ui/time/tests/time.test.tsx new file mode 100644 index 00000000..f1afce61 --- /dev/null +++ b/packages/react/src/ui/time/tests/time.test.tsx @@ -0,0 +1,185 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createPlayerWrapper } from '../../../testing/mocks'; +import { Value } from '../time-value'; + +vi.mock('@videojs/store/react', () => ({ + useStore: vi.fn((store: { state: object }, selector?: (state: object) => unknown) => + selector ? selector(store.state) : store + ), +})); + +const timeState = { + currentTime: 90, + duration: 300, + seeking: false, + seek: vi.fn(), +}; + +function setup(props: Value.Props = {}) { + const { Wrapper } = createPlayerWrapper(timeState); + + return render( + + + + ); +} + +afterEach(cleanup); + +describe('Time.Value', () => { + it('renders current time by default', () => { + setup(); + + const time = screen.getByTestId('time'); + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); + + it('toggles current time to remaining time on click', () => { + setup({ toggle: true }); + + const time = screen.getByTestId('time'); + fireEvent.click(time); + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + expect(time.getAttribute('aria-label')).toBe('3 minutes, 30 seconds remaining. Show elapsed time.'); + + fireEvent.click(time); + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + expect(time.getAttribute('aria-label')).toBe('1 minute, 30 seconds. Show remaining time.'); + }); + + it('toggles with Enter and Space', () => { + setup({ toggle: true }); + + const time = screen.getByTestId('time'); + fireEvent.keyDown(time, { key: 'Enter' }); + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + + fireEvent.keyDown(time, { key: ' ' }); + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); + + it('does not toggle on repeated keydown events', () => { + setup({ toggle: true }); + + const time = screen.getByTestId('time'); + fireEvent.keyDown(time, { key: 'Enter' }); + fireEvent.keyDown(time, { key: 'Enter', repeat: true }); + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + }); + + it('starts in remaining mode when type is remaining', () => { + setup({ toggle: true, type: 'remaining' }); + + const time = screen.getByTestId('time'); + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + expect(time.getAttribute('aria-label')).toBe('3 minutes, 30 seconds remaining. Show duration.'); + }); + + it('toggles remaining time to duration on click', () => { + setup({ toggle: true, type: 'remaining' }); + + const time = screen.getByTestId('time'); + fireEvent.click(time); + + expect(time.textContent).toBe('5:00'); + expect(time.getAttribute('data-type')).toBe('duration'); + expect(time.getAttribute('role')).toBe('button'); + + fireEvent.click(time); + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + }); + + it('resets to the default type when toggle is turned off', () => { + const { Wrapper } = createPlayerWrapper(timeState); + const { rerender } = render( + + + + ); + + const time = screen.getByTestId('time'); + fireEvent.click(time); + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + + rerender( + + + + ); + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + + rerender( + + + + ); + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); + + it('toggles duration to remaining time on click', () => { + setup({ toggle: true, type: 'duration' }); + + const time = screen.getByTestId('time'); + fireEvent.click(time); + + expect(time.textContent).toBe('-3:30'); + expect(time.getAttribute('data-type')).toBe('remaining'); + + fireEvent.click(time); + + expect(time.textContent).toBe('5:00'); + expect(time.getAttribute('data-type')).toBe('duration'); + }); + + it('calls user event handlers before toggling', () => { + const onClick = vi.fn(); + const onKeyDown = vi.fn(); + + setup({ toggle: true, onClick, onKeyDown }); + + const time = screen.getByTestId('time'); + fireEvent.click(time); + fireEvent.keyDown(time, { key: 'Enter' }); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(onKeyDown).toHaveBeenCalledTimes(1); + }); + + it('does not toggle when user event handlers prevent default', () => { + setup({ + toggle: true, + onClick: (event) => event.preventDefault(), + onKeyDown: (event) => event.preventDefault(), + }); + + const time = screen.getByTestId('time'); + fireEvent.click(time); + fireEvent.keyDown(time, { key: 'Enter' }); + + expect(time.textContent).toBe('1:30'); + expect(time.getAttribute('data-type')).toBe('current'); + }); +}); diff --git a/packages/react/src/ui/time/time-value.tsx b/packages/react/src/ui/time/time-value.tsx index 718a9ca9..bdb693dc 100644 --- a/packages/react/src/ui/time/time-value.tsx +++ b/packages/react/src/ui/time/time-value.tsx @@ -2,9 +2,9 @@ import { TimeCore, TimeDataAttrs } from '@videojs/core'; import { logMissingFeature, selectTime } from '@videojs/core/dom'; -import type { ForwardedRef } from 'react'; -import { forwardRef, useState } from 'react'; - +import { isInteractiveActivation } from '@videojs/utils/dom'; +import type { ForwardedRef, KeyboardEvent, MouseEvent } from 'react'; +import { forwardRef, useEffect, useState } from 'react'; import { usePlayer } from '../../player/context'; import type { UIComponentProps } from '../../utils/types'; import { renderElement } from '../../utils/use-render'; @@ -25,12 +25,19 @@ export const Value = forwardRef(function Value( componentProps: ValueProps, forwardedRef: ForwardedRef ) { - const { render, className, style, type, negativeSign, label, ...elementProps } = componentProps; + const { render, className, style, type, negativeSign, label, toggle = false, ...elementProps } = componentProps; const time = usePlayer(selectTime); - const [core] = useState(() => new TimeCore()); - core.setProps({ type, negativeSign, label }); + + const defaultType = type ?? TimeCore.defaultProps.type; + const [activeType, setActiveType] = useState(defaultType); + // biome-ignore lint/correctness/useExhaustiveDependencies: We want to listen for changes to defaultType and toggle (so we revert to default), this just means one less useEffect. + useEffect(() => { + setActiveType(defaultType); + }, [defaultType, toggle]); + + core.setProps({ type: activeType, negativeSign, label, toggle }); if (!time) { if (__DEV__) logMissingFeature('Time.Value', 'time'); @@ -49,6 +56,28 @@ export const Value = forwardRef(function Value( state.text ); + const toggleType = () => { + setActiveType((value) => { + if (defaultType === 'current') { + return value === 'remaining' ? 'current' : 'remaining'; + } + return value === 'duration' ? 'remaining' : 'duration'; + }); + }; + + const handleClick = (event: MouseEvent) => { + if (event.defaultPrevented) return; + toggleType(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || !isInteractiveActivation(event.nativeEvent)) return; + // Prevent space from scrolling page. + event.preventDefault(); + if (event.repeat) return; + toggleType(); + }; + return renderElement( 'time', { render, className, style }, @@ -60,7 +89,8 @@ export const Value = forwardRef(function Value( { dateTime: state.datetime, children: content, - ...core.getAttrs(state), + ...core.getAttrs(state, defaultType), + ...(toggle ? { onClick: handleClick, onKeyDown: handleKeyDown } : undefined), }, elementProps, ], diff --git a/packages/skins/src/default/css/components/time.css b/packages/skins/src/default/css/components/time.css index fc0f7645..6686212f 100644 --- a/packages/skins/src/default/css/components/time.css +++ b/packages/skins/src/default/css/components/time.css @@ -14,3 +14,18 @@ .media-default-skin .media-time { font-variant-numeric: tabular-nums; } + +.media-default-skin .media-time[role="button"] { + cursor: pointer; + outline: 2px solid transparent; + outline-offset: -2px; + border-radius: 0.25rem; + transition-timing-function: ease-out; + transition-duration: 100ms; + transition-property: outline-color, outline-offset; + + &:focus-visible { + outline-color: currentColor; + outline-offset: 2px; + } +} diff --git a/packages/skins/src/default/tailwind/components/time.ts b/packages/skins/src/default/tailwind/components/time.ts index e46de1cd..622464a4 100644 --- a/packages/skins/src/default/tailwind/components/time.ts +++ b/packages/skins/src/default/tailwind/components/time.ts @@ -1,5 +1,6 @@ export const time = { group: '@container/media-time flex items-center flex-1 gap-3 px-2', current: 'hidden @2xs/media-time:block tabular-nums', - duration: 'tabular-nums', + duration: + 'tabular-nums cursor-pointer rounded-sm outline-2 outline-transparent -outline-offset-2 transition-[outline-color,outline-offset] duration-100 ease-out focus-visible:outline-current focus-visible:outline-offset-2', }; diff --git a/packages/skins/src/minimal/css/components/time.css b/packages/skins/src/minimal/css/components/time.css index 0dc1663d..804aac4f 100644 --- a/packages/skins/src/minimal/css/components/time.css +++ b/packages/skins/src/minimal/css/components/time.css @@ -21,6 +21,26 @@ font-variant-numeric: tabular-nums; } +.media-minimal-skin .media-time[role="button"] { + cursor: pointer; + outline: 2px solid transparent; + outline-offset: -2px; + border-radius: 0.25rem; + transition-timing-function: ease-out; + transition-duration: 100ms; + transition-property: outline-color, outline-offset; + + @supports (corner-shape: squircle) { + border-radius: 1rem; + corner-shape: squircle; + } + + &:focus-visible { + outline-color: currentColor; + outline-offset: 2px; + } +} + .media-minimal-skin .media-time--current, .media-minimal-skin .media-time-separator { display: none; diff --git a/packages/skins/src/minimal/tailwind/components/time.ts b/packages/skins/src/minimal/tailwind/components/time.ts index d1af4fc6..f96befd1 100644 --- a/packages/skins/src/minimal/tailwind/components/time.ts +++ b/packages/skins/src/minimal/tailwind/components/time.ts @@ -2,7 +2,14 @@ import { cn } from '@videojs/utils/style'; export const time = { group: 'flex items-center gap-1', - current: cn('hidden tabular-nums', '@2xl/media-root:inline'), + current: cn( + 'hidden tabular-nums cursor-pointer rounded-sm outline-2 outline-transparent -outline-offset-2', + 'transition-[outline-color,outline-offset] duration-100 ease-out', + 'supports-[corner-shape:squircle]:rounded-4', + 'supports-[corner-shape:squircle]:[corner-shape:squircle]', + 'focus-visible:outline-current focus-visible:outline-offset-2', + '@2xl/media-root:inline' + ), separator: cn('hidden', '@2xl/media-root:inline @2xl/media-root:text-current/60'), duration: cn('tabular-nums', '@2xl/media-root:text-current/60'), controls: cn('@container flex flex-row-reverse items-center flex-1 gap-3', '@2xl/media-root:flex-row'), diff --git a/site/src/content/docs/reference/time.mdx b/site/src/content/docs/reference/time.mdx index d58fcaed..ee909b6c 100644 --- a/site/src/content/docs/reference/time.mdx +++ b/site/src/content/docs/reference/time.mdx @@ -85,6 +85,26 @@ Three display types — `current`, `duration`, and `remaining` — in digital fo Hour display is triggered when either the current value or the duration exceeds 1 hour, ensuring consistency within a Group. Remaining time displays a negative sign (customizable via the `negativeSign` prop). +Use `toggle` to let current displays switch between elapsed and remaining time, or remaining and duration displays switch between those two values. The initial display comes from `type`. + + + +```tsx + + + +``` + + + + +```html + + + +``` + + ## Styling The negative sign is rendered inside `