mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(packages): add flip functionality to popovers/tooltips/menus (#1857)
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { usePositionedState } from '../use-positioned-state';
|
||||
|
||||
describe('usePositionedState', () => {
|
||||
it('uses the preferred side on the first closed render', () => {
|
||||
const sides: string[] = [];
|
||||
const { result, rerender } = renderHook(
|
||||
({ open }: { open: boolean }) => {
|
||||
const positioned = usePositionedState<{ open: boolean; side: 'top' | 'bottom' }>({ open, side: 'top' });
|
||||
sides.push(positioned.state.side);
|
||||
return positioned;
|
||||
},
|
||||
{ initialProps: { open: true } }
|
||||
);
|
||||
|
||||
act(() => result.current.setPositionedSide('bottom'));
|
||||
expect(result.current.state.side).toBe('bottom');
|
||||
|
||||
sides.length = 0;
|
||||
rerender({ open: false });
|
||||
|
||||
expect(sides[0]).toBe('top');
|
||||
expect(result.current.state.side).toBe('top');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface PopupState {
|
||||
open: boolean;
|
||||
side: string | undefined;
|
||||
}
|
||||
|
||||
interface PositionedState<State extends PopupState> {
|
||||
state: State;
|
||||
preferredSide: State['side'];
|
||||
setPositionedSide: (side: State['side']) => void;
|
||||
}
|
||||
|
||||
export function usePositionedState<State extends PopupState>(preferredState: State): PositionedState<State> {
|
||||
const preferredSide = preferredState.side;
|
||||
const [position, setPosition] = useState<{ preferred: State['side']; side: State['side'] }>({
|
||||
preferred: preferredSide,
|
||||
side: preferredSide,
|
||||
});
|
||||
|
||||
const side = preferredState.open && position.preferred === preferredSide ? position.side : preferredSide;
|
||||
|
||||
const state = useMemo(
|
||||
() => (side === preferredSide ? preferredState : { ...preferredState, side }),
|
||||
[side, preferredState, preferredSide]
|
||||
);
|
||||
|
||||
const setPositionedSide = useCallback(
|
||||
(nextSide: State['side']) =>
|
||||
setPosition((prev) =>
|
||||
prev.preferred === preferredSide && prev.side === nextSide ? prev : { preferred: preferredSide, side: nextSide }
|
||||
),
|
||||
[preferredSide]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferredState.open) setPositionedSide(preferredSide);
|
||||
}, [preferredState.open, preferredSide, setPositionedSide]);
|
||||
|
||||
return { state, preferredSide, setPositionedSide };
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export interface MenuContextValue {
|
||||
core: MenuCore;
|
||||
menu: MenuApi;
|
||||
state: MenuState;
|
||||
preferredSide: MenuState['side'];
|
||||
setPositionedSide: (side: MenuState['side']) => void;
|
||||
stateAttrMap: StateAttrMap<MenuState>;
|
||||
contentId: string;
|
||||
anchorName: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getMenuViewportElement,
|
||||
getMenuViewTransitionAttrs,
|
||||
getPopupPositionRect,
|
||||
getPositionedSide,
|
||||
getPositioningBoundaryRect,
|
||||
getRootPositionOptions,
|
||||
isEventWithinElement,
|
||||
@@ -109,8 +110,19 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
{ render, className, style, onKeyDown, onBlur, ...elementProps },
|
||||
forwardedRef
|
||||
) {
|
||||
const { core, menu, state, stateAttrMap, anchorName, contentId, boundary, container, activeSubMenuId } =
|
||||
useMenuContext();
|
||||
const {
|
||||
core,
|
||||
menu,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
stateAttrMap,
|
||||
anchorName,
|
||||
contentId,
|
||||
boundary,
|
||||
container,
|
||||
activeSubMenuId,
|
||||
} = useMenuContext();
|
||||
const subMenuCtx = useSubMenuContext();
|
||||
const isSubmenu = state.isSubmenu;
|
||||
|
||||
@@ -229,7 +241,10 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
const rootComposedRef = useComposedRefs(forwardedRef, contentRef, internalRef);
|
||||
const menuViewComposedRef = useComposedRefs(forwardedRef, setMenuViewElement);
|
||||
|
||||
const positionOptions = useMemo(() => getRootPositionOptions(state.side, state.align), [state.side, state.align]);
|
||||
const positionOptions = useMemo(
|
||||
() => getRootPositionOptions(preferredSide, state.align),
|
||||
[preferredSide, state.align]
|
||||
);
|
||||
|
||||
const anchorStyle = useMemo(() => {
|
||||
if (isSubmenu || !positionOptions || !supportsAnchorPositioning()) return null;
|
||||
@@ -237,7 +252,7 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
return rest as CSSProperties;
|
||||
}, [isSubmenu, anchorName, positionOptions]);
|
||||
|
||||
const [manualStyle, setManualStyle] = useState<CSSProperties | null>(null);
|
||||
const [position, setPosition] = useState<CSSProperties | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isSubmenu) return;
|
||||
@@ -264,7 +279,7 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
useLayoutEffect(() => {
|
||||
if (isSubmenu) return;
|
||||
if (!state.open) {
|
||||
setManualStyle(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -284,15 +299,16 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
const root = contentElement.getRootNode() as Document | ShadowRoot;
|
||||
const boundaryElement = resolvePositioningBoundary(boundary, { container, root });
|
||||
const anchorSupported = supportsAnchorPositioning();
|
||||
const contentRect = anchorSupported ? undefined : getPopupPositionRect(contentElement);
|
||||
let contentRect = getPopupPositionRect(contentElement, rootPositionOptions.side);
|
||||
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
|
||||
const offsets = resolveOffsets(contentElement);
|
||||
let side = getPositionedSide(triggerRect, contentRect, boundaryRect, rootPositionOptions, offsets);
|
||||
|
||||
let nextStyle = getAnchorPositionStyle(
|
||||
anchorName,
|
||||
rootPositionOptions,
|
||||
{ ...rootPositionOptions, side },
|
||||
triggerRect,
|
||||
contentRect,
|
||||
anchorSupported ? undefined : contentRect,
|
||||
boundaryRect,
|
||||
offsets
|
||||
);
|
||||
@@ -305,11 +321,13 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
);
|
||||
|
||||
if (!anchorSupported) {
|
||||
contentRect = getPopupPositionRect(contentElement, rootPositionOptions.side);
|
||||
side = getPositionedSide(triggerRect, contentRect, boundaryRect, rootPositionOptions, offsets);
|
||||
nextStyle = getAnchorPositionStyle(
|
||||
anchorName,
|
||||
rootPositionOptions,
|
||||
{ ...rootPositionOptions, side },
|
||||
triggerRect,
|
||||
getPopupPositionRect(contentElement),
|
||||
contentRect,
|
||||
boundaryRect,
|
||||
offsets
|
||||
);
|
||||
@@ -317,7 +335,8 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
|
||||
const { positionAnchor: _, ...rootStyle } = nextStyle;
|
||||
|
||||
setManualStyle(rootStyle as CSSProperties);
|
||||
setPosition(rootStyle as CSSProperties);
|
||||
setPositionedSide(side);
|
||||
}
|
||||
|
||||
measure();
|
||||
@@ -356,7 +375,7 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [isSubmenu, state.open, anchorName, positionOptions, menu, boundary, container]);
|
||||
}, [isSubmenu, state.open, anchorName, positionOptions, menu, boundary, container, setPositionedSide]);
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -393,7 +412,7 @@ export const MenuContent = forwardRef<HTMLDivElement, MenuContentProps>(function
|
||||
|
||||
if (!state.open) return null;
|
||||
|
||||
const positioningStyle = manualStyle ?? anchorStyle ?? POPOVER_RESET;
|
||||
const positioningStyle = position ?? anchorStyle ?? POPOVER_RESET;
|
||||
|
||||
return renderElement(
|
||||
'div',
|
||||
|
||||
@@ -10,6 +10,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 { usePositionedState } from '../hooks/use-positioned-state';
|
||||
import { MenuContextProvider, SubMenuContextProvider, useOptionalMenuContext } from './context';
|
||||
|
||||
export interface MenuRootProps extends MenuCore.Props {
|
||||
@@ -98,11 +99,12 @@ export function MenuRoot({
|
||||
useDestroy(menu);
|
||||
|
||||
const input = useSnapshot(menu.input);
|
||||
const state = useMemo(() => {
|
||||
const preferredState = useMemo(() => {
|
||||
core.setProps({ side, align, closeOnEscape, closeOnOutsideClick, isSubmenu });
|
||||
core.setInput(input);
|
||||
return core.getState();
|
||||
}, [core, input, side, align, closeOnEscape, closeOnOutsideClick, isSubmenu]);
|
||||
const { state, preferredSide, setPositionedSide } = usePositionedState(preferredState);
|
||||
|
||||
// Subscribe to navigation state — used by Content/Trigger when this is a root menu.
|
||||
const navigationInput = useSnapshot(menu.navigationInput);
|
||||
@@ -116,6 +118,8 @@ export function MenuRoot({
|
||||
core,
|
||||
menu,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
stateAttrMap: MenuDataAttrs,
|
||||
contentId,
|
||||
anchorName,
|
||||
@@ -131,6 +135,8 @@ export function MenuRoot({
|
||||
core,
|
||||
menu,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
contentId,
|
||||
anchorName,
|
||||
boundary,
|
||||
|
||||
@@ -19,7 +19,14 @@ import { MenuSeparator } from '../menu-separator';
|
||||
import { MenuTrigger } from '../menu-trigger';
|
||||
import { MenuView } from '../menu-view';
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect {
|
||||
return new DOMRect(x, y, width, height);
|
||||
}
|
||||
|
||||
function SubmenuFixture() {
|
||||
return (
|
||||
@@ -374,6 +381,40 @@ function DynamicMenuFixture({ showCaptions }: { showCaptions: boolean }) {
|
||||
}
|
||||
|
||||
describe('MenuContent', () => {
|
||||
it('exposes the positioned side on root content', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.testid === 'trigger') return makeDOMRect(100, 10, 40, 20);
|
||||
if (this.dataset.testid === 'content') return makeDOMRect(0, 0, 100, 60);
|
||||
return makeDOMRect(0, 0, 300, 200);
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'content' ? 100 : 0;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'content' ? 60 : 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<MenuRoot defaultOpen side="top" boundary="viewport">
|
||||
<MenuTrigger
|
||||
render={(props, state) => <button {...props} data-render-side={state.side} data-testid="trigger" />}
|
||||
>
|
||||
Settings
|
||||
</MenuTrigger>
|
||||
<MenuContent data-testid="content">
|
||||
<MenuView>
|
||||
<MenuItem>Auto</MenuItem>
|
||||
</MenuView>
|
||||
</MenuContent>
|
||||
</MenuRoot>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('content').getAttribute('data-side')).toBe('bottom');
|
||||
expect(screen.getByTestId('trigger').getAttribute('data-render-side')).toBe('bottom');
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes menu state data attributes to content elements', async () => {
|
||||
render(
|
||||
<MenuRoot defaultOpen side="top" align="end">
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface PopoverContextValue {
|
||||
core: PopoverCore;
|
||||
popover: PopoverApi;
|
||||
state: PopoverCore.State;
|
||||
preferredSide: PopoverCore.State['side'];
|
||||
setPositionedSide: (side: PopoverCore.State['side']) => void;
|
||||
stateAttrMap: StateAttrMap<PopoverCore.State>;
|
||||
anchorName: string;
|
||||
popupId: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { PopoverState } from '@videojs/core';
|
||||
import {
|
||||
getAnchorPositionStyle,
|
||||
getPopupPositionRect,
|
||||
getPositionedSide,
|
||||
getPositioningBoundaryRect,
|
||||
isEventWithinElement,
|
||||
resolveOffsets,
|
||||
@@ -27,7 +28,18 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
{ render, className, style, ...elementProps },
|
||||
forwardedRef
|
||||
) {
|
||||
const { core, popover, state, stateAttrMap, anchorName, popupId, boundary, container } = usePopoverContext();
|
||||
const {
|
||||
core,
|
||||
popover,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
stateAttrMap,
|
||||
anchorName,
|
||||
popupId,
|
||||
boundary,
|
||||
container,
|
||||
} = usePopoverContext();
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const popupRef = useCallback(
|
||||
@@ -44,7 +56,7 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
const posOpts = useMemo(() => ({ side: state.side, align: state.align }), [state.side, state.align]);
|
||||
const posOpts = useMemo(() => ({ side: preferredSide, align: state.align }), [preferredSide, state.align]);
|
||||
|
||||
// CSS Anchor Positioning — computed from state, no measurement needed.
|
||||
// `position-anchor` is set imperatively in the ref callback above
|
||||
@@ -55,12 +67,12 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
return rest as CSSProperties;
|
||||
}, [anchorName, posOpts]);
|
||||
|
||||
// Manual fallback — measure rects after layout, before paint.
|
||||
const [manualStyle, setManualStyle] = useState<CSSProperties | null>(null);
|
||||
// Measure rects after layout, before paint.
|
||||
const [position, setPosition] = useState<CSSProperties | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!state.open) {
|
||||
setManualStyle(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,20 +84,22 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
const triggerRect = triggerEl.getBoundingClientRect();
|
||||
const root = popupEl.getRootNode() as Document | ShadowRoot;
|
||||
const boundaryElement = resolvePositioningBoundary(boundary, { container, root });
|
||||
const popupRect = supportsAnchorPositioning() ? undefined : getPopupPositionRect(popupEl);
|
||||
const popupRect = getPopupPositionRect(popupEl, posOpts.side);
|
||||
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
|
||||
const offsets = resolveOffsets(popupEl);
|
||||
const side = getPositionedSide(triggerRect, popupRect, boundaryRect, posOpts, offsets);
|
||||
|
||||
const { positionAnchor: _, ...nextStyle } = getAnchorPositionStyle(
|
||||
anchorName,
|
||||
posOpts,
|
||||
{ ...posOpts, side },
|
||||
triggerRect,
|
||||
popupRect,
|
||||
supportsAnchorPositioning() ? undefined : popupRect,
|
||||
boundaryRect,
|
||||
offsets
|
||||
);
|
||||
|
||||
setManualStyle(nextStyle as CSSProperties);
|
||||
setPosition(nextStyle as CSSProperties);
|
||||
setPositionedSide(side);
|
||||
}
|
||||
|
||||
measure();
|
||||
@@ -137,11 +151,10 @@ export const PopoverPopup = forwardRef<HTMLDivElement, PopoverPopupProps>(functi
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [state.open, anchorName, posOpts, popover, boundary, container]);
|
||||
}, [state.open, anchorName, posOpts, popover, boundary, container, setPositionedSide]);
|
||||
|
||||
// Anchor path uses computed styles; manual path uses measured styles;
|
||||
// fallback resets UA [popover] defaults until positioning is computed.
|
||||
const positioningStyle = manualStyle ?? anchorStyle ?? POPOVER_RESET;
|
||||
// Use measured styles once available; reset UA [popover] defaults until then.
|
||||
const positioningStyle = position ?? anchorStyle ?? POPOVER_RESET;
|
||||
|
||||
// --- Visibility ---
|
||||
|
||||
|
||||
@@ -16,6 +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 { usePositionedState } from '../hooks/use-positioned-state';
|
||||
import { PopoverContextProvider } from './context';
|
||||
|
||||
export interface PopoverRootProps extends CorePopoverProps {
|
||||
@@ -112,11 +113,22 @@ export function PopoverRoot({
|
||||
|
||||
const input = useSnapshot(popover.input);
|
||||
core.setInput(input);
|
||||
const state = core.getState();
|
||||
const { state, preferredSide, setPositionedSide } = usePositionedState(core.getState());
|
||||
|
||||
return (
|
||||
<PopoverContextProvider
|
||||
value={{ core, popover, state, stateAttrMap: PopoverDataAttrs, anchorName, popupId, boundary, container }}
|
||||
value={{
|
||||
core,
|
||||
popover,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
stateAttrMap: PopoverDataAttrs,
|
||||
anchorName,
|
||||
popupId,
|
||||
boundary,
|
||||
container,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</PopoverContextProvider>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import * as Popover from '../index.parts';
|
||||
|
||||
function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect {
|
||||
return new DOMRect(x, y, width, height);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Popover', () => {
|
||||
it('exposes the positioned side on every part', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.testid === 'trigger') return makeDOMRect(100, 10, 40, 20);
|
||||
if (this.dataset.testid === 'popup') return makeDOMRect(0, 0, 100, 60);
|
||||
return makeDOMRect(0, 0, 300, 200);
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'popup' ? 100 : 0;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'popup' ? 60 : 0;
|
||||
});
|
||||
|
||||
render(
|
||||
<Popover.Root defaultOpen side="top" boundary="viewport">
|
||||
<Popover.Trigger data-testid="trigger">Open</Popover.Trigger>
|
||||
<Popover.Popup data-testid="popup">
|
||||
Content
|
||||
<Popover.Arrow data-testid="arrow" />
|
||||
</Popover.Popup>
|
||||
</Popover.Root>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const part of ['trigger', 'popup', 'arrow']) {
|
||||
expect(screen.getByTestId(part).getAttribute('data-side')).toBe('bottom');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ export interface TooltipContextValue {
|
||||
core: TooltipCore;
|
||||
tooltip: TooltipApi;
|
||||
state: TooltipCore.State;
|
||||
preferredSide: TooltipCore.State['side'];
|
||||
setPositionedSide: (side: TooltipCore.State['side']) => void;
|
||||
stateAttrMap: StateAttrMap<TooltipCore.State>;
|
||||
anchorName: string;
|
||||
popupId: string;
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Tooltip, useOptionalTooltipContext } from '..';
|
||||
|
||||
function makeDOMRect(x: number, y: number, width: number, height: number): DOMRect {
|
||||
return new DOMRect(x, y, width, height);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function TooltipContent({ label, shortcut }: { label?: string; shortcut?: string }) {
|
||||
const tooltip = useOptionalTooltipContext();
|
||||
const setContent = tooltip?.setContent;
|
||||
@@ -18,6 +26,37 @@ function TooltipContent({ label, shortcut }: { label?: string; shortcut?: string
|
||||
}
|
||||
|
||||
describe('Tooltip', () => {
|
||||
it('exposes the positioned side on every part', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.dataset.testid === 'trigger') return makeDOMRect(100, 10, 40, 20);
|
||||
if (this.dataset.testid === 'popup') return makeDOMRect(0, 0, 100, 60);
|
||||
return makeDOMRect(0, 0, 300, 200);
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'popup' ? 100 : 0;
|
||||
});
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return this.dataset.testid === 'popup' ? 60 : 0;
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<Tooltip.Root defaultOpen side="top" boundary="viewport">
|
||||
<Tooltip.Trigger data-testid="trigger">Play</Tooltip.Trigger>
|
||||
<Tooltip.Popup data-testid="popup">
|
||||
<Tooltip.Label data-testid="label">Play</Tooltip.Label>
|
||||
<Tooltip.Shortcut data-testid="shortcut">K</Tooltip.Shortcut>
|
||||
<Tooltip.Arrow data-testid="arrow" />
|
||||
</Tooltip.Popup>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
for (const part of ['trigger', 'popup', 'label', 'shortcut', 'arrow']) {
|
||||
expect(container.querySelector(`[data-testid="${part}"]`)?.getAttribute('data-side')).toBe('bottom');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('renders label and kbd shortcut with skin popup.tooltipShortcut from context', async () => {
|
||||
const { container } = render(
|
||||
<Tooltip.Root defaultOpen>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { TooltipCSSVars, type TooltipState } from '@videojs/core';
|
||||
import {
|
||||
getAnchorPositionStyle,
|
||||
getPopupPositionRect,
|
||||
getPositionedSide,
|
||||
getPositioningBoundaryRect,
|
||||
isEventWithinElement,
|
||||
resolveOffsets,
|
||||
@@ -29,7 +30,18 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
{ render, className, style, children, ...elementProps },
|
||||
forwardedRef
|
||||
) {
|
||||
const { core, tooltip, state, stateAttrMap, anchorName, popupId, boundary, container } = useTooltipContext();
|
||||
const {
|
||||
core,
|
||||
tooltip,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
stateAttrMap,
|
||||
anchorName,
|
||||
popupId,
|
||||
boundary,
|
||||
container,
|
||||
} = useTooltipContext();
|
||||
const internalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const popupRef = useCallback(
|
||||
@@ -46,7 +58,7 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
|
||||
// --- Positioning ---
|
||||
|
||||
const posOpts = useMemo(() => ({ side: state.side, align: state.align }), [state.side, state.align]);
|
||||
const posOpts = useMemo(() => ({ side: preferredSide, align: state.align }), [preferredSide, state.align]);
|
||||
|
||||
// CSS Anchor Positioning — computed from state, no measurement needed.
|
||||
// `position-anchor` is set imperatively in the ref callback above
|
||||
@@ -65,12 +77,12 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
return rest as CSSProperties;
|
||||
}, [anchorName, posOpts]);
|
||||
|
||||
// Manual fallback — measure rects after layout, before paint.
|
||||
const [manualStyle, setManualStyle] = useState<CSSProperties | null>(null);
|
||||
// Measure rects after layout, before paint.
|
||||
const [position, setPosition] = useState<CSSProperties | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!state.open) {
|
||||
setManualStyle(null);
|
||||
setPosition(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,21 +94,23 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
const triggerRect = triggerEl.getBoundingClientRect();
|
||||
const root = popupEl.getRootNode() as Document | ShadowRoot;
|
||||
const boundaryElement = resolvePositioningBoundary(boundary, { container, root });
|
||||
const popupRect = supportsAnchorPositioning() ? undefined : getPopupPositionRect(popupEl);
|
||||
const popupRect = getPopupPositionRect(popupEl, posOpts.side);
|
||||
const boundaryRect = getPositioningBoundaryRect(boundaryElement);
|
||||
const offsets = resolveOffsets(popupEl, TooltipCSSVars);
|
||||
const side = getPositionedSide(triggerRect, popupRect, boundaryRect, posOpts, offsets);
|
||||
|
||||
const { positionAnchor: _, ...nextStyle } = getAnchorPositionStyle(
|
||||
anchorName,
|
||||
posOpts,
|
||||
{ ...posOpts, side },
|
||||
triggerRect,
|
||||
popupRect,
|
||||
supportsAnchorPositioning() ? undefined : popupRect,
|
||||
boundaryRect,
|
||||
offsets,
|
||||
TooltipCSSVars
|
||||
);
|
||||
|
||||
setManualStyle(nextStyle as CSSProperties);
|
||||
setPosition(nextStyle as CSSProperties);
|
||||
setPositionedSide(side);
|
||||
}
|
||||
|
||||
measure();
|
||||
@@ -148,11 +162,10 @@ export const TooltipPopup = forwardRef<HTMLDivElement, TooltipPopupProps>(functi
|
||||
window.removeEventListener('scroll', reposition, true);
|
||||
window.removeEventListener('resize', reposition);
|
||||
};
|
||||
}, [state.open, anchorName, posOpts, tooltip, boundary, container]);
|
||||
}, [state.open, anchorName, posOpts, tooltip, boundary, container, setPositionedSide]);
|
||||
|
||||
// Anchor path uses computed styles; manual path uses measured styles;
|
||||
// fallback resets UA [popover] defaults until positioning is computed.
|
||||
const positioningStyle = manualStyle ?? anchorStyle ?? POPUP_RESET;
|
||||
// Use measured styles once available; reset UA [popover] defaults until then.
|
||||
const positioningStyle = position ?? anchorStyle ?? POPUP_RESET;
|
||||
|
||||
// --- Visibility ---
|
||||
|
||||
|
||||
@@ -16,6 +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 { usePositionedState } from '../hooks/use-positioned-state';
|
||||
import { type TooltipContent, TooltipContextProvider } from './context';
|
||||
import { useTooltipGroup } from './group-context';
|
||||
|
||||
@@ -115,7 +116,7 @@ export function TooltipRoot({
|
||||
|
||||
const input = useSnapshot(tooltip.input);
|
||||
core.setInput(input);
|
||||
const state = core.getState();
|
||||
const { state, preferredSide, setPositionedSide } = usePositionedState(core.getState());
|
||||
|
||||
return (
|
||||
<TooltipContextProvider
|
||||
@@ -123,6 +124,8 @@ export function TooltipRoot({
|
||||
core,
|
||||
tooltip,
|
||||
state,
|
||||
preferredSide,
|
||||
setPositionedSide,
|
||||
stateAttrMap: TooltipDataAttrs,
|
||||
anchorName,
|
||||
popupId,
|
||||
|
||||
Reference in New Issue
Block a user