feat(packages): add time display toggle (#1669)

This commit is contained in:
Sam Potts
2026-07-01 12:27:27 +10:00
committed by GitHub
parent 0de3fef878
commit be4d5a1155
27 changed files with 790 additions and 48 deletions
@@ -89,7 +89,7 @@ function getTemplateHTML() {
<div class="${time.controls}">
<media-time-group class="${time.group}">
<media-time type="current" class="${time.current}"></media-time>
<media-time toggle type="current" class="${time.current}"></media-time>
<media-time-separator class="${time.separator}"></media-time-separator>
<media-time type="duration" class="${time.duration}"></media-time>
</media-time-group>
@@ -71,7 +71,7 @@ function getTemplateHTML() {
<div class="media-time-controls">
<media-time-group class="media-time-group">
<media-time type="current" class="media-time media-time--current"></media-time>
<media-time toggle type="current" class="media-time media-time--current"></media-time>
<media-time-separator class="media-time-separator"></media-time-separator>
<media-time type="duration" class="media-time media-time--duration"></media-time>
</media-time-group>
@@ -99,7 +99,7 @@ function getTemplateHTML() {
<media-slider-value type="pointer" class="${slider.value}"></media-slider-value>
</media-slider-preview>
</media-time-slider>
<media-time type="duration" class="${time.duration}"></media-time>
<media-time toggle type="remaining" class="${time.duration}"></media-time>
</div>
<div class="${buttonGroup}">
+1 -1
View File
@@ -81,7 +81,7 @@ function getTemplateHTML() {
<media-slider-value type="pointer" class="media-slider__value media-time"></media-slider-value>
</media-slider-preview>
</media-time-slider>
<media-time type="duration" class="media-time"></media-time>
<media-time toggle type="remaining" class="media-time"></media-time>
</div>
<div class="media-button-group">
@@ -97,7 +97,7 @@ function getTemplateHTML() {
<div class="${time.controls}">
<media-time-group class="${time.group}">
<media-time type="current" class="${time.current}"></media-time>
<media-time toggle type="current" class="${time.current}"></media-time>
<media-time-separator class="${time.separator}"></media-time-separator>
<media-time type="duration" class="${time.duration}"></media-time>
</media-time-group>
@@ -74,7 +74,7 @@ function getTemplateHTML() {
<div class="media-time-controls">
<media-time-group class="media-time-group">
<media-time type="current" class="media-time media-time--current"></media-time>
<media-time toggle type="current" class="media-time media-time--current"></media-time>
<media-time-separator class="media-time-separator"></media-time-separator>
<media-time type="duration" class="media-time media-time--duration"></media-time>
</media-time-group>
@@ -113,7 +113,7 @@ function getTemplateHTML() {
<media-slider-value type="pointer" class="${cn(slider.value, time.current)}"></media-slider-value>
</media-slider-preview>
</media-time-slider>
<media-time type="duration" class="${time.duration}"></media-time>
<media-time toggle type="remaining" class="${time.duration}"></media-time>
</div>
<div class="${cn(buttonGroupEnd, menu.settingsGroup)}">
+1 -1
View File
@@ -91,7 +91,7 @@ function getTemplateHTML() {
<media-slider-value type="pointer" class="media-slider__value media-time"></media-slider-value>
</media-slider-preview>
</media-time-slider>
<media-time type="duration" class="media-time"></media-time>
<media-time toggle type="remaining" class="media-time"></media-time>
</div>
<div class="media-button-group">
@@ -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<Element extends HTMLElement>(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<void> {
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
async function waitForAssertion(assertion: () => void): Promise<void> {
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<unknown>()<MediaTimeState>({
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<TimeElement> = {}) {
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');
});
});
+83 -5
View File
@@ -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<keyof TimeCore.Props>;
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,
});
}
}