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
17 changes: 12 additions & 5 deletions packages/react/src/select/positioner/SelectPositioner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import type {
Side,
UseAnchorPositioningSharedParameters,
} from '../../internals/useAnchorPositioning';
import { SelectPositionerContext } from './SelectPositionerContext';
import { SelectPositionerContext, SelectScrollArrowContext } from './SelectPositionerContext';
import { InternalBackdrop } from '../../utils/popups/InternalBackdrop';
import { DROPDOWN_COLLISION_AVOIDANCE } from '../../internals/constants';
import { clearStyles } from '../popup/utils';
Expand Down Expand Up @@ -212,21 +212,28 @@ export const SelectPositioner = React.forwardRef(function SelectPositioner(
side: renderedSide,
alignItemWithTriggerActive,
setControlledAlignItemWithTrigger,
scrollUpArrowRef,
scrollDownArrowRef,
}),
[positioning, renderedSide, alignItemWithTriggerActive, setControlledAlignItemWithTrigger],
);

const scrollArrowContextValue: SelectScrollArrowContext = React.useMemo(
() => ({ side: renderedSide, scrollUpArrowRef, scrollDownArrowRef }),
[renderedSide],
);

return (
<CompositeList
elementsRef={store.context.listRef}
labelsRef={store.context.labelsRef}
onMapChange={onMapChange}
>
<SelectPositionerContext.Provider value={contextValue}>
{mounted && modal && <InternalBackdrop inert={inertValue(!open)} cutout={triggerElement} />}
{element}
<SelectScrollArrowContext.Provider value={scrollArrowContextValue}>
{mounted && modal && (
<InternalBackdrop inert={inertValue(!open)} cutout={triggerElement} />
)}
{element}
</SelectScrollArrowContext.Provider>
</SelectPositionerContext.Provider>
</CompositeList>
);
Expand Down
26 changes: 24 additions & 2 deletions packages/react/src/select/positioner/SelectPositionerContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ export interface SelectPositionerContext extends Omit<UseAnchorPositioningReturn
side: 'none' | Side;
alignItemWithTriggerActive: boolean;
setControlledAlignItemWithTrigger: React.Dispatch<React.SetStateAction<boolean>>;
scrollUpArrowRef: React.RefObject<HTMLDivElement | null>;
scrollDownArrowRef: React.RefObject<HTMLDivElement | null>;
}

export const SelectPositionerContext = React.createContext<SelectPositionerContext | undefined>(
Expand All @@ -23,3 +21,27 @@ export function useSelectPositionerContext() {
}
return context;
}

/**
* What the scroll arrows read from the positioner. Separate from `SelectPositionerContext`, whose
* value changes with every position update, so that a hidden arrow doesn't re-render on those.
*/
export interface SelectScrollArrowContext {
side: 'none' | Side;
scrollUpArrowRef: React.RefObject<HTMLDivElement | null>;
scrollDownArrowRef: React.RefObject<HTMLDivElement | null>;
}

export const SelectScrollArrowContext = React.createContext<SelectScrollArrowContext | undefined>(
undefined,
);

export function useSelectScrollArrowContext() {
const context = React.useContext(SelectScrollArrowContext);
if (!context) {
throw new Error(
'Base UI: SelectPositionerContext is missing. SelectPositioner parts must be placed within <Select.Positioner>.',
);
}
return context;
}
121 changes: 120 additions & 1 deletion packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { expect, vi, describe, it, beforeEach, afterEach } from 'vitest';
import { Select } from '@base-ui/react/select';
import { act, fireEvent, screen } from '@mui/internal-test-utils';
import { createRenderer } from '#test-utils';
import { createRenderer, pressWithTouch } from '#test-utils';

const DEFAULT_ITEM_OFFSETS = [0, 40, 80, 120, 160, 200, 240, 280, 320, 360];

Expand Down Expand Up @@ -351,6 +351,125 @@ describe('<Select.ScrollArrow />', () => {
expect(downArrow).not.toHaveAttribute('data-visible');
});

describe('opening and closing', () => {
function pressWithMouse(element: HTMLElement) {
fireEvent.pointerDown(element, { pointerType: 'mouse' });
fireEvent.mouseDown(element);
}

async function advanceFrames() {
await act(async () => {
await vi.advanceTimersByTimeAsync(100);
});
}

/**
* A mouse press opens the popup in one frame, and the popup reads its scroller in the next.
* React 18 commits the open only as `act` exits, once the clock has stopped, so that next frame
* needs an `act` of its own.
*/
async function settle() {
await advanceFrames();
await advanceFrames();
}

/**
* Renders once, so `children` stay the same elements and only the select itself can re-render
* the arrows. Focus keeps the closed popup mounted, so one arrow instance sees the whole cycle.
*/
async function renderClosedSelect(scrollHeight: number) {
const renders = { up: 0, down: 0 };
let scrollTop = 100;

await render(
<Select.Root>
<Select.Trigger data-testid="trigger">Open</Select.Trigger>
<Select.Portal>
<Select.Positioner alignItemWithTrigger={false}>
<Select.Popup>
<Select.ScrollUpArrow
keepMounted
data-testid="up"
render={(props) => {
renders.up += 1;
return <div {...props} />;
}}
/>
<Select.List
ref={(node) =>
stubScroller(
node,
{ scrollHeight, clientHeight: 200 },
() => scrollTop,
(value) => {
scrollTop = value;
},
)
}
>
<Select.Item value="one">One</Select.Item>
</Select.List>
<Select.ScrollDownArrow
keepMounted
data-testid="down"
render={(props) => {
renders.down += 1;
return <div {...props} />;
}}
/>
</Select.Popup>
</Select.Positioner>
</Select.Portal>
</Select.Root>,
);

const trigger = screen.getByTestId('trigger');
fireEvent.focus(trigger);
await settle();

return { renders, trigger };
}

it.each([
{ name: 'a list that fits, opened with a mouse', scrollHeight: 200, press: pressWithMouse },
{ name: 'a list that overflows, opened by touch', scrollHeight: 400, press: pressWithTouch },
])('does not re-render hidden arrows for $name', async ({ scrollHeight, press }) => {
const { renders, trigger } = await renderClosedSelect(scrollHeight);

expect(renders.up).toBeGreaterThan(0);
expect(renders.down).toBeGreaterThan(0);

const before = { ...renders };

press(trigger);
await settle();

expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('down')).not.toHaveAttribute('data-visible');
expect(renders).toEqual(before);

fireEvent.keyDown(screen.getByRole('option'), { key: 'Escape' });
await settle();

expect(trigger).toHaveAttribute('aria-expanded', 'false');
expect(renders).toEqual(before);
});

it.each([
{ name: 'shows the arrows when a mouse opens', press: pressWithMouse, visible: true },
{ name: 'hides the arrows when touch opens', press: pressWithTouch, visible: false },
])('$name a list that overflows both ways', async ({ press, visible }) => {
const { trigger } = await renderClosedSelect(400);

press(trigger);
await settle();

expect(trigger).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByTestId('up').hasAttribute('data-visible')).toBe(visible);
expect(screen.getByTestId('down').hasAttribute('data-visible')).toBe(visible);
});
});

it('ignores pointer interaction when the arrow has no scrollable popup', async () => {
await render(
<Select.Root open>
Expand Down
12 changes: 3 additions & 9 deletions packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useTimeout } from '@base-ui/utils/useTimeout';
import { useIsoLayoutEffect } from '@base-ui/utils/useIsoLayoutEffect';
import type { BaseUIComponentProps } from '../../internals/types';
import { useSelectRootContext } from '../root/SelectRootContext';
import { useSelectPositionerContext } from '../positioner/SelectPositionerContext';
import { useSelectScrollArrowContext } from '../positioner/SelectPositionerContext';
import type { Side } from '../../internals/useAnchorPositioning';
import { useTransitionStatus } from '../../internals/useTransitionStatus';
import type { TransitionStatus } from '../../internals/useTransitionStatus';
Expand All @@ -29,15 +29,9 @@ export const SelectScrollArrow = React.forwardRef(function SelectScrollArrow(
const isUp = direction === 'up';

const store = useSelectRootContext();
const { side, scrollDownArrowRef, scrollUpArrowRef } = useSelectPositionerContext();
const { side, scrollDownArrowRef, scrollUpArrowRef } = useSelectScrollArrowContext();

const visibleSelector = isUp ? 'scrollUpArrowVisible' : 'scrollDownArrowVisible';

const stateVisible = store.useState(visibleSelector);
const openMethod = store.useState('openMethod');

// Scroll arrows are disabled for touch modality as they are a hover-only element.
const visible = stateVisible && openMethod !== 'touch';
const visible = store.useState(isUp ? 'scrollUpArrowShown' : 'scrollDownArrowShown');

const timeout = useTimeout();

Expand Down
4 changes: 4 additions & 0 deletions packages/react/src/select/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ export const selectors = {

scrollUpArrowVisible: (state: State) => state.scrollUpArrowVisible,
scrollDownArrowVisible: (state: State) => state.scrollDownArrowVisible,
// Scroll arrows are disabled for touch modality as they are a hover-only element.
scrollUpArrowShown: (state: State) => state.scrollUpArrowVisible && state.openMethod !== 'touch',
scrollDownArrowShown: (state: State) =>
state.scrollDownArrowVisible && state.openMethod !== 'touch',

hasScrollArrows: (state: State) => state.hasScrollArrows,
};
Expand Down
Loading