Skip to content
52 changes: 52 additions & 0 deletions packages/react/src/composite/root/CompositeRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,58 @@ describe('Composite', () => {
expect(item1).toHaveFocus();
});

it('keeps native input behavior when the native target differs from the synthetic target', async () => {
render(
<CompositeRoot orientation="horizontal">
<CompositeItem data-testid="1">1</CompositeItem>
<div data-testid="host" />
<CompositeItem data-testid="2">2</CompositeItem>
</CompositeRoot>,
);

const item1 = screen.getByTestId('1');
const item2 = screen.getByTestId('2');
const host = screen.getByTestId('host');
const input = document.createElement('input');

input.type = 'text';
input.value = 'abcd';
input.setSelectionRange(2, 2);

const focusEvent = new FocusEvent('focusin', { bubbles: true });
Object.defineProperty(focusEvent, 'composedPath', {
configurable: true,
value: () => [input, host],
});

fireEvent(host, focusEvent);

// Focusing a native input within a composite selects the whole value so
// the first arrow key returns control to the textbox before moving focus.
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(4);

act(() => item1.focus());

input.setSelectionRange(1, 1);

const keyDownEvent = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
});
Object.defineProperty(keyDownEvent, 'composedPath', {
configurable: true,
value: () => [input, host],
});

fireEvent(host, keyDownEvent);
await flushMicrotasks();

expect(item1).toHaveFocus();
expect(item2).not.toHaveFocus();
});

it.skipIf(isJSDOM)('updates the order of items', async () => {
function App(props: { items: string[] }) {
return (
Expand Down
44 changes: 44 additions & 0 deletions packages/react/src/floating-ui-react/hooks/useHover.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,50 @@ describe.skipIf(!isJSDOM)('useHover', () => {
fireEvent.mouseLeave(button);
});

test('does not treat a synthetic child target as inactive when the native path differs', async () => {
const onOpenChange = vi.fn();

function App() {
const [open, setOpen] = React.useState(true);
const { refs, context } = useFloating({
open,
onOpenChange(nextOpen, details) {
onOpenChange(nextOpen, details);
setOpen(nextOpen);
},
});
const { getReferenceProps, getFloatingProps } = useInteractions([useHover(context)]);

return (
<React.Fragment>
<button ref={refs.setReference} {...getReferenceProps()}>
<span data-testid="child" />
</button>
{open && <div role="tooltip" ref={refs.setFloating} {...getFloatingProps()} />}
</React.Fragment>
);
}

render(<App />);

const child = screen.getByTestId('child');
const event = new MouseEvent('mousemove', { bubbles: true });

// Deliberately skew the native path so `getTarget(nativeEvent)` resolves
// outside the trigger while React's synthetic `event.target` remains `child`.
Object.defineProperty(event, 'composedPath', {
configurable: true,
value: () => [document.body, child.parentElement, child],
});

fireEvent(child, event);

await flushMicrotasks();

expect(onOpenChange).toHaveBeenCalledTimes(0);
expect(screen.queryByRole('tooltip')).not.toBe(null);
});

test('cleans up blockPointerEvents if trigger changes', async () => {
vi.useRealTimers();
const user = userEvent.setup();
Expand Down
2 changes: 1 addition & 1 deletion packages/react/src/floating-ui-react/hooks/useHover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ export function useHover(
// wasn't used to open the floating element.
const isOverInactiveTrigger =
store.select('domReferenceElement') &&
!contains(store.select('domReferenceElement'), getTarget(nativeEvent) as Element);
!contains(store.select('domReferenceElement'), event.target as Element);

function handleMouseMove() {
if (!blockMouseMoveRef.current && (!store.select('open') || isOverInactiveTrigger)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,78 @@ describe.skipIf(!isJSDOM)('useHoverReferenceInteraction', () => {
expect(screen.queryByRole('tooltip')).not.toBe(null);
});

it('does not treat a synthetic child target as inactive when the native path differs', async () => {
const onOpenChange = vi.fn();

function App() {
const [open, setOpen] = React.useState(true);
const triggerElementRef = React.useRef<Element | null>(null);
const { refs, context } = useFloating({
open,
onOpenChange(nextOpen, details) {
onOpenChange(nextOpen, details);
setOpen(nextOpen);
},
});

const hoverProps = useHoverReferenceInteraction(context, {
mouseOnly: true,
restMs: 100,
delay: { close: 0 },
move: false,
triggerElementRef,
});

return (
<React.Fragment>
<div data-testid="wrapper" {...hoverProps}>
<button
data-testid="trigger"
ref={(node) => {
refs.setReference(node);
triggerElementRef.current = node;
}}
>
<span data-testid="child" />
</button>
</div>
{open && <div role="tooltip" ref={refs.setFloating} />}
</React.Fragment>
);
}

render(<App />);

const wrapper = screen.getByTestId('wrapper');
const child = screen.getByTestId('child');

fireEvent.pointerEnter(wrapper, { pointerType: 'mouse' });
fireEvent.mouseEnter(wrapper);

const event = new MouseEvent('mousemove', { bubbles: true });
Object.defineProperties(event, {
composedPath: {
configurable: true,
value: () => [document.body, child, wrapper],
},
movementX: {
configurable: true,
value: 10,
},
movementY: {
configurable: true,
value: 0,
},
});

fireEvent(child, event);

await flushMicrotasks();

expect(onOpenChange).toHaveBeenCalledTimes(0);
expect(screen.queryByRole('tooltip')).not.toBe(null);
});

it('treats disabled child trigger as inactive in wrapper fallback mode', async () => {
const onOpenChange = vi.fn();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -386,11 +386,7 @@ export function useHoverReferenceInteraction(

const currentDomReference = store.select('domReferenceElement');
const currentOpen = store.select('open');
const isOverInactive = isOverInactiveTrigger(
currentDomReference,
trigger,
getTarget(nativeEvent),
);
const isOverInactive = isOverInactiveTrigger(currentDomReference, trigger, event.target);

if (mouseOnly && !isMouseLikePointerType(instance.pointerType)) {
return;
Expand Down
4 changes: 2 additions & 2 deletions packages/react/src/scroll-area/root/ScrollAreaRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { ScrollAreaScrollbarDataAttributes } from '../scrollbar/ScrollAreaScroll
import { styleDisableScrollbar } from '../../utils/styles';
import { useBaseUiId } from '../../utils/useBaseUiId';
import { scrollAreaStateAttributesMapping } from './stateAttributes';
import { contains, getTarget } from '../../floating-ui-react/utils';
import { contains } from '../../floating-ui-react/utils';
import { useCSPContext } from '../../csp-provider/CSPContext';

const DEFAULT_COORDS = { x: 0, y: 0 };
Expand Down Expand Up @@ -202,7 +202,7 @@ export const ScrollAreaRoot = React.forwardRef(function ScrollAreaRoot(
handleTouchModalityChange(event);

if (event.pointerType !== 'touch') {
const isTargetRootChild = contains(rootRef.current, getTarget(event.nativeEvent) as Element);
const isTargetRootChild = contains(rootRef.current, event.target as Element);
setHovering(isTargetRootChild);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,119 @@ describe('<ScrollArea.Scrollbar />', () => {
});
});

describe('data-hovering attribute', () => {
it('adds [data-hovering] when the synthetic pointer target differs from the native path', async () => {
await render(
<ScrollArea.Root data-testid="root" style={{ width: 200, height: 200 }}>
<ScrollArea.Viewport data-testid="viewport" style={{ width: '100%', height: '100%' }}>
<div style={{ width: 1000, height: 1000 }} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" data-testid="vertical" keepMounted />
</ScrollArea.Root>,
);

const viewport = screen.getByTestId('viewport');
const verticalScrollbar = screen.getByTestId('vertical');

// Real browser runs can start with the viewport already hovered because
// ScrollAreaViewport syncs `:hover` on mount.
fireEvent.pointerLeave(viewport, { pointerType: 'mouse' });
expect(verticalScrollbar).not.toHaveAttribute('data-hovering');

const PointerEventCtor = window.PointerEvent ?? window.Event;
const event = new PointerEventCtor('pointerover', {
bubbles: true,
});

Object.defineProperties(event, {
composedPath: {
configurable: true,
value: () => [document.body, viewport],
},
pointerType: {
configurable: true,
value: 'mouse',
},
});

fireEvent(viewport, event);

expect(verticalScrollbar).toHaveAttribute('data-hovering', '');

fireEvent.pointerLeave(viewport, { pointerType: 'mouse' });

expect(verticalScrollbar).not.toHaveAttribute('data-hovering');
});
});

describe('track pointer down', () => {
it('ignores thumb clicks when the native path differs from the synthetic target', async () => {
await render(
<ScrollArea.Root style={{ width: 200, height: 200 }}>
<ScrollArea.Viewport data-testid="viewport" style={{ width: '100%', height: '100%' }}>
<div style={{ width: 1000, height: 1000 }} />
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" data-testid="vertical" keepMounted>
<ScrollArea.Thumb data-testid="thumb" />
</ScrollArea.Scrollbar>
</ScrollArea.Root>,
);

const viewport = screen.getByTestId('viewport') as HTMLDivElement;
const verticalScrollbar = screen.getByTestId('vertical');
const thumb = screen.getByTestId('thumb');

Object.defineProperties(viewport, {
clientHeight: {
configurable: true,
value: 200,
},
scrollHeight: {
configurable: true,
value: 1000,
},
scrollTop: {
configurable: true,
writable: true,
value: 0,
},
});

Object.defineProperties(verticalScrollbar, {
offsetHeight: {
configurable: true,
value: 200,
},
getBoundingClientRect: {
configurable: true,
value: () => ({
top: 0,
}),
},
});

Object.defineProperty(thumb, 'offsetHeight', {
configurable: true,
value: 40,
});

const event = new MouseEvent('pointerdown', {
bubbles: true,
button: 0,
clientY: 160,
});

Object.defineProperty(event, 'composedPath', {
configurable: true,
value: () => [thumb, verticalScrollbar],
});

fireEvent(verticalScrollbar, event);

expect(viewport.scrollTop).toBe(0);
});
});

describe.skipIf(isJSDOM)('data overflow attributes (scrollbars)', () => {
const VIEWPORT_SIZE = 200;
const SCROLLABLE_CONTENT_SIZE = 1000;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import { getTarget } from '../../floating-ui-react/utils';
import type { BaseUIComponentProps, HTMLProps } from '../../utils/types';
import { contains, getTarget } from '../../floating-ui-react/utils';
import { useScrollAreaRootContext } from '../root/ScrollAreaRootContext';
import { ScrollAreaScrollbarContext } from './ScrollAreaScrollbarContext';
import { useRenderElement } from '../../utils/useRenderElement';
Expand Down Expand Up @@ -126,8 +126,12 @@ export const ScrollAreaScrollbar = React.forwardRef(function ScrollAreaScrollbar
return;
}

// Ignore clicks on thumb
if (event.currentTarget !== getTarget(event.nativeEvent)) {
const target = getTarget(event.nativeEvent) as Element | null;
const thumb = orientation === 'vertical' ? thumbYRef.current : thumbXRef.current;

// Ignore clicks on thumb, including cases where React retargets the
// synthetic event to the track host across a shadow boundary.
if (thumb && contains(thumb, target)) {
return;
}

Expand Down
Loading