diff --git a/packages/react/src/select/positioner/SelectPositioner.tsx b/packages/react/src/select/positioner/SelectPositioner.tsx index 92df1b4ae78..5636e3dbca2 100644 --- a/packages/react/src/select/positioner/SelectPositioner.tsx +++ b/packages/react/src/select/positioner/SelectPositioner.tsx @@ -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'; @@ -212,12 +212,15 @@ 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 ( - {mounted && modal && } - {element} + + {mounted && modal && ( + + )} + {element} + ); diff --git a/packages/react/src/select/positioner/SelectPositionerContext.ts b/packages/react/src/select/positioner/SelectPositionerContext.ts index 163d6c5c0bb..fc1c745b3d7 100644 --- a/packages/react/src/select/positioner/SelectPositionerContext.ts +++ b/packages/react/src/select/positioner/SelectPositionerContext.ts @@ -6,8 +6,6 @@ export interface SelectPositionerContext extends Omit>; - scrollUpArrowRef: React.RefObject; - scrollDownArrowRef: React.RefObject; } export const SelectPositionerContext = React.createContext( @@ -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; + scrollDownArrowRef: React.RefObject; +} + +export const SelectScrollArrowContext = React.createContext( + 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 .', + ); + } + return context; +} diff --git a/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx b/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx index 3dcd36392e4..e1b330e4a30 100644 --- a/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx +++ b/packages/react/src/select/scroll-arrow/SelectScrollArrow.test.tsx @@ -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]; @@ -351,6 +351,125 @@ describe('', () => { 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( + + Open + + + + { + renders.up += 1; + return
; + }} + /> + + stubScroller( + node, + { scrollHeight, clientHeight: 200 }, + () => scrollTop, + (value) => { + scrollTop = value; + }, + ) + } + > + One + + { + renders.down += 1; + return
; + }} + /> + + + + , + ); + + 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( diff --git a/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx b/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx index 5f5febcdd3c..d91620158f3 100644 --- a/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx +++ b/packages/react/src/select/scroll-arrow/SelectScrollArrow.tsx @@ -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'; @@ -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(); diff --git a/packages/react/src/select/store.ts b/packages/react/src/select/store.ts index 52c14106668..28250b99d8a 100644 --- a/packages/react/src/select/store.ts +++ b/packages/react/src/select/store.ts @@ -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, };