feat(packages): compound tooltips with label and shortcut parts (#1494)

This commit is contained in:
Sam Potts
2026-06-18 08:12:16 +10:00
committed by GitHub
parent 9275f93504
commit 035b509c7a
82 changed files with 1509 additions and 238 deletions
+22 -9
View File
@@ -11,7 +11,7 @@ import { usePlayer } from '../player/context';
import type { renderElement as renderElementFn } from '../utils/use-render';
import { renderElement } from '../utils/use-render';
import { useButton } from './hooks/use-button';
import { useAriaKeyShortcuts } from './hotkey/use-aria-key-shortcuts';
import { useHotkeyShortcut } from './hotkey/use-hotkey-shortcut';
import { useOptionalMenuTriggerChildContext } from './menu/context';
import { useOptionalTooltipContext } from './tooltip/context';
@@ -22,13 +22,24 @@ interface MediaButtonConfig<Core extends Required<MediaButtonComponent>> {
selector: Selector<object, InferMediaState<Core> | undefined>;
action: (core: Core, state: InferMediaState<Core>) => void;
hotkeyAction?: string;
hotkeyValue?: (props: Record<string, unknown>) => number | undefined;
tooltipLabel?: (core: Core, state: InferComponentState<Core>) => string | undefined;
}
/** Creates a media button React component from a core class and config. */
export function createMediaButton<Core extends Required<MediaButtonComponent>, Props extends object>(
config: MediaButtonConfig<Core>
): ForwardRefExoticComponent<Props & RefAttributes<HTMLButtonElement>> {
const { displayName, core: CoreClass, stateAttrMap, selector, action, hotkeyAction } = config;
const {
displayName,
core: CoreClass,
stateAttrMap,
selector,
action,
hotkeyAction,
hotkeyValue,
tooltipLabel,
} = config;
// Props that exist in the core's defaultProps are routed to setProps; the rest go to the DOM element.
const corePropKeys = new Set(Object.keys(CoreClass.defaultProps));
@@ -52,8 +63,9 @@ export function createMediaButton<Core extends Required<MediaButtonComponent>, P
const tooltipCtx = useOptionalTooltipContext();
const menuTriggerChild = useOptionalMenuTriggerChildContext();
const setTooltipContent = tooltipCtx?.setContent;
const feature = usePlayer(selector);
const shortcuts = useAriaKeyShortcuts(hotkeyAction);
const shortcut = useHotkeyShortcut(hotkeyAction, hotkeyValue?.(coreProps));
const [core] = useState(() => new CoreClass());
@@ -75,20 +87,21 @@ export function createMediaButton<Core extends Required<MediaButtonComponent>, P
if (feature) core.setMedia(feature);
const state = feature ? (core.getState() as State) : null;
const label = state ? core.getLabel(state) : undefined;
const tooltipText = state ? (tooltipLabel?.(core, state) ?? label) : undefined;
// Forward label to tooltip popup content when inside a Tooltip.Root.
useLayoutEffect(() => {
if (!tooltipCtx) return;
tooltipCtx.setContent(label);
return () => tooltipCtx.setContent(undefined);
}, [tooltipCtx, label]);
if (!setTooltipContent) return;
setTooltipContent(tooltipText ? { label: tooltipText, shortcut: shortcut.shortcut } : undefined);
return () => setTooltipContent(undefined);
}, [setTooltipContent, tooltipText, shortcut.shortcut]);
if (!feature || !state) {
if (__DEV__) logMissingFeature(displayName, selector.displayName ?? displayName);
return null;
}
const attrs = { ...core.getAttrs(state), 'aria-keyshortcuts': shortcuts };
const attrs = { ...core.getAttrs(state), 'aria-keyshortcuts': shortcut.aria };
return renderElement(
'button',
@@ -97,7 +110,7 @@ export function createMediaButton<Core extends Required<MediaButtonComponent>, P
state,
stateAttrMap,
ref: [forwardedRef, buttonRef],
props: [attrs, elementProps, getButtonProps()],
props: [getButtonProps(), elementProps, attrs],
}
);
});
@@ -0,0 +1,58 @@
import { render, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { PlayerContextProvider, type PlayerContextValue } from '../../../player/context';
import { createMockStore } from '../../../testing/mocks';
import { Hotkey } from '../hotkey';
import { useHotkeyShortcut } from '../use-hotkey-shortcut';
function createContextValue(container: HTMLElement): PlayerContextValue {
return {
store: createMockStore() as any,
media: null,
setMedia: vi.fn(),
container,
setContainer: vi.fn(),
};
}
function Wrapper({ children, value }: { children: ReactNode; value: PlayerContextValue }) {
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
}
function Shortcut({ keys }: { keys: string }) {
const shortcut = useHotkeyShortcut('togglePaused');
return (
<>
<span data-testid="shortcut">{shortcut.shortcut}</span>
<span data-testid="aria">{shortcut.aria}</span>
<Hotkey keys={keys} action="togglePaused" />
</>
);
}
describe('useHotkeyShortcut', () => {
it('updates when hotkey registrations change', async () => {
const container = document.createElement('div');
const value = createContextValue(container);
const { rerender } = render(
<Wrapper value={value}>
<Shortcut keys="k" />
</Wrapper>
);
await waitFor(() => expect(document.querySelector('[data-testid="shortcut"]')?.textContent).toBe('K'));
expect(document.querySelector('[data-testid="aria"]')?.textContent).toBe('k');
rerender(
<Wrapper value={value}>
<Shortcut keys="p" />
</Wrapper>
);
await waitFor(() => expect(document.querySelector('[data-testid="shortcut"]')?.textContent).toBe('P'));
expect(document.querySelector('[data-testid="aria"]')?.textContent).toBe('p');
});
});
@@ -1,14 +0,0 @@
'use client';
import { findHotkeyCoordinator } from '@videojs/core/dom';
import { useMemo } from 'react';
import { useContainer } from '../../player/context';
export function useAriaKeyShortcuts(action: string | undefined): string | undefined {
const container = useContainer();
return useMemo(() => {
if (!container || !action) return undefined;
return findHotkeyCoordinator(container)?.getAriaKeys(action);
}, [container, action]);
}
@@ -0,0 +1,27 @@
'use client';
import { getHotkeyCoordinator, type HotkeyShortcutDetails } from '@videojs/core/dom';
import { useEffect, useState } from 'react';
import { useContainer } from '../../player/context';
export function useHotkeyShortcut(action: string | undefined, value?: number | undefined): HotkeyShortcutDetails {
const container = useContainer();
const [shortcut, setShortcut] = useState<HotkeyShortcutDetails>({});
useEffect(() => {
if (!container || !action) {
setShortcut({});
return;
}
const coordinator = getHotkeyCoordinator(container);
const update = () => setShortcut(coordinator.getShortcut(action, value));
update();
return coordinator.subscribeShortcutChanges(update);
}, [container, action, value]);
return shortcut;
}
+1 -1
View File
@@ -29,5 +29,5 @@ export function useHotkey(options: UseHotkeyOptions): void {
disabled,
onActivate: (event, key) => onActivateRef.current(event, key),
});
}, [container, keys, target, repeatable, disabled, onActivateRef]);
}, [container, keys, target, repeatable, disabled]);
}
@@ -71,7 +71,7 @@ export const LiveButton = forwardRef<HTMLButtonElement, LiveButtonProps>(
useLayoutEffect(() => {
if (!tooltipCtx) return;
tooltipCtx.setContent(labelText);
tooltipCtx.setContent(labelText ? { label: labelText } : undefined);
return () => tooltipCtx.setContent(undefined);
}, [tooltipCtx, labelText]);
@@ -32,6 +32,7 @@ export const PlaybackRateButton = createMediaButton<PlaybackRateButtonCore, Play
stateAttrMap: PlaybackRateButtonDataAttrs,
selector: selectPlaybackRate,
action: (core, state) => core.cycle(state),
hotkeyAction: 'speedUp',
});
export namespace PlaybackRateButton {
@@ -0,0 +1,58 @@
import { render, waitFor } from '@testing-library/react';
import { popup } from '@videojs/skins/default/tailwind/video.tailwind';
import type { ReactNode } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { PlayerContextProvider, type PlayerContextValue } from '../../../player/context';
import { createMockStore } from '../../../testing/mocks';
import { Hotkey } from '../../hotkey/hotkey';
import { Tooltip } from '../../tooltip';
import { PlaybackRateButton } from '../playback-rate-button';
function createContextValue(container: HTMLElement): PlayerContextValue {
return {
store: createMockStore({
playbackRates: [0.5, 1, 1.5, 2],
playbackRate: 1,
setPlaybackRate: vi.fn(),
}) as any,
media: null,
setMedia: vi.fn(),
container,
setContainer: vi.fn(),
};
}
function Wrapper({ children, value }: { children: ReactNode; value: PlayerContextValue }) {
return <PlayerContextProvider value={value}>{children}</PlayerContextProvider>;
}
describe('PlaybackRateButton', () => {
it('uses the core label and the speed-up shortcut', async () => {
const container = document.createElement('div');
const value = createContextValue(container);
render(
<Wrapper value={value}>
<Tooltip.Root defaultOpen>
<Tooltip.Trigger render={<PlaybackRateButton data-testid="button" />} />
<Tooltip.Popup data-testid="popup">
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
<Hotkey keys=">" action="speedUp" />
<Hotkey keys="<" action="speedDown" />
</Wrapper>
);
const button = document.querySelector('[data-testid="button"]');
await waitFor(() => {
expect(document.querySelector('[data-testid="popup"] span')?.textContent).toBe('Playback rate 1');
expect(document.querySelector('[data-testid="popup"] kbd')?.textContent).toBe('>');
});
expect(button?.getAttribute('aria-label')).toBe('Playback rate 1');
expect(button?.getAttribute('aria-keyshortcuts')).toBe('>');
});
});
@@ -31,6 +31,8 @@ export const SeekButton = createMediaButton<SeekButtonCore, SeekButtonProps>({
stateAttrMap: SeekButtonDataAttrs,
selector: selectTime,
action: (core, state) => core.seek(state),
hotkeyAction: 'seekStep',
hotkeyValue: (props) => (typeof props.seconds === 'number' ? props.seconds : SeekButtonCore.defaultProps.seconds),
});
export namespace SeekButton {
+7 -2
View File
@@ -4,6 +4,11 @@ import type { StateAttrMap, TooltipCore } from '@videojs/core';
import type { MediaContainer, PositioningBoundary, TooltipApi } from '@videojs/core/dom';
import { createContext, useContext } from 'react';
export interface TooltipContent {
label?: string | undefined;
shortcut?: string | undefined;
}
export interface TooltipContextValue {
core: TooltipCore;
tooltip: TooltipApi;
@@ -11,8 +16,8 @@ export interface TooltipContextValue {
stateAttrMap: StateAttrMap<TooltipCore.State>;
anchorName: string;
popupId: string;
content: string | undefined;
setContent: (content: string | undefined) => void;
content: TooltipContent | undefined;
setContent: (content: TooltipContent | undefined) => void;
boundary: PositioningBoundary;
container: MediaContainer | null;
}
@@ -1,5 +1,7 @@
export { TooltipArrow as Arrow, type TooltipArrowProps as ArrowProps } from './tooltip-arrow';
export { TooltipLabel as Label, type TooltipLabelProps as LabelProps } from './tooltip-label';
export { TooltipPopup as Popup, type TooltipPopupProps as PopupProps } from './tooltip-popup';
export { TooltipProvider as Provider, type TooltipProviderProps as ProviderProps } from './tooltip-provider';
export { TooltipRoot as Root, type TooltipRootProps as RootProps } from './tooltip-root';
export { TooltipShortcut as Shortcut, type TooltipShortcutProps as ShortcutProps } from './tooltip-shortcut';
export { TooltipTrigger as Trigger, type TooltipTriggerProps as TriggerProps } from './tooltip-trigger';
+1 -1
View File
@@ -1,2 +1,2 @@
export { type TooltipContextValue, useOptionalTooltipContext, useTooltipContext } from './context';
export { type TooltipContent, type TooltipContextValue, useOptionalTooltipContext, useTooltipContext } from './context';
export * as Tooltip from './index.parts';
@@ -0,0 +1,76 @@
import { render, waitFor } from '@testing-library/react';
import { popup } from '@videojs/skins/default/tailwind/video.tailwind';
import { useLayoutEffect } from 'react';
import { describe, expect, it } from 'vitest';
import { Tooltip, useOptionalTooltipContext } from '..';
function TooltipContent({ label, shortcut }: { label?: string; shortcut?: string }) {
const tooltip = useOptionalTooltipContext();
const setContent = tooltip?.setContent;
useLayoutEffect(() => {
setContent?.({ label, shortcut });
return () => setContent?.(undefined);
}, [setContent, label, shortcut]);
return null;
}
describe('Tooltip', () => {
it('renders label and kbd shortcut with skin popup.tooltipShortcut from context', async () => {
const { container } = render(
<Tooltip.Root defaultOpen>
<TooltipContent label="Play" shortcut="K" />
<Tooltip.Popup data-testid="popup">
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
);
await waitFor(() => {
expect(container.querySelector('[data-testid="popup"] span')?.textContent).toBe('Play');
const hint = container.querySelector('[data-testid="popup"] kbd');
expect(hint?.textContent).toBe('K');
expect(hint?.localName).toBe('kbd');
});
expect(container.querySelector('[data-testid="popup"] span')?.getAttribute('class')).toBeNull();
});
it('omits shortcut without a shortcut value', async () => {
const { container } = render(
<Tooltip.Root defaultOpen>
<TooltipContent label="Play" />
<Tooltip.Popup data-testid="popup">
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</Tooltip.Popup>
</Tooltip.Root>
);
await waitFor(() => expect(container.querySelector('[data-testid="popup"] span')?.textContent).toBe('Play'));
expect(container.querySelector('[data-testid="popup"] kbd')).toBeNull();
});
it('passes TooltipState to custom popup render functions', async () => {
const { container } = render(
<Tooltip.Root defaultOpen>
<TooltipContent label="Play" shortcut="K" />
<Tooltip.Popup
render={(props, state) => (
<div {...props} data-open={String(state.open)} data-side={state.side} data-testid="popup">
<Tooltip.Label />
<Tooltip.Shortcut className={popup.tooltipShortcut} />
</div>
)}
/>
</Tooltip.Root>
);
await waitFor(() =>
expect(container.querySelector('[data-testid="popup"]')?.getAttribute('data-open')).toBe('true')
);
expect(container.querySelector('[data-testid="popup"]')?.getAttribute('data-side')).toBe('top');
});
});
@@ -0,0 +1,35 @@
'use client';
import type { TooltipState } from '@videojs/core';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useTooltipContext } from './context';
export interface TooltipLabelProps extends UIComponentProps<'span', TooltipState> {}
/** Tooltip body label; defaults to context `content.label` from the linked trigger. */
export const TooltipLabel = forwardRef<HTMLSpanElement, TooltipLabelProps>(function TooltipLabel(
{ render, className, style, children, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap, content } = useTooltipContext();
const body = children !== undefined ? children : (content?.label ?? '');
return renderElement(
'span',
{ render, className, style },
{
state,
stateAttrMap,
ref: forwardedRef,
props: [elementProps, { children: body }],
}
);
});
export namespace TooltipLabel {
export type Props = TooltipLabelProps;
export type State = TooltipState;
}
@@ -1,7 +1,6 @@
'use client';
import type { TooltipState } from '@videojs/core';
import { TooltipCSSVars } from '@videojs/core';
import { TooltipCSSVars, type TooltipState } from '@videojs/core';
import {
getAnchorPositionStyle,
getPopupPositionRect,
@@ -18,6 +17,8 @@ import type { UIComponentProps } from '../../utils/types';
import { useComposedRefs } from '../../utils/use-composed-refs';
import { renderElement } from '../../utils/use-render';
import { useTooltipContext } from './context';
import { TooltipLabel } from './tooltip-label';
import { TooltipShortcut } from './tooltip-shortcut';
export interface TooltipPopupProps extends UIComponentProps<'div', TooltipState> {}
@@ -25,10 +26,10 @@ const POPUP_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0
/** Container for the tooltip content. Positioned relative to the trigger using CSS anchor positioning with a JavaScript fallback. */
export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(function TooltipPopup(
{ render, className, style, ...elementProps },
{ render, className, style, children, ...elementProps },
forwardedRef
) {
const { core, tooltip, state, stateAttrMap, anchorName, popupId, content, boundary, container } = useTooltipContext();
const { core, tooltip, state, stateAttrMap, anchorName, popupId, boundary, container } = useTooltipContext();
const internalRef = useRef<HTMLDivElement>(null);
const popupRef = useCallback(
@@ -49,7 +50,7 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
// CSS Anchor Positioning — computed from state, no measurement needed.
// `position-anchor` is set imperatively in the ref callback above
// because React's style prop silently drops unrecognised CSS properties.
// because React's style prop silently drops unrecognized CSS properties.
const anchorStyle = useMemo(() => {
if (!supportsAnchorPositioning()) return null;
const { positionAnchor: _, ...rest } = getAnchorPositionStyle(
@@ -159,6 +160,16 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
return null;
}
const body =
children !== undefined ? (
children
) : (
<>
<TooltipLabel />
<TooltipShortcut />
</>
);
// Remap DOM focus events to React synthetic event names.
const { onFocusOut, ...restPopupProps } = tooltip.popupProps;
@@ -175,8 +186,7 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
style: positioningStyle,
...core.getPopupAttrs(state),
},
// Forwarded content as default children — explicit children override.
{ children: content },
{ children: body },
{ ...restPopupProps, onBlur: onFocusOut },
elementProps,
],
@@ -16,7 +16,7 @@ import { useDestroy } from '../../utils/use-destroy';
import { useLatestRef } from '../../utils/use-latest-ref';
import { useSafeId } from '../../utils/use-safe-id';
import { useOptionalControlsContext } from '../controls/context';
import { TooltipContextProvider } from './context';
import { type TooltipContent, TooltipContextProvider } from './context';
import { useTooltipGroup } from './group-context';
export interface TooltipRootProps extends CoreTooltipProps {
@@ -85,7 +85,7 @@ export function TooltipRoot({
return instance;
});
const [content, setContent] = useState<string | undefined>();
const [content, setContent] = useState<TooltipContent | undefined>();
const anchorName = useSafeId();
const popupId = useSafeId('tooltip');
@@ -0,0 +1,39 @@
'use client';
import type { TooltipState } from '@videojs/core';
import { forwardRef } from 'react';
import type { UIComponentProps } from '../../utils/types';
import { renderElement } from '../../utils/use-render';
import { useTooltipContext } from './context';
export interface TooltipShortcutProps extends UIComponentProps<'kbd', TooltipState> {}
/** Keyboard shortcut hint; apply skin `className` (CSS: `media-tooltip__kbd`; Tailwind: `popup.tooltipShortcut`). */
export const TooltipShortcut = forwardRef<HTMLElement, TooltipShortcutProps>(function TooltipShortcut(
{ render, className, style, children, ...elementProps },
forwardedRef
) {
const { state, stateAttrMap, content } = useTooltipContext();
const shortcut = children !== undefined && children !== null ? children : (content?.shortcut ?? null);
if (!shortcut) {
return null;
}
return renderElement(
'kbd',
{ render, className, style },
{
state,
stateAttrMap,
ref: forwardedRef,
props: [elementProps, { children: shortcut }],
}
);
});
export namespace TooltipShortcut {
export type Props = TooltipShortcutProps;
export type State = TooltipState;
}