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:
rahim
2026-04-02 14:20:01 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 341b405a21
commit 86cf3e8977
52 changed files with 242 additions and 485 deletions
@@ -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,
};
}
}
+13
View File
@@ -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];