feat(packages): add UI support for gestures and hotkeys (#1388)

Co-authored-by: Rahim <rahim.alwer@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sam Potts
2026-05-05 10:34:12 +10:00
committed by GitHub
co-authored by Rahim Claude Opus 4.6
parent 6c81f2d190
commit 0620814a67
187 changed files with 5665 additions and 405 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ export function Gesture({ type, action, value, pointer, region, disabled }: Gest
resolver({ store, value, event });
};
const options = { pointer, region, action };
const options = { pointer, region, action, value };
if (type === 'doubletap') {
return createDoubleTapGesture(container, onActivate, options);
+1
View File
@@ -0,0 +1 @@
export * from './gesture';
+1
View File
@@ -33,6 +33,7 @@ export function Hotkey({ keys, action, value, disabled, target }: HotkeyProps):
return createHotkey(container, {
keys,
action,
value,
target,
disabled,
repeatable: !isHotkeyToggleAction(action),
+1
View File
@@ -0,0 +1 @@
export * from './hotkey';
@@ -0,0 +1,136 @@
import { cleanup, fireEvent, render } from '@testing-library/react';
import type { SeekIndicatorCore, StatusIndicatorCore, VolumeIndicatorCore } from '@videojs/core';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PlayerContextProvider, type PlayerContextValue } from '../../../player/context';
import { SeekIndicator } from '../../seek-indicator';
import { SeekIndicatorProvider } from '../../seek-indicator/context';
import { StatusAnnouncer } from '../../status-announcer/status-announcer';
import { StatusIndicator } from '../../status-indicator';
import { StatusIndicatorProvider } from '../../status-indicator/context';
import { VolumeIndicator } from '../../volume-indicator';
import { VolumeIndicatorProvider } from '../../volume-indicator/context';
import { useIndicatorVisibility } from '../use-indicator-visibility';
afterEach(cleanup);
describe('input indicators', () => {
it('renders status values from the nearest status item context', () => {
const state: StatusIndicatorCore.State = {
open: true,
generation: 1,
status: 'captions-on',
label: 'Captions on',
value: null,
transitionStarting: false,
transitionEnding: false,
};
const { getByTestId } = render(
<StatusIndicatorProvider value={{ state }}>
<StatusIndicator.Value data-testid="value" />
</StatusIndicatorProvider>
);
expect(getByTestId('value').textContent).toBe('Captions on');
});
it('uses implicit StatusAnnouncer live-region semantics without rendering text content', () => {
const { getByRole } = renderWithPlayer(<StatusAnnouncer />);
expect(getByRole('status').hasAttribute('aria-live')).toBe(false);
expect(getByRole('status').textContent).toBe('');
});
it('scopes the volume CSS variable to VolumeIndicator.Fill', () => {
const state: VolumeIndicatorCore.State = {
open: true,
generation: 1,
level: 'high',
value: '60%',
fill: '60%',
min: false,
max: false,
transitionStarting: false,
transitionEnding: false,
};
const { getByTestId } = render(
<VolumeIndicatorProvider value={{ state }}>
<div data-testid="root">
<VolumeIndicator.Fill data-testid="fill">
<VolumeIndicator.Value data-testid="value" />
</VolumeIndicator.Fill>
</div>
</VolumeIndicatorProvider>
);
expect(getByTestId('root').style.getPropertyValue('--media-volume-fill')).toBe('');
expect(getByTestId('fill').style.getPropertyValue('--media-volume-fill')).toBe('60%');
expect(getByTestId('value').textContent).toBe('60%');
});
it('keeps seek value content populated while mounted', () => {
const state: SeekIndicatorCore.State = {
open: true,
generation: 1,
direction: 'forward',
count: 1,
seekTotal: 10,
value: null,
currentTime: '0:30',
transitionStarting: false,
transitionEnding: false,
};
const { getByTestId } = render(
<SeekIndicatorProvider value={{ state }}>
<SeekIndicator.Value data-testid="value" />
</SeekIndicatorProvider>
);
expect(getByTestId('value').textContent).toBe('0:30');
});
it('closes the previous visual indicator when a new one is shown', () => {
const firstClose = vi.fn();
const secondClose = vi.fn();
const { getByTestId } = renderWithPlayer(
<>
<VisibilityProbe close={firstClose} id="first" />
<VisibilityProbe close={secondClose} id="second" />
</>
);
fireEvent.click(getByTestId('second'));
expect(firstClose).toHaveBeenCalledOnce();
expect(secondClose).not.toHaveBeenCalled();
});
});
function VisibilityProbe({ close, id }: { close: () => void; id: string }) {
const show = useIndicatorVisibility(close);
return (
<button data-testid={id} onClick={show} type="button">
{id}
</button>
);
}
function renderWithPlayer(ui: ReactNode) {
const container = document.createElement('div');
const playerContextValue = {
store: {
state: {},
subscribe: () => () => {},
},
media: null,
setMedia: vi.fn(),
container,
setContainer: vi.fn(),
} as unknown as PlayerContextValue;
return render(<PlayerContextProvider value={playerContextValue}>{ui}</PlayerContextProvider>);
}
@@ -0,0 +1,30 @@
'use client';
import type { IndicatorVisibilityHandle } from '@videojs/core';
import { getIndicatorVisibilityCoordinator } from '@videojs/core/dom';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useContainer } from '../../player/context';
import { useLatestRef } from '../../utils/use-latest-ref';
export function useIndicatorVisibility(close: () => void): () => void {
const container = useContainer();
const closeRef = useLatestRef(close);
const coordinatorRef = useRef<ReturnType<typeof getIndicatorVisibilityCoordinator> | null>(null);
const [handle] = useState<IndicatorVisibilityHandle>(() => ({
close: () => closeRef.current(),
}));
useEffect(() => {
if (!container) return;
const coordinator = getIndicatorVisibilityCoordinator(container);
coordinatorRef.current = coordinator;
return coordinator.register(handle);
}, [container, handle]);
return useCallback(() => {
coordinatorRef.current?.show(handle);
}, [handle]);
}
@@ -0,0 +1,23 @@
'use client';
import type { InputActionEvent, MediaSnapshot } from '@videojs/core';
import { getMediaSnapshot, subscribeToInputActions } from '@videojs/core/dom';
import { useEffect } from 'react';
import { useContainer, usePlayer } from '../../player/context';
import { useLatestRef } from '../../utils/use-latest-ref';
export function useInputActionSubscription(callback: (event: InputActionEvent, snapshot: MediaSnapshot) => void): void {
const container = useContainer();
const store = usePlayer();
const callbackRef = useLatestRef(callback);
const storeRef = useLatestRef(store);
useEffect(() => {
if (!container) return;
return subscribeToInputActions(container, (event) => {
callbackRef.current(event, getMediaSnapshot(storeRef.current));
});
}, [container]);
}
@@ -0,0 +1,40 @@
'use client';
import type { IndicatorLifecycleState, InputActionEvent, MediaSnapshot } from '@videojs/core';
import type { State as StoreState } from '@videojs/store';
import { useState, useSyncExternalStore } from 'react';
import { useDestroy } from '../../utils/use-destroy';
import { useIndicatorVisibility } from './use-indicator-visibility';
import { useInputActionSubscription } from './use-input-action-subscription';
import { useRenderedIndicatorState } from './use-rendered-indicator-state';
interface InputIndicatorRootCore<IndicatorState extends IndicatorLifecycleState, Props> {
readonly state: StoreState<IndicatorState>;
setProps(props: Props): void;
destroy(): void;
close(): void;
processEvent(event: InputActionEvent, snapshot: MediaSnapshot): boolean;
}
export function useInputIndicatorRoot<IndicatorState extends IndicatorLifecycleState, Props>(
createCore: () => InputIndicatorRootCore<IndicatorState, Props>,
props: Props
) {
const [core] = useState(createCore);
useDestroy(core);
core.setProps(props);
const showIndicator = useIndicatorVisibility(() => core.close());
useInputActionSubscription((event, snapshot) => {
if (core.processEvent(event, snapshot)) showIndicator();
});
const currentState = useSyncExternalStore(
(callback) => core.state.subscribe(callback),
() => core.state.current,
() => core.state.current
);
return useRenderedIndicatorState(currentState);
}
@@ -0,0 +1,46 @@
'use client';
import { getRenderedIndicatorState, type IndicatorLifecycleState, isIndicatorPresent } from '@videojs/core';
import { createTransition } from '@videojs/core/dom';
import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useDestroy } from '../../utils/use-destroy';
export function useRenderedIndicatorState<State extends IndicatorLifecycleState>(currentState: State) {
const elementRef = useRef<HTMLElement>(null);
const currentStateRef = useRef(currentState);
const snapshotRef = useRef(currentState);
const [transition] = useState(() => createTransition());
useDestroy(transition);
currentStateRef.current = currentState;
const transitionState = useSyncExternalStore(
(callback) => transition.state.subscribe(callback),
() => transition.state.current,
() => transition.state.current
);
const { generation, open } = currentState;
useEffect(() => {
if (open) {
const nextState = currentStateRef.current;
if (nextState.generation !== generation) return;
snapshotRef.current = nextState;
void transition.open();
return;
}
const { active, status } = transition.state.current;
if (active && status !== 'ending') {
void transition.close(elementRef.current);
}
}, [generation, open, transition]);
return {
elementRef,
present: isIndicatorPresent(currentState, transitionState),
state: getRenderedIndicatorState(currentState, snapshotRef.current, transitionState),
};
}
@@ -0,0 +1,20 @@
'use client';
import type { SeekIndicatorCore } from '@videojs/core';
import { createContext, type ProviderProps, useContext } from 'react';
export interface SeekIndicatorContextValue {
state: SeekIndicatorCore.State;
}
const SeekIndicatorContext = createContext<SeekIndicatorContextValue | null>(null);
export function SeekIndicatorProvider({ value, children }: ProviderProps<SeekIndicatorContextValue>) {
return <SeekIndicatorContext.Provider value={value}>{children}</SeekIndicatorContext.Provider>;
}
export function useSeekIndicatorContext(): SeekIndicatorContextValue {
const ctx = useContext(SeekIndicatorContext);
if (!ctx) throw new Error('SeekIndicator child compounds must be used within a SeekIndicator.Root');
return ctx;
}
@@ -0,0 +1,2 @@
export { SeekIndicatorRoot as Root, type SeekIndicatorRootProps as RootProps } from './seek-indicator-root';
export { SeekIndicatorValue as Value, type SeekIndicatorValueProps as ValueProps } from './seek-indicator-value';
@@ -0,0 +1 @@
export * as SeekIndicator from './index.parts';
@@ -0,0 +1,44 @@
'use client';
import { SeekIndicatorCore, SeekIndicatorDataAttrs } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useInputIndicatorRoot } from '../input-indicators/use-input-indicator-root';
import { SeekIndicatorProvider } from './context';
export interface SeekIndicatorRootProps
extends UIComponentProps<'div', SeekIndicatorCore.State>,
SeekIndicatorCore.Props {}
export const SeekIndicatorRoot = forwardRef(function SeekIndicatorRoot(
componentProps: SeekIndicatorRootProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, closeDelay, ...elementProps } = componentProps;
const { elementRef, present, state } = useInputIndicatorRoot(() => new SeekIndicatorCore(), { closeDelay });
if (!present) return null;
return (
<SeekIndicatorProvider value={{ state }}>
{renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: SeekIndicatorDataAttrs,
ref: [forwardedRef, elementRef],
props: [elementProps],
}
)}
</SeekIndicatorProvider>
);
});
export namespace SeekIndicatorRoot {
export type Props = SeekIndicatorRootProps;
export type State = SeekIndicatorCore.State;
}
@@ -0,0 +1,33 @@
'use client';
import { getSeekIndicatorDisplayValue, type SeekIndicatorCore } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useSeekIndicatorContext } from './context';
export interface SeekIndicatorValueProps extends UIComponentProps<'div', SeekIndicatorCore.State> {}
export const SeekIndicatorValue = forwardRef(function SeekIndicatorValue(
componentProps: SeekIndicatorValueProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, ...elementProps } = componentProps;
const { state } = useSeekIndicatorContext();
return renderElement(
'div',
{ render, className, style },
{
state,
ref: forwardedRef,
props: [{ children: getSeekIndicatorDisplayValue(state) }, elementProps],
}
);
});
export namespace SeekIndicatorValue {
export type Props = SeekIndicatorValueProps;
}
+4 -2
View File
@@ -2,7 +2,7 @@
import type { SliderState, StateAttrMap } from '@videojs/core';
import type { SliderThumbProps } from '@videojs/core/dom';
import type { RefCallback } from 'react';
import type { ProviderProps, RefCallback } from 'react';
import { createContext, useContext } from 'react';
export interface SliderContextValue {
@@ -18,7 +18,9 @@ export interface SliderContextValue {
const SliderContext = createContext<SliderContextValue | null>(null);
export function SliderProvider({ value, children }: { value: SliderContextValue; children: React.ReactNode }) {
type SliderProviderProps = ProviderProps<SliderContextValue>;
export function SliderProvider({ value, children }: SliderProviderProps) {
return <SliderContext.Provider value={value}>{children}</SliderContext.Provider>;
}
@@ -0,0 +1,55 @@
'use client';
import { StatusAnnouncerCore } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef, useState, useSyncExternalStore } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { useDestroy } from '../../utils/use-destroy';
import { renderElement } from '../../utils/use-render';
import { useInputActionSubscription } from '../input-indicators/use-input-action-subscription';
export interface StatusAnnouncerProps
extends UIComponentProps<'div', StatusAnnouncerCore.State>,
StatusAnnouncerCore.Props {}
export const StatusAnnouncer = forwardRef(function StatusAnnouncer(
componentProps: StatusAnnouncerProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, closeDelay, labels, ...elementProps } = componentProps;
const [core] = useState(() => new StatusAnnouncerCore());
useDestroy(core);
core.setProps({ closeDelay, labels });
useInputActionSubscription((event, snapshot) => {
core.processEvent(event, snapshot);
});
const state = useSyncExternalStore(
(callback) => core.state.subscribe(callback),
() => core.state.current,
() => core.state.current
);
return renderElement(
'div',
{ render, className, style },
{
state,
ref: forwardedRef,
props: [
{
role: 'status',
'aria-label': state.label ?? undefined,
},
elementProps,
],
}
);
});
export namespace StatusAnnouncer {
export type Props = StatusAnnouncerProps;
export type State = StatusAnnouncerCore.State;
}
@@ -0,0 +1,20 @@
'use client';
import type { StatusIndicatorCore } from '@videojs/core';
import { createContext, type ProviderProps, useContext } from 'react';
export interface StatusIndicatorContextValue {
state: StatusIndicatorCore.State;
}
const StatusIndicatorContext = createContext<StatusIndicatorContextValue | null>(null);
export function StatusIndicatorProvider({ value, children }: ProviderProps<StatusIndicatorContextValue>) {
return <StatusIndicatorContext.Provider value={value}>{children}</StatusIndicatorContext.Provider>;
}
export function useStatusIndicatorContext(): StatusIndicatorContextValue {
const ctx = useContext(StatusIndicatorContext);
if (!ctx) throw new Error('StatusIndicator child compounds must be used within a StatusIndicator.Root');
return ctx;
}
@@ -0,0 +1,8 @@
export {
StatusIndicatorRoot as Root,
type StatusIndicatorRootProps as RootProps,
} from './status-indicator-root';
export {
StatusIndicatorValue as Value,
type StatusIndicatorValueProps as ValueProps,
} from './status-indicator-value';
@@ -0,0 +1 @@
export * as StatusIndicator from './index.parts';
@@ -0,0 +1,48 @@
'use client';
import { StatusIndicatorCore, StatusIndicatorDataAttrs } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useInputIndicatorRoot } from '../input-indicators/use-input-indicator-root';
import { StatusIndicatorProvider } from './context';
export interface StatusIndicatorRootProps
extends UIComponentProps<'div', StatusIndicatorCore.State>,
StatusIndicatorCore.Props {}
export const StatusIndicatorRoot = forwardRef(function StatusIndicatorRoot(
componentProps: StatusIndicatorRootProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, actions, closeDelay, labels, ...elementProps } = componentProps;
const { elementRef, present, state } = useInputIndicatorRoot(() => new StatusIndicatorCore(), {
actions,
closeDelay,
labels,
});
if (!present) return null;
return (
<StatusIndicatorProvider value={{ state }}>
{renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: StatusIndicatorDataAttrs,
ref: [forwardedRef, elementRef],
props: [elementProps],
}
)}
</StatusIndicatorProvider>
);
});
export namespace StatusIndicatorRoot {
export type Props = StatusIndicatorRootProps;
export type State = StatusIndicatorCore.State;
}
@@ -0,0 +1,33 @@
'use client';
import { getStatusIndicatorDisplayValue, type StatusIndicatorCore } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useStatusIndicatorContext } from './context';
export interface StatusIndicatorValueProps extends UIComponentProps<'span', StatusIndicatorCore.State> {}
export const StatusIndicatorValue = forwardRef(function StatusIndicatorValue(
componentProps: StatusIndicatorValueProps,
forwardedRef: ForwardedRef<HTMLSpanElement>
) {
const { render, className, style, ...elementProps } = componentProps;
const { state } = useStatusIndicatorContext();
return renderElement(
'span',
{ render, className, style },
{
state,
ref: forwardedRef,
props: [{ children: getStatusIndicatorDisplayValue(state) }, elementProps],
}
);
});
export namespace StatusIndicatorValue {
export type Props = StatusIndicatorValueProps;
}
@@ -0,0 +1,20 @@
'use client';
import type { VolumeIndicatorCore } from '@videojs/core';
import { createContext, type ProviderProps, useContext } from 'react';
export interface VolumeIndicatorContextValue {
state: VolumeIndicatorCore.State;
}
const VolumeIndicatorContext = createContext<VolumeIndicatorContextValue | null>(null);
export function VolumeIndicatorProvider({ value, children }: ProviderProps<VolumeIndicatorContextValue>) {
return <VolumeIndicatorContext.Provider value={value}>{children}</VolumeIndicatorContext.Provider>;
}
export function useVolumeIndicatorContext(): VolumeIndicatorContextValue {
const ctx = useContext(VolumeIndicatorContext);
if (!ctx) throw new Error('VolumeIndicator child compounds must be used within a VolumeIndicator.Root');
return ctx;
}
@@ -0,0 +1,6 @@
export { VolumeIndicatorFill as Fill, type VolumeIndicatorFillProps as FillProps } from './volume-indicator-fill';
export { VolumeIndicatorRoot as Root, type VolumeIndicatorRootProps as RootProps } from './volume-indicator-root';
export {
VolumeIndicatorValue as Value,
type VolumeIndicatorValueProps as ValueProps,
} from './volume-indicator-value';
@@ -0,0 +1 @@
export * as VolumeIndicator from './index.parts';
@@ -0,0 +1,60 @@
'use client';
import { type VolumeIndicatorCore, VolumeIndicatorCSSVars } from '@videojs/core';
import { isFunction } from '@videojs/utils/predicate';
import type { CSSProperties, ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useVolumeIndicatorContext } from './context';
export interface VolumeIndicatorFillProps extends UIComponentProps<'div', VolumeIndicatorCore.State> {}
export const VolumeIndicatorFill = forwardRef(function VolumeIndicatorFill(
componentProps: VolumeIndicatorFillProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, ...elementProps } = componentProps;
const { state } = useVolumeIndicatorContext();
const fillStyle = getVolumeIndicatorFillStyle(state, style);
return renderElement(
'div',
{ render, className, style: fillStyle },
{
state,
ref: forwardedRef,
props: [elementProps],
}
);
});
export namespace VolumeIndicatorFill {
export type Props = VolumeIndicatorFillProps;
}
function getVolumeIndicatorFillStyle(
state: VolumeIndicatorCore.State,
style: VolumeIndicatorFillProps['style']
): VolumeIndicatorFillProps['style'] {
const vars = state.fill
? ({
[VolumeIndicatorCSSVars.fill]: state.fill,
} as CSSProperties)
: undefined;
if (!vars) return style;
if (isFunction(style)) {
return (nextState) => ({
...style(nextState),
...vars,
});
}
return {
...style,
...vars,
};
}
@@ -0,0 +1,44 @@
'use client';
import { VolumeIndicatorCore, VolumeIndicatorDataAttrs } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useInputIndicatorRoot } from '../input-indicators/use-input-indicator-root';
import { VolumeIndicatorProvider } from './context';
export interface VolumeIndicatorRootProps
extends UIComponentProps<'div', VolumeIndicatorCore.State>,
VolumeIndicatorCore.Props {}
export const VolumeIndicatorRoot = forwardRef(function VolumeIndicatorRoot(
componentProps: VolumeIndicatorRootProps,
forwardedRef: ForwardedRef<HTMLDivElement>
) {
const { render, className, style, closeDelay, ...elementProps } = componentProps;
const { elementRef, present, state } = useInputIndicatorRoot(() => new VolumeIndicatorCore(), { closeDelay });
if (!present) return null;
return (
<VolumeIndicatorProvider value={{ state }}>
{renderElement(
'div',
{ render, className, style },
{
state,
stateAttrMap: VolumeIndicatorDataAttrs,
ref: [forwardedRef, elementRef],
props: [elementProps],
}
)}
</VolumeIndicatorProvider>
);
});
export namespace VolumeIndicatorRoot {
export type Props = VolumeIndicatorRootProps;
export type State = VolumeIndicatorCore.State;
}
@@ -0,0 +1,33 @@
'use client';
import { getVolumeIndicatorDisplayValue, type VolumeIndicatorCore } from '@videojs/core';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useVolumeIndicatorContext } from './context';
export interface VolumeIndicatorValueProps extends UIComponentProps<'span', VolumeIndicatorCore.State> {}
export const VolumeIndicatorValue = forwardRef(function VolumeIndicatorValue(
componentProps: VolumeIndicatorValueProps,
forwardedRef: ForwardedRef<HTMLSpanElement>
) {
const { render, className, style, ...elementProps } = componentProps;
const { state } = useVolumeIndicatorContext();
return renderElement(
'span',
{ render, className, style },
{
state,
ref: forwardedRef,
props: [{ children: getVolumeIndicatorDisplayValue(state) }, elementProps],
}
);
});
export namespace VolumeIndicatorValue {
export type Props = VolumeIndicatorValueProps;
}