mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add time display toggle (#1669)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TimeType, string> = {
|
||||
current: 'Current time',
|
||||
duration: 'Duration',
|
||||
remaining: 'Remaining',
|
||||
const TOGGLE_LABELS: Record<TimeType, string> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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)}">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
|
||||
|
||||
<div className={time.controls}>
|
||||
<Time.Group className={time.group}>
|
||||
<Time.Value type="current" className={time.current} />
|
||||
<Time.Value toggle type="current" className={time.current} />
|
||||
<Time.Separator className={time.separator} />
|
||||
<Time.Value type="duration" className={time.duration} />
|
||||
</Time.Group>
|
||||
|
||||
@@ -190,7 +190,7 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
|
||||
|
||||
<div className="media-time-controls">
|
||||
<Time.Group className="media-time-group">
|
||||
<Time.Value type="current" className="media-time media-time--current" />
|
||||
<Time.Value toggle type="current" className="media-time media-time--current" />
|
||||
<Time.Separator className="media-time-separator" />
|
||||
<Time.Value type="duration" className="media-time media-time--duration" />
|
||||
</Time.Group>
|
||||
|
||||
@@ -256,7 +256,7 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
|
||||
<TimeSlider.Value type="pointer" className={slider.value} />
|
||||
</TimeSlider.Preview>
|
||||
</TimeSlider.Root>
|
||||
<Time.Value type="duration" className={time.duration} />
|
||||
<Time.Value toggle type="remaining" className={time.duration} />
|
||||
</div>
|
||||
|
||||
<div className={buttonGroup}>
|
||||
|
||||
@@ -200,7 +200,7 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
|
||||
<TimeSlider.Value type="pointer" className="media-slider__value media-time" />
|
||||
</TimeSlider.Preview>
|
||||
</TimeSlider.Root>
|
||||
<Time.Value type="duration" className="media-time" />
|
||||
<Time.Value toggle type="remaining" className="media-time" />
|
||||
</div>
|
||||
|
||||
<div className="media-button-group">
|
||||
|
||||
@@ -503,7 +503,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
|
||||
|
||||
<div className={time.controls}>
|
||||
<Time.Group className={time.group}>
|
||||
<Time.Value type="current" className={time.current} />
|
||||
<Time.Value toggle type="current" className={time.current} />
|
||||
<Time.Separator className={time.separator} />
|
||||
<Time.Value type="duration" className={time.duration} />
|
||||
</Time.Group>
|
||||
|
||||
@@ -435,7 +435,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
|
||||
|
||||
<div className="media-time-controls">
|
||||
<Time.Group className="media-time-group">
|
||||
<Time.Value type="current" className="media-time media-time--current" />
|
||||
<Time.Value toggle type="current" className="media-time media-time--current" />
|
||||
<Time.Separator className="media-time-separator" />
|
||||
<Time.Value type="duration" className="media-time media-time--duration" />
|
||||
</Time.Group>
|
||||
|
||||
@@ -518,7 +518,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
|
||||
<TimeSlider.Value type="pointer" className={slider.value} />
|
||||
</TimeSlider.Preview>
|
||||
</TimeSlider.Root>
|
||||
<Time.Value type="duration" className={time.duration} />
|
||||
<Time.Value toggle type="remaining" className={time.duration} />
|
||||
</div>
|
||||
|
||||
<div className={cn(buttonGroupEnd, menu.settingsGroup)}>
|
||||
|
||||
@@ -451,7 +451,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
|
||||
<TimeSlider.Value type="pointer" className="media-time media-slider__value" />
|
||||
</TimeSlider.Preview>
|
||||
</TimeSlider.Root>
|
||||
<Time.Value type="duration" className="media-time" />
|
||||
<Time.Value toggle type="remaining" className="media-time" />
|
||||
</div>
|
||||
|
||||
<div className="media-button-group">
|
||||
|
||||
@@ -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(
|
||||
<Wrapper>
|
||||
<Value data-testid="time" {...props} />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
<Wrapper>
|
||||
<Value data-testid="time" toggle />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const time = screen.getByTestId('time');
|
||||
fireEvent.click(time);
|
||||
|
||||
expect(time.textContent).toBe('-3:30');
|
||||
expect(time.getAttribute('data-type')).toBe('remaining');
|
||||
|
||||
rerender(
|
||||
<Wrapper>
|
||||
<Value data-testid="time" />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
expect(time.textContent).toBe('1:30');
|
||||
expect(time.getAttribute('data-type')).toBe('current');
|
||||
|
||||
rerender(
|
||||
<Wrapper>
|
||||
<Value data-testid="time" toggle />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLTimeElement>
|
||||
) {
|
||||
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<HTMLTimeElement>) => {
|
||||
if (event.defaultPrevented) return;
|
||||
toggleType();
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLTimeElement>) => {
|
||||
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,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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`.
|
||||
|
||||
<FrameworkCase frameworks={["react"]}>
|
||||
|
||||
```tsx
|
||||
<Time.Value toggle />
|
||||
<Time.Value toggle type="remaining" />
|
||||
<Time.Value toggle type="duration" />
|
||||
```
|
||||
</FrameworkCase>
|
||||
|
||||
<FrameworkCase frameworks={["html"]}>
|
||||
|
||||
```html
|
||||
<media-time toggle></media-time>
|
||||
<media-time toggle type="remaining"></media-time>
|
||||
<media-time toggle type="duration"></media-time>
|
||||
```
|
||||
</FrameworkCase>
|
||||
|
||||
## Styling
|
||||
|
||||
The negative sign is rendered inside `<span aria-hidden="true">` and can be hidden with CSS:
|
||||
@@ -111,6 +131,8 @@ Each `<Time.Value>` has:
|
||||
|
||||
No `aria-live` region is used — time updates too frequently and might overwhelm screen readers. The separator is `aria-hidden="true"` since screen readers already hear each time value separately. The negative sign is also `aria-hidden` because `aria-valuetext` already conveys "remaining". In React, `<time datetime>` provides machine-readable time for parsers.
|
||||
|
||||
Toggleable time displays receive `role="button"` and keyboard support for Enter and Space.
|
||||
|
||||
## Examples
|
||||
|
||||
### Current Time
|
||||
|
||||
Reference in New Issue
Block a user