From d572ea39390390ba9af74710bdb51f023e52e837 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 19:08:33 -0500 Subject: [PATCH 1/4] fix: [select] mark the selected item and allow alignItemWithTrigger --- .../content/docs/components/select/index.mdx | 5 ++- .../content/docs/components/select/props.ts | 6 ++++ .../select/__tests__/data-slots.test.tsx | 7 ++-- .../select/__tests__/select.test.tsx | 32 +++++++++++++++++++ .../components/select/select-content.tsx | 3 +- .../react/components/select/select-item.tsx | 26 ++++++++++++++- .../react/components/select/select.module.css | 18 +++++++++++ 7 files changed, 91 insertions(+), 6 deletions(-) diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 2c2925bff..70b2fd9e1 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -43,7 +43,7 @@ Appearance first, meaning size and variant, then how many items can be chosen, a ### Basic Select -A trigger and a list of items. `Select.Value` renders the current selection in the trigger. +A trigger and a list of items. `Select.Value` renders the current selection in the trigger. A check mark shows the selected item in the list. In multiple mode, each item has a checkbox instead. @@ -117,6 +117,8 @@ The container that holds the Select items. +Set `alignItemWithTrigger` to open the list over the trigger, with the selected item lined up on the trigger's value. Base UI turns it off for touch input and when there is not enough space. `side` and `align` have no effect while it is on. + ### Item Individual clickable options within the Select. @@ -164,6 +166,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `select-item` | An individual option row | | `select-item-icon` | Leading icon on an item (when set) | | `select-item-text` | The text label of an item | +| `select-item-indicator` | The check mark on the selected item (single selection only) | ## Accessibility diff --git a/apps/www/src/content/docs/components/select/props.ts b/apps/www/src/content/docs/components/select/props.ts index 80ddce754..14924698a 100644 --- a/apps/www/src/content/docs/components/select/props.ts +++ b/apps/www/src/content/docs/components/select/props.ts @@ -79,6 +79,12 @@ export interface SelectContentProps { */ sideOffset?: number; + /** + * Overlaps the trigger so the selected item's text lines up with the trigger's value. Has no effect in autocomplete mode. + * @default false + */ + alignItemWithTrigger?: boolean; + /** Additional CSS class names. */ className?: string; diff --git a/packages/react/components/select/__tests__/data-slots.test.tsx b/packages/react/components/select/__tests__/data-slots.test.tsx index d26d23883..9d3cbf254 100644 --- a/packages/react/components/select/__tests__/data-slots.test.tsx +++ b/packages/react/components/select/__tests__/data-slots.test.tsx @@ -45,10 +45,10 @@ describe('Select data-slot contract', () => { ]); }); - it('exposes content, list, group, label, separator and item slots when open', async () => { + it('exposes content, list, group, label, separator, item and indicator slots when open', async () => { const user = userEvent.setup(); render( - @@ -76,7 +76,8 @@ describe('Select data-slot contract', () => { 'select-label', 'select-separator', 'select-item', - 'select-item-text' + 'select-item-text', + 'select-item-indicator' ]); }); diff --git a/packages/react/components/select/__tests__/select.test.tsx b/packages/react/components/select/__tests__/select.test.tsx index ebf572071..33ff2e7c4 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -81,6 +81,23 @@ describe('Select', () => { }); describe('Single Selection', () => { + it('marks only the selected item with an indicator', async () => { + const user = userEvent.setup(); + render(); + await flushMicrotasks(); + await openSelect(user); + await flushMicrotasks(); + + const options = screen.getAllByRole('option'); + expect( + options[1].querySelector('[data-slot="select-item-indicator"]') + ).not.toBeNull(); + expect( + options[0].querySelector('[data-slot="select-item-indicator"]') + ).toBeNull(); + expect(within(options[1]).queryByRole('checkbox')).toBeNull(); + }); + it('displays selected value in trigger', async () => { render(); await flushMicrotasks(); @@ -281,6 +298,21 @@ describe('Select', () => { expect(searchInput).toBeInTheDocument(); }); + it('marks the selected item with an indicator', async () => { + render(); + + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + + const options = screen.getAllByRole('option'); + expect( + options[1].querySelector('[data-slot="select-item-indicator"]') + ).not.toBeNull(); + expect( + options[0].querySelector('[data-slot="select-item-indicator"]') + ).toBeNull(); + }); + it('filters options based on search', async () => { const user = userEvent.setup(); render(); diff --git a/packages/react/components/select/select-content.tsx b/packages/react/components/select/select-content.tsx index 1b6e20a52..123d10e99 100644 --- a/packages/react/components/select/select-content.tsx +++ b/packages/react/components/select/select-content.tsx @@ -28,6 +28,7 @@ export function SelectContent({ sideOffset = 4, side = 'bottom', align = 'start', + alignItemWithTrigger = false, radius, ...props }: SelectContentProps) { @@ -80,7 +81,7 @@ export function SelectContent({ side={side} align={align} className={styles.positioner} - alignItemWithTrigger={false} + alignItemWithTrigger={alignItemWithTrigger} data-slot='select-positioner' > )} - {children} + {autocomplete ? ( + + {children} + + ) : ( + } + className={styles.itemText} + data-slot='select-item-text' + > + {children} + + )} ) : ( children @@ -70,6 +83,9 @@ export function SelectItem({ const ItemPrimitive = autocomplete ? ComboboxPrimitive.Item : SelectPrimitive.Item; + const IndicatorPrimitive = autocomplete + ? ComboboxPrimitive.ItemIndicator + : SelectPrimitive.ItemIndicator; return ( {multiple && } {element} + {!multiple && ( + + + )} )} /> diff --git a/packages/react/components/select/select.module.css b/packages/react/components/select/select.module.css index b5f5ae2fb..b159a565e 100644 --- a/packages/react/components/select/select.module.css +++ b/packages/react/components/select/select.module.css @@ -286,3 +286,21 @@ .hidden { display: none; } + +.itemText { + flex: 1; + min-width: 0; +} + +.itemIndicator { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; + color: var(--rs-color-foreground-base-primary); +} + +.itemIndicator svg { + width: var(--rs-space-5); + height: var(--rs-space-5); +} From 33fb330ea417f021151fba77d2515a0847db2130 Mon Sep 17 00:00:00 2001 From: Ravi Suhag Date: Wed, 7 Oct 2026 20:11:44 -0500 Subject: [PATCH 2/4] fix: [docs] clarify when select side and align apply --- apps/www/src/content/docs/components/select/index.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index 70b2fd9e1..ef6f79097 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -117,7 +117,7 @@ The container that holds the Select items. -Set `alignItemWithTrigger` to open the list over the trigger, with the selected item lined up on the trigger's value. Base UI turns it off for touch input and when there is not enough space. `side` and `align` have no effect while it is on. +Set `alignItemWithTrigger` to open the list over the trigger, with the selected item lined up on the trigger's value. Base UI turns it off for touch input and when there is not enough space, and then places the list the normal way. `side` and `align` only apply when the list is placed the normal way. ### Item From 5b138add71374e42c49d782471823b863ae7ba4e Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Oct 2026 01:03:52 +0530 Subject: [PATCH 3/4] fix: [select] remove the item indicator and stop the open scroll jump Remove the check mark on the selected item, with its CSS, tests, slot, and docs. Take the item checkbox in multiple mode out of the tab order. Base UI focused it without preventScroll when the list opened, before the list was placed, so the page scrolled to the bottom. Let the popup fill the positioner when alignItemWithTrigger is active. --- .../content/docs/components/select/index.mdx | 3 +- .../select/__tests__/data-slots.test.tsx | 7 ++- .../select/__tests__/select.test.tsx | 44 +++++-------------- .../react/components/select/select-item.tsx | 16 ++----- .../react/components/select/select.module.css | 18 +++----- 5 files changed, 24 insertions(+), 64 deletions(-) diff --git a/apps/www/src/content/docs/components/select/index.mdx b/apps/www/src/content/docs/components/select/index.mdx index ef6f79097..3d2a7d972 100644 --- a/apps/www/src/content/docs/components/select/index.mdx +++ b/apps/www/src/content/docs/components/select/index.mdx @@ -43,7 +43,7 @@ Appearance first, meaning size and variant, then how many items can be chosen, a ### Basic Select -A trigger and a list of items. `Select.Value` renders the current selection in the trigger. A check mark shows the selected item in the list. In multiple mode, each item has a checkbox instead. +A trigger and a list of items. `Select.Value` renders the current selection in the trigger. @@ -166,7 +166,6 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `select-item` | An individual option row | | `select-item-icon` | Leading icon on an item (when set) | | `select-item-text` | The text label of an item | -| `select-item-indicator` | The check mark on the selected item (single selection only) | ## Accessibility diff --git a/packages/react/components/select/__tests__/data-slots.test.tsx b/packages/react/components/select/__tests__/data-slots.test.tsx index 9d3cbf254..d26d23883 100644 --- a/packages/react/components/select/__tests__/data-slots.test.tsx +++ b/packages/react/components/select/__tests__/data-slots.test.tsx @@ -45,10 +45,10 @@ describe('Select data-slot contract', () => { ]); }); - it('exposes content, list, group, label, separator, item and indicator slots when open', async () => { + it('exposes content, list, group, label, separator and item slots when open', async () => { const user = userEvent.setup(); render( - @@ -76,8 +76,7 @@ describe('Select data-slot contract', () => { 'select-label', 'select-separator', 'select-item', - 'select-item-text', - 'select-item-indicator' + 'select-item-text' ]); }); diff --git a/packages/react/components/select/__tests__/select.test.tsx b/packages/react/components/select/__tests__/select.test.tsx index 33ff2e7c4..8b196a985 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -81,23 +81,6 @@ describe('Select', () => { }); describe('Single Selection', () => { - it('marks only the selected item with an indicator', async () => { - const user = userEvent.setup(); - render(); - await flushMicrotasks(); - await openSelect(user); - await flushMicrotasks(); - - const options = screen.getAllByRole('option'); - expect( - options[1].querySelector('[data-slot="select-item-indicator"]') - ).not.toBeNull(); - expect( - options[0].querySelector('[data-slot="select-item-indicator"]') - ).toBeNull(); - expect(within(options[1]).queryByRole('checkbox')).toBeNull(); - }); - it('displays selected value in trigger', async () => { render(); await flushMicrotasks(); @@ -205,6 +188,18 @@ describe('Select', () => { ).toHaveAttribute('aria-checked', 'true'); }); + it('keeps the item checkbox out of the tab order', async () => { + const user = userEvent.setup(); + render(); + await openSelect(user); + + const option = screen.getAllByRole('option')[0]; + expect(within(option).getByRole('checkbox')).toHaveAttribute( + 'tabindex', + '-1' + ); + }); + it('fires onValueChange exactly once per click on an option', async () => { const handleValueChange = vi.fn(); render(); @@ -298,21 +293,6 @@ describe('Select', () => { expect(searchInput).toBeInTheDocument(); }); - it('marks the selected item with an indicator', async () => { - render(); - - fireEvent.click(screen.getByLabelText('Select option')); - await flushMicrotasks(); - - const options = screen.getAllByRole('option'); - expect( - options[1].querySelector('[data-slot="select-item-indicator"]') - ).not.toBeNull(); - expect( - options[0].querySelector('[data-slot="select-item-indicator"]') - ).toBeNull(); - }); - it('filters options based on search', async () => { const user = userEvent.setup(); render(); diff --git a/packages/react/components/select/select-item.tsx b/packages/react/components/select/select-item.tsx index 9e1ebd9d8..b9d40877e 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -6,7 +6,6 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { ReactNode, useLayoutEffect } from 'react'; -import { CheckIcon } from '~/icons'; import { Checkbox } from '../checkbox'; import { getMatch } from '../menu/utils'; import { Text } from '../text'; @@ -83,9 +82,6 @@ export function SelectItem({ const ItemPrimitive = autocomplete ? ComboboxPrimitive.Item : SelectPrimitive.Item; - const IndicatorPrimitive = autocomplete - ? ComboboxPrimitive.ItemIndicator - : SelectPrimitive.ItemIndicator; return ( (
- {multiple && } + {/* A tabbable checkbox takes the popup's initial focus without + preventScroll, which scrolls the page before the popup is placed. */} + {multiple && } {element} - {!multiple && ( - - - )}
)} /> diff --git a/packages/react/components/select/select.module.css b/packages/react/components/select/select.module.css index b159a565e..049dfe411 100644 --- a/packages/react/components/select/select.module.css +++ b/packages/react/components/select/select.module.css @@ -22,6 +22,11 @@ overflow: auto; } +/* With alignItemWithTrigger, Base UI sizes the positioner and the popup must fill it. */ +.content[data-side="none"] { + max-height: none; +} + .content[data-starting-style], .content[data-ending-style] { opacity: 0; @@ -291,16 +296,3 @@ flex: 1; min-width: 0; } - -.itemIndicator { - display: flex; - align-items: center; - flex-shrink: 0; - margin-left: auto; - color: var(--rs-color-foreground-base-primary); -} - -.itemIndicator svg { - width: var(--rs-space-5); - height: var(--rs-space-5); -} From 597ccb87a66785a7d28b76ab81f385e82a0f9660 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Wed, 28 Oct 2026 01:23:49 +0530 Subject: [PATCH 4/4] refactor: [select] remove the item checkbox comment --- packages/react/components/select/select-item.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/components/select/select-item.tsx b/packages/react/components/select/select-item.tsx index b9d40877e..3062b3602 100644 --- a/packages/react/components/select/select-item.tsx +++ b/packages/react/components/select/select-item.tsx @@ -93,8 +93,6 @@ export function SelectItem({ {...props} render={(renderProps, state) => (
- {/* A tabbable checkbox takes the popup's initial focus without - preventScroll, which scrolls the page before the popup is placed. */} {multiple && } {element}