mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(packages): make tooltips visual-only and auto-forward media button labels (#1174)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaTextTrackState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface CaptionsButtonProps {
|
||||
/** Custom label for the button. */
|
||||
@@ -11,7 +13,7 @@ export interface CaptionsButtonProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface CaptionsButtonState extends Pick<MediaTextTrackState, 'subtitlesShowing'> {
|
||||
export interface CaptionsButtonState extends Pick<MediaTextTrackState, 'subtitlesShowing'>, ButtonState {
|
||||
availability: 'available' | 'unavailable';
|
||||
}
|
||||
|
||||
@@ -21,6 +23,12 @@ export class CaptionsButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<CaptionsButtonState>({
|
||||
subtitlesShowing: false,
|
||||
availability: 'unavailable',
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...CaptionsButtonCore.defaultProps };
|
||||
#media: MediaTextTrackState | null = null;
|
||||
|
||||
@@ -58,12 +66,16 @@ export class CaptionsButtonCore {
|
||||
|
||||
getState(): CaptionsButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
subtitlesShowing: media.subtitlesShowing,
|
||||
availability: media.textTrackList.some((t) => t.kind === 'captions' || t.kind === 'subtitles')
|
||||
? 'available'
|
||||
: 'unavailable',
|
||||
};
|
||||
const availability: CaptionsButtonState['availability'] = media.textTrackList.some(
|
||||
(t) => t.kind === 'captions' || t.kind === 'subtitles'
|
||||
)
|
||||
? 'available'
|
||||
: 'unavailable';
|
||||
|
||||
this.state.patch({ subtitlesShowing: media.subtitlesShowing, availability });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
toggle(media: MediaTextTrackState): void {
|
||||
|
||||
@@ -20,6 +20,7 @@ function createState(overrides: Partial<CaptionsButtonState> = {}): CaptionsButt
|
||||
return {
|
||||
subtitlesShowing: false,
|
||||
availability: 'available',
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaFullscreenState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface FullscreenButtonProps {
|
||||
/** Custom label for the button. */
|
||||
@@ -11,7 +13,7 @@ export interface FullscreenButtonProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface FullscreenButtonState extends Pick<MediaFullscreenState, 'fullscreen'> {
|
||||
export interface FullscreenButtonState extends Pick<MediaFullscreenState, 'fullscreen'>, ButtonState {
|
||||
/** Whether fullscreen can be requested on this platform. */
|
||||
availability: MediaFullscreenState['fullscreenAvailability'];
|
||||
}
|
||||
@@ -22,6 +24,12 @@ export class FullscreenButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<FullscreenButtonState>({
|
||||
fullscreen: false,
|
||||
availability: 'available',
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...FullscreenButtonCore.defaultProps };
|
||||
#media: MediaFullscreenState | null = null;
|
||||
|
||||
@@ -59,10 +67,10 @@ export class FullscreenButtonCore {
|
||||
|
||||
getState(): FullscreenButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
fullscreen: media.fullscreen,
|
||||
availability: media.fullscreenAvailability,
|
||||
};
|
||||
this.state.patch({ fullscreen: media.fullscreen, availability: media.fullscreenAvailability });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
async toggle(media: MediaFullscreenState): Promise<void> {
|
||||
|
||||
@@ -18,6 +18,7 @@ function createState(overrides: Partial<FullscreenButtonState> = {}): Fullscreen
|
||||
return {
|
||||
fullscreen: false,
|
||||
availability: 'available',
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaVolumeState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export type VolumeLevel = 'off' | 'low' | 'medium' | 'high';
|
||||
|
||||
@@ -13,7 +15,7 @@ export interface MuteButtonProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface MuteButtonState extends Pick<MediaVolumeState, 'muted'> {
|
||||
export interface MuteButtonState extends Pick<MediaVolumeState, 'muted'>, ButtonState {
|
||||
/**
|
||||
* Derived volume level:
|
||||
* - `off`: muted or volume is 0
|
||||
@@ -30,6 +32,12 @@ export class MuteButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<MuteButtonState>({
|
||||
muted: false,
|
||||
volumeLevel: 'off',
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...MuteButtonCore.defaultProps };
|
||||
#media: MediaVolumeState | null = null;
|
||||
|
||||
@@ -67,10 +75,10 @@ export class MuteButtonCore {
|
||||
|
||||
getState(): MuteButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
muted: media.muted || media.volume === 0,
|
||||
volumeLevel: getVolumeLevel(media),
|
||||
};
|
||||
this.state.patch({ muted: media.muted || media.volume === 0, volumeLevel: getVolumeLevel(media) });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
toggle(media: MediaVolumeState): void {
|
||||
|
||||
@@ -19,6 +19,7 @@ function createState(overrides: Partial<MuteButtonState> = {}): MuteButtonState
|
||||
return {
|
||||
muted: false,
|
||||
volumeLevel: 'high',
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaPictureInPictureState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface PiPButtonProps {
|
||||
/** Custom label for the button. */
|
||||
@@ -11,7 +13,7 @@ export interface PiPButtonProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PiPButtonState extends Pick<MediaPictureInPictureState, 'pip'> {
|
||||
export interface PiPButtonState extends Pick<MediaPictureInPictureState, 'pip'>, ButtonState {
|
||||
/** Whether picture-in-picture can be requested on this platform. */
|
||||
availability: MediaPictureInPictureState['pipAvailability'];
|
||||
}
|
||||
@@ -22,6 +24,12 @@ export class PiPButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<PiPButtonState>({
|
||||
pip: false,
|
||||
availability: 'available',
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...PiPButtonCore.defaultProps };
|
||||
#media: MediaPictureInPictureState | null = null;
|
||||
|
||||
@@ -59,10 +67,10 @@ export class PiPButtonCore {
|
||||
|
||||
getState(): PiPButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
pip: media.pip,
|
||||
availability: media.pipAvailability,
|
||||
};
|
||||
this.state.patch({ pip: media.pip, availability: media.pipAvailability });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
async toggle(media: MediaPictureInPictureState): Promise<void> {
|
||||
|
||||
@@ -18,6 +18,7 @@ function createState(overrides: Partial<PiPButtonState> = {}): PiPButtonState {
|
||||
return {
|
||||
pip: false,
|
||||
availability: 'available',
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaPlaybackState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface PlayButtonProps {
|
||||
/** Custom label for the button. */
|
||||
@@ -11,7 +13,7 @@ export interface PlayButtonProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PlayButtonState extends Pick<MediaPlaybackState, 'paused' | 'ended' | 'started'> {}
|
||||
export interface PlayButtonState extends Pick<MediaPlaybackState, 'paused' | 'ended' | 'started'>, ButtonState {}
|
||||
|
||||
export class PlayButtonCore {
|
||||
static readonly defaultProps: NonNullableObject<PlayButtonProps> = {
|
||||
@@ -19,6 +21,13 @@ export class PlayButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<PlayButtonState>({
|
||||
paused: true,
|
||||
ended: false,
|
||||
started: false,
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...PlayButtonCore.defaultProps };
|
||||
#media: MediaPlaybackState | null = null;
|
||||
|
||||
@@ -57,11 +66,11 @@ export class PlayButtonCore {
|
||||
|
||||
getState(): PlayButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
paused: media.paused,
|
||||
ended: media.ended,
|
||||
started: media.started,
|
||||
};
|
||||
|
||||
this.state.patch({ paused: media.paused, ended: media.ended, started: media.started });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
async toggle(media: MediaPlaybackState): Promise<void> {
|
||||
|
||||
@@ -21,6 +21,7 @@ function createState(overrides: Partial<PlayButtonState> = {}): PlayButtonState
|
||||
paused: true,
|
||||
ended: false,
|
||||
started: false,
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaPlaybackRateState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface PlaybackRateButtonProps {
|
||||
/** Custom label for the button. */
|
||||
@@ -11,7 +13,7 @@ export interface PlaybackRateButtonProps {
|
||||
disabled?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PlaybackRateButtonState {
|
||||
export interface PlaybackRateButtonState extends ButtonState {
|
||||
rate: number;
|
||||
}
|
||||
|
||||
@@ -21,6 +23,11 @@ export class PlaybackRateButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<PlaybackRateButtonState>({
|
||||
rate: 1,
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...PlaybackRateButtonCore.defaultProps };
|
||||
#media: MediaPlaybackRateState | null = null;
|
||||
|
||||
@@ -58,9 +65,10 @@ export class PlaybackRateButtonCore {
|
||||
|
||||
getState(): PlaybackRateButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
rate: media.playbackRate,
|
||||
};
|
||||
this.state.patch({ rate: media.playbackRate });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
cycle(media: MediaPlaybackRateState): void {
|
||||
|
||||
@@ -16,6 +16,7 @@ function createMediaState(overrides: Partial<MediaPlaybackRateState> = {}): Medi
|
||||
function createState(overrides: Partial<PlaybackRateButtonState> = {}): PlaybackRateButtonState {
|
||||
return {
|
||||
rate: 1,
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { createState } from '@videojs/store';
|
||||
import { defaults } from '@videojs/utils/object';
|
||||
import { isFunction } from '@videojs/utils/predicate';
|
||||
import type { NonNullableObject } from '@videojs/utils/types';
|
||||
|
||||
import type { MediaTimeState } from '../../media/state';
|
||||
import type { ButtonState } from '../types';
|
||||
|
||||
export interface SeekButtonProps {
|
||||
/** Seconds to seek. Positive = forward, negative = backward. Default `30`. */
|
||||
@@ -15,7 +17,7 @@ export interface SeekButtonProps {
|
||||
|
||||
export type SeekButtonDirection = 'forward' | 'backward';
|
||||
|
||||
export interface SeekButtonState {
|
||||
export interface SeekButtonState extends ButtonState {
|
||||
/** Whether a seek is in progress. */
|
||||
seeking: boolean;
|
||||
/** Whether the button seeks forward or backward. */
|
||||
@@ -29,6 +31,12 @@ export class SeekButtonCore {
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
readonly state = createState<SeekButtonState>({
|
||||
seeking: false,
|
||||
direction: 'forward',
|
||||
label: '',
|
||||
});
|
||||
|
||||
#props = { ...SeekButtonCore.defaultProps };
|
||||
#media: MediaTimeState | null = null;
|
||||
|
||||
@@ -67,10 +75,12 @@ export class SeekButtonCore {
|
||||
|
||||
getState(): SeekButtonState {
|
||||
const media = this.#media!;
|
||||
return {
|
||||
seeking: media.seeking,
|
||||
direction: this.#props.seconds < 0 ? 'backward' : 'forward',
|
||||
};
|
||||
const direction: SeekButtonDirection = this.#props.seconds < 0 ? 'backward' : 'forward';
|
||||
|
||||
this.state.patch({ seeking: media.seeking, direction });
|
||||
this.state.patch({ label: this.getLabel(this.state.current) });
|
||||
|
||||
return this.state.current;
|
||||
}
|
||||
|
||||
async seek(media: MediaTimeState): Promise<void> {
|
||||
|
||||
@@ -18,6 +18,7 @@ function createState(overrides: Partial<SeekButtonState> = {}): SeekButtonState
|
||||
return {
|
||||
seeking: false,
|
||||
direction: 'forward',
|
||||
label: '',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -44,39 +44,13 @@ describe('TooltipCore', () => {
|
||||
expect(state.align).toBe('center');
|
||||
});
|
||||
|
||||
describe('getTriggerAttrs', () => {
|
||||
it('returns undefined aria-describedby when closed', () => {
|
||||
const core = new TooltipCore();
|
||||
core.setInput(CLOSED);
|
||||
const attrs = core.getTriggerAttrs(core.getState(), 'tooltip-1');
|
||||
|
||||
expect(attrs['aria-describedby']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns aria-describedby with popupId when open', () => {
|
||||
const core = new TooltipCore();
|
||||
core.setInput(OPEN);
|
||||
const attrs = core.getTriggerAttrs(core.getState(), 'tooltip-1');
|
||||
|
||||
expect(attrs['aria-describedby']).toBe('tooltip-1');
|
||||
});
|
||||
|
||||
it('returns undefined aria-describedby when no popupId', () => {
|
||||
const core = new TooltipCore();
|
||||
core.setInput(OPEN);
|
||||
const attrs = core.getTriggerAttrs(core.getState());
|
||||
|
||||
expect(attrs['aria-describedby']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPopupAttrs', () => {
|
||||
it('returns tooltip role', () => {
|
||||
it('returns presentation role', () => {
|
||||
const core = new TooltipCore();
|
||||
core.setInput(OPEN);
|
||||
const attrs = core.getPopupAttrs(core.getState());
|
||||
|
||||
expect(attrs.role).toBe('tooltip');
|
||||
expect(attrs.role).toBe('presentation');
|
||||
});
|
||||
|
||||
it('returns popover manual attribute', () => {
|
||||
|
||||
@@ -76,16 +76,10 @@ export class TooltipCore {
|
||||
};
|
||||
}
|
||||
|
||||
getTriggerAttrs(state: TooltipState, popupId?: string) {
|
||||
return {
|
||||
'aria-describedby': state.open ? popupId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
getPopupAttrs(_state: TooltipState) {
|
||||
return {
|
||||
popover: 'manual' as const,
|
||||
role: 'tooltip',
|
||||
role: 'presentation' as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { State } from '@videojs/store';
|
||||
|
||||
export type StateAttrMap<State> = {
|
||||
[Key in keyof State]?: string;
|
||||
};
|
||||
@@ -14,6 +16,17 @@ export interface MediaUIComponent<Props = object, State extends object = object>
|
||||
setMedia(media: object): void;
|
||||
}
|
||||
|
||||
export interface ButtonState {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** Constraint for media button cores that provide a label derived from state. */
|
||||
export interface MediaButtonComponent<Props = object, ComponentState extends ButtonState = ButtonState>
|
||||
extends MediaUIComponent<Props, ComponentState> {
|
||||
readonly state: State<ComponentState>;
|
||||
getLabel(state: ComponentState): string;
|
||||
}
|
||||
|
||||
/** Extracts the media state parameter type from a core's `setMedia` method. */
|
||||
export type InferMediaState<Core extends MediaUIComponent> = Parameters<Core['setMedia']>[0];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user