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
@@ -89,13 +89,6 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -160,9 +153,7 @@ export function MinimalAudioSkinTailwind(props: MinimalAudioSkinProps): ReactNod
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -37,13 +37,6 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -106,9 +99,7 @@ export function MinimalAudioSkin(props: MinimalAudioSkinProps): ReactNode {
</PlayButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -89,13 +89,6 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -160,9 +153,7 @@ export function AudioSkinTailwind(props: AudioSkinProps): ReactNode {
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
+1 -10
View File
@@ -37,13 +37,6 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -106,9 +99,7 @@ export function AudioSkin(props: AudioSkinProps): ReactNode {
</PlayButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -110,28 +110,6 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function CaptionsLabel(): string {
const active = usePlayer((s) => Boolean(s.subtitlesShowing));
return active ? 'Disable captions' : 'Enable captions';
}
function PiPLabel(): string {
const pip = usePlayer((s) => Boolean(s.pip));
return pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture';
}
function FullscreenLabel(): string {
const fullscreen = usePlayer((s) => Boolean(s.fullscreen));
return fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -215,9 +193,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -289,9 +265,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</CaptionsButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<CaptionsLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -303,9 +277,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</PiPButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<PiPLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -317,9 +289,7 @@ export function MinimalVideoSkinTailwind(props: MinimalVideoSkinProps): ReactNod
</FullscreenButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<FullscreenLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
</div>
</Tooltip.Provider>
@@ -53,28 +53,6 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function CaptionsLabel(): string {
const active = usePlayer((s) => Boolean(s.subtitlesShowing));
return active ? 'Disable captions' : 'Enable captions';
}
function PiPLabel(): string {
const pip = usePlayer((s) => Boolean(s.pip));
return pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture';
}
function FullscreenLabel(): string {
const fullscreen = usePlayer((s) => Boolean(s.fullscreen));
return fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -149,9 +127,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</PlayButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -226,9 +202,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</CaptionsButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<CaptionsLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -240,9 +214,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</PiPButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<PiPLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -254,9 +226,7 @@ export function MinimalVideoSkin(props: MinimalVideoSkinProps): ReactNode {
</FullscreenButton>
}
/>
<Tooltip.Popup className="media-tooltip">
<FullscreenLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-tooltip" />
</Tooltip.Root>
</div>
</Tooltip.Provider>
@@ -110,28 +110,6 @@ const SliderThumb = forwardRef<HTMLDivElement, ComponentProps<'div'> & { persist
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function CaptionsLabel(): string {
const active = usePlayer((s) => Boolean(s.subtitlesShowing));
return active ? 'Disable captions' : 'Enable captions';
}
function PiPLabel(): string {
const pip = usePlayer((s) => Boolean(s.pip));
return pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture';
}
function FullscreenLabel(): string {
const fullscreen = usePlayer((s) => Boolean(s.fullscreen));
return fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -217,9 +195,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</PlayButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -285,9 +261,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</CaptionsButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<CaptionsLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -299,9 +273,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</PiPButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<PiPLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -313,9 +285,7 @@ export function VideoSkinTailwind(props: VideoSkinProps): ReactNode {
</FullscreenButton>
}
/>
<Tooltip.Popup className={cn(popup.tooltip)}>
<FullscreenLabel />
</Tooltip.Popup>
<Tooltip.Popup className={cn(popup.tooltip)}></Tooltip.Popup>
</Tooltip.Root>
</div>
</Tooltip.Provider>
+4 -34
View File
@@ -53,28 +53,6 @@ const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function
);
});
function PlayLabel(): string {
const paused = usePlayer((s) => Boolean(s.paused));
const ended = usePlayer((s) => Boolean(s.ended));
if (ended) return 'Replay';
return paused ? 'Play' : 'Pause';
}
function CaptionsLabel(): string {
const active = usePlayer((s) => Boolean(s.subtitlesShowing));
return active ? 'Disable captions' : 'Enable captions';
}
function PiPLabel(): string {
const pip = usePlayer((s) => Boolean(s.pip));
return pip ? 'Exit picture-in-picture' : 'Enter picture-in-picture';
}
function FullscreenLabel(): string {
const fullscreen = usePlayer((s) => Boolean(s.fullscreen));
return fullscreen ? 'Exit fullscreen' : 'Enter fullscreen';
}
function VolumePopover(): ReactNode {
const volumeUnsupported = usePlayer((s) => s.volumeAvailability === 'unsupported');
@@ -151,9 +129,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</PlayButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<PlayLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -222,9 +198,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</CaptionsButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<CaptionsLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -236,9 +210,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</PiPButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<PiPLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
<Tooltip.Root side="top">
@@ -250,9 +222,7 @@ export function VideoSkin(props: VideoSkinProps): ReactNode {
</FullscreenButton>
}
/>
<Tooltip.Popup className="media-surface media-tooltip">
<FullscreenLabel />
</Tooltip.Popup>
<Tooltip.Popup className="media-surface media-tooltip" />
</Tooltip.Root>
</div>
</Tooltip.Provider>
+23 -10
View File
@@ -1,17 +1,18 @@
'use client';
import type { InferComponentState, InferMediaState, MediaUIComponent, StateAttrMap } from '@videojs/core';
import type { InferComponentState, InferMediaState, MediaButtonComponent, StateAttrMap } from '@videojs/core';
import { logMissingFeature } from '@videojs/core/dom';
import type { Selector } from '@videojs/store';
import type { ForwardedRef, ForwardRefExoticComponent, RefAttributes } from 'react';
import { forwardRef, useState } from 'react';
import { forwardRef, useLayoutEffect, useState } from 'react';
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 { useOptionalTooltipContext } from './tooltip/context';
interface MediaButtonConfig<Core extends Required<MediaUIComponent>> {
interface MediaButtonConfig<Core extends Required<MediaButtonComponent>> {
displayName: string;
core: { new (): Core; defaultProps: Record<string, unknown> };
stateAttrMap: StateAttrMap<InferComponentState<Core>>;
@@ -20,7 +21,7 @@ interface MediaButtonConfig<Core extends Required<MediaUIComponent>> {
}
/** Creates a media button React component from a core class and config. */
export function createMediaButton<Core extends Required<MediaUIComponent>, Props extends object>(
export function createMediaButton<Core extends Required<MediaButtonComponent>, Props extends object>(
config: MediaButtonConfig<Core>
): ForwardRefExoticComponent<Props & RefAttributes<HTMLButtonElement>> {
const { displayName, core: CoreClass, stateAttrMap, selector, action } = config;
@@ -45,6 +46,7 @@ export function createMediaButton<Core extends Required<MediaUIComponent>, Props
}
}
const tooltipCtx = useOptionalTooltipContext();
const feature = usePlayer(selector);
const [core] = useState(() => new CoreClass());
@@ -56,15 +58,26 @@ export function createMediaButton<Core extends Required<MediaUIComponent>, Props
isDisabled: () => !!coreProps.disabled || !feature,
});
if (!feature) {
// Derive state and label before the hooks boundary so the
// useLayoutEffect below (called unconditionally) can reference them.
type State = InferComponentState<Core>;
if (feature) core.setMedia(feature);
const state = feature ? (core.getState() as State) : null;
const label = state ? core.getLabel(state) : 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 (!feature || !state) {
if (__DEV__) logMissingFeature(displayName, selector.displayName ?? displayName);
return null;
}
type State = InferComponentState<Core>;
core.setMedia(feature);
const state = core.getState() as State;
const attrs = core.getAttrs(state);
return renderElement(
'button',
@@ -73,7 +86,7 @@ export function createMediaButton<Core extends Required<MediaUIComponent>, Props
state,
stateAttrMap,
ref: [forwardedRef, buttonRef],
props: [core.getAttrs(state), elementProps, getButtonProps()],
props: [attrs, elementProps, getButtonProps()],
}
);
});
@@ -11,6 +11,8 @@ export interface TooltipContextValue {
stateAttrMap: StateAttrMap<TooltipCore.State>;
anchorName: string;
popupId: string;
content: string | undefined;
setContent: (content: string | undefined) => void;
}
const TooltipContext = createContext<TooltipContextValue | null>(null);
@@ -22,3 +24,7 @@ export function useTooltipContext(): TooltipContextValue {
if (!ctx) throw new Error('Tooltip compound components must be used within a Tooltip.Root');
return ctx;
}
export function useOptionalTooltipContext(): TooltipContextValue | null {
return useContext(TooltipContext);
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { type TooltipContextValue, useTooltipContext } from './context';
export { type TooltipContextValue, useOptionalTooltipContext, useTooltipContext } from './context';
export * as Tooltip from './index.parts';
@@ -21,7 +21,7 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
{ render, className, style, ...elementProps },
forwardedRef
) {
const { core, tooltip, state, stateAttrMap, anchorName, popupId } = useTooltipContext();
const { core, tooltip, state, stateAttrMap, anchorName, popupId, content } = useTooltipContext();
const internalRef = useRef<HTMLDivElement>(null);
const popupRef = useCallback(
@@ -156,6 +156,8 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
style: positioningStyle,
...core.getPopupAttrs(state),
},
// Forwarded content as default children — explicit children override.
{ children: content },
{ ...restPopupProps, onBlur: onFocusOut },
elementProps,
],
@@ -73,6 +73,8 @@ export function TooltipRoot({
return instance;
});
const [content, setContent] = useState<string | undefined>();
const anchorName = useSafeId();
const popupId = useSafeId('tooltip');
@@ -97,7 +99,9 @@ export function TooltipRoot({
const state = core.getState();
return (
<TooltipContextProvider value={{ core, tooltip, state, stateAttrMap: TooltipDataAttrs, anchorName, popupId }}>
<TooltipContextProvider
value={{ core, tooltip, state, stateAttrMap: TooltipDataAttrs, anchorName, popupId, content, setContent }}
>
{children}
</TooltipContextProvider>
);
@@ -15,7 +15,7 @@ export const TooltipTrigger = forwardRef<HTMLButtonElement, TooltipTriggerProps>
{ render, className, style, ...elementProps },
forwardedRef
) {
const { core, tooltip, state, stateAttrMap, anchorName, popupId } = useTooltipContext();
const { tooltip, state, stateAttrMap, anchorName } = useTooltipContext();
const triggerRef = useCallback(
(el: HTMLButtonElement | null) => {
@@ -42,7 +42,6 @@ export const TooltipTrigger = forwardRef<HTMLButtonElement, TooltipTriggerProps>
props: [
{
type: 'button' as const,
...core.getTriggerAttrs(state, popupId),
},
{ ...restTriggerProps, onFocus: onFocusIn, onBlur: onFocusOut },
elementProps,