Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/react/src/select/item/SelectItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,37 @@ describe('<Select.Item />', () => {
expect(handleClick).toHaveBeenCalledOnce();
});

it('should select an unhighlighted item with the mouse', async () => {
await render(
<Select.Root defaultOpen highlightItemOnHover={false}>
<Select.Trigger data-testid="trigger">
<Select.Value data-testid="value" />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item value="one">one</Select.Item>
<Select.Item value="two">two</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>,
);

const option = screen.getByRole('option', { name: 'two' });

expect(option).not.toHaveAttribute('data-highlighted');

fireEvent.pointerDown(option, { pointerType: 'mouse' });
fireEvent.mouseDown(option);
fireEvent.mouseUp(option);
fireEvent.click(option, { detail: 1 });

await waitFor(() => {
expect(screen.getByTestId('value').textContent).toBe('two');
});
});

it('should focus the selected item upon opening the popup', async () => {
const { user } = await render(
<Select.Root>
Expand Down Expand Up @@ -372,6 +403,73 @@ describe('<Select.Item />', () => {
expect(handleClick).toHaveBeenCalledOnce();
});

it('should select via drag-to-select when hover highlighting is disabled', async () => {
ignoreActWarnings();

await renderFakeTimers(
<Select.Root highlightItemOnHover={false}>
<Select.Trigger data-testid="trigger">
<Select.Value data-testid="value" placeholder="Select font" />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item value="one">one</Select.Item>
<Select.Item value="two">two</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>,
);

const trigger = screen.getByTestId('trigger');
fireEvent.mouseDown(trigger);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBe(null));

const option = screen.getByRole('option', { name: 'two' });
fireEvent.pointerEnter(option, { pointerType: 'mouse' });
fireEvent.pointerMove(option, { pointerType: 'mouse' });

expect(option).not.toHaveAttribute('data-highlighted');

await act(async () => {
await clock.tickAsync(500);
});
fireEvent.mouseUp(option);

await waitFor(() => expect(screen.getByTestId('value').textContent).toBe('two'));
});

it('should ignore an opening click that did not start on the item', async () => {
ignoreActWarnings();

await renderFakeTimers(
<Select.Root highlightItemOnHover={false}>
<Select.Trigger data-testid="trigger">
<Select.Value data-testid="value" placeholder="Select font" />
</Select.Trigger>
<Select.Portal>
<Select.Positioner>
<Select.Popup>
<Select.Item value="one">one</Select.Item>
<Select.Item value="two">two</Select.Item>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>,
);

const trigger = screen.getByTestId('trigger');
fireEvent.pointerDown(trigger, { pointerType: 'mouse' });
fireEvent.mouseDown(trigger);
await waitFor(() => expect(screen.queryByRole('listbox')).not.toBe(null));

const option = screen.getByRole('option', { name: 'one' });
fireEvent.click(option, { detail: 1 });

expect(screen.getByTestId('value').textContent).toBe('Select font');
});

it('should not select item when onClick calls preventBaseUIHandler during drag-to-select', async () => {
ignoreActWarnings();
const handleClick = vi.fn((event) => event.preventBaseUIHandler());
Expand Down
29 changes: 17 additions & 12 deletions packages/react/src/select/item/SelectItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const SelectItem = React.memo(

const lastKeyRef = React.useRef<string | null>(null);
const pointerTypeRef = React.useRef<'mouse' | 'touch' | 'pen'>('mouse');
const didPointerDownRef = React.useRef(false);
const allowMouseSelectionRef = React.useRef(false);

const { getButtonProps, buttonRef } = useButton({
disabled,
Expand Down Expand Up @@ -151,6 +151,7 @@ export const SelectItem = React.memo(
'aria-selected': selected,
tabIndex: highlighted ? 0 : -1,
onTouchStart() {
allowMouseSelectionRef.current = false;
selectionRef.current = {
allowSelectedMouseUp: false,
allowUnselectedMouseUp: false,
Expand All @@ -165,7 +166,14 @@ export const SelectItem = React.memo(
}
},
onClick(event) {
didPointerDownRef.current = false;
const isMouseClick = event.type === 'click' && pointerTypeRef.current !== 'touch';
const isVirtualClick = isMouseClick && event.nativeEvent.detail === 0;
// With alignItemWithTrigger, opening can place an item under the cursor. Real mouse
// clicks must start on the item, while virtual clicks keep the highlighted-item guard.
const isInvalidMouseClick =
isMouseClick && !allowMouseSelectionRef.current && (isVirtualClick ? !highlighted : true);

allowMouseSelectionRef.current = false;

// Prevent double commit on {Enter}
if (event.type === 'keydown' && lastKeyRef.current === null) {
Expand All @@ -175,7 +183,7 @@ export const SelectItem = React.memo(
if (
disabled ||
(event.type === 'keydown' && lastKeyRef.current === ' ' && typingRef.current) ||
(pointerTypeRef.current !== 'touch' && !highlighted)
isInvalidMouseClick
) {
return;
}
Expand All @@ -188,31 +196,28 @@ export const SelectItem = React.memo(
},
onPointerDown(event) {
pointerTypeRef.current = event.pointerType;
didPointerDownRef.current = true;
allowMouseSelectionRef.current = true;
},
onMouseUp() {
if (disabled) {
return;
}

// Regular click (pointerdown on this element) if didPointerDownRef is set, otherwise drag-to-select
if (didPointerDownRef.current) {
didPointerDownRef.current = false;
// Regular clicks are committed by the click event.
if (allowMouseSelectionRef.current) {
return;
}

const disallowSelectedMouseUp = !selectionRef.current.allowSelectedMouseUp && selected;
const disallowUnselectedMouseUp = !selectionRef.current.allowUnselectedMouseUp && !selected;

if (
disallowSelectedMouseUp ||
disallowUnselectedMouseUp ||
(pointerTypeRef.current !== 'touch' && !highlighted)
) {
if (disallowSelectedMouseUp || disallowUnselectedMouseUp) {
return;
}

allowMouseSelectionRef.current = true;
itemRef.current?.click();
allowMouseSelectionRef.current = false;
},
};

Expand Down
Loading