Skip to content

feat: S2 SelectBox #8541

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 29 commits into from
Aug 18, 2025
Merged

feat: S2 SelectBox #8541

merged 29 commits into from
Aug 18, 2025

Conversation

DPandyan
Copy link
Collaborator

Closes #8540

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 Test Instructions:

🧢 Your Project:

@yihuiliao
Copy link
Member

yihuiliao commented Jul 14, 2025

seems like there's a couple of lint errors, mind running yarn lint and fixing those so we can get a build on the pr? or actually...you might be on a fork but i think we should be able to get you access so that you can directly contribute and we can get builds from you. you might need to ask danni about adding you as a collaborator

@yihuiliao yihuiliao changed the title S2 SelectBox Implementation (WIP) wip: S2 SelectBox Implementation Jul 14, 2025
allowMultiSelect: boolean,
children: ReactNode,
style?: React.CSSProperties,
className?: string,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style and className might need the UNSAFE_ prefix. and you might want to add the styles prop

Copy link
Member

@yihuiliao yihuiliao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry i didn't get to everything but i'll look more closely on monday! looks good tho

@yihuiliao yihuiliao mentioned this pull request Aug 4, 2025
5 tasks
import {useDOMRef} from '@react-spectrum/utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface SelectBoxGroupProps extends StyleProps {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might want to extend from ListBoxProps from RAC and omit any prop you don't want, or override any props that you might want to change the type of (like selectionMode for example)

@DPandyan
Copy link
Collaborator Author

DPandyan commented Aug 7, 2025

I made a lot of changes, to bring it more in line with ComboBox and IllustratedMessage. Should be much cleaner. I removed the form integration as it was in conflict with reorganizing the code and I think it increases the scope by quite a bit. I am open to adding it back though. I think the various props/contexts are a bit messy and I'll work on cleaning that up.

import {useControlledState} from '@react-stately/utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface SelectBoxGroupProps<T> extends StyleProps, Omit<ListBoxProps<T>, 'layout' | 'dragAndDropHooks'>, AriaLabelingProps{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can also probably omit renderEmptyState, dependencies, items. it would be nice to also put in the Omit children, selectionMode even though you are just overwriting them here. it helps to make things more clear. i'd go through the rest and see what make sense to keep or leave out.

on top of that, you'll want to add Omit<ListBoxProps, keyof GlobalDOMAttributes` to it

lastly, since ListBoxProps already extends from AriaLabelingProps, you can remove it from here

const childrenArray = React.Children.toArray(children).filter((x) => x);
useEffect(() => {
if (childrenArray.length > 9) {
console.warn('Invalid content. SelectBoxGroup cannot have more than 9 children.');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussed with rob, we think this is something the design should be handling, not engineering so we can just remove this warning altogether

Comment on lines 325 to 332
className={renderProps => (props.UNSAFE_className || '') + selectBoxStyles({
size,
orientation,
isDisabled: renderProps.isDisabled,
isSelected: renderProps.isSelected,
isHovered: renderProps.isHovered,
isFocusVisible: renderProps.isFocusVisible
}, props.styles)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
className={renderProps => (props.UNSAFE_className || '') + selectBoxStyles({
size,
orientation,
isDisabled: renderProps.isDisabled,
isSelected: renderProps.isSelected,
isHovered: renderProps.isHovered,
isFocusVisible: renderProps.isFocusVisible
}, props.styles)}
className={renderProps => (props.UNSAFE_className || '') + selectBoxStyles({
size,
orientation,
...renderProps
}, props.styles)}

selectionMode={selectionMode}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
className={UNSAFE_className + gridStyles({gutterWidth, orientation}, props.styles)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
className={UNSAFE_className + gridStyles({gutterWidth, orientation}, props.styles)}
className={(UNSAFE_className || '')+ gridStyles({gutterWidth, orientation}, props.styles)}

}
});

const gridStyles = style({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think you'll need to add outlineStyle: 'none' here, that should help a bit with the focus ring appearing in the isDisabled story

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and turns out the other issue where it will focus on the first item is just a bug in RAC ListBox in general. has to do with the fact that the first item is selected AND disabled. issue doesn't occur when the item isn't initially selected.

if you have time, it would be nice to look into but i wouldn't get too hung up on this since it's pre-existing

)
};

export const WithCheckboxes: Story = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think you can just get rid of this example since you can do this using the controls

'illustration . label',
'illustration . description'
],
[descriptionOnly]: [
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unaware of any description only use cases. I think we can drop this as an unsupported use case

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a story that I was using for testing all of the possible layouts. I'll just remove the stuff that has unsupported cases as well.

Comment on lines 365 to 366
slots: {
label: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add the default slot as a possibility for the label as well. see Menu for an example

[DEFAULT_SLOT]: {styles: label({size})},

selectionMode={selectionMode}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
className={UNSAFE_className + gridStyles({gutterWidth, orientation}, props.styles)}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
className={UNSAFE_className + gridStyles({gutterWidth, orientation}, props.styles)}
className={(UNSAFE_className || '') + gridStyles({gutterWidth, orientation}, props.styles)}

so we don't get 'undefined' in our classname string accidentally

aria-label="SelectBoxGroup"
aria-labelledby="SelectBoxGroup"
style={{
gridTemplateColumns: `repeat(${numColumns}, 1fr)`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to limit the columns? could we use css Grid auto-fit instead?
https://css-tricks.com/auto-sizing-columns-css-grid-auto-fill-vs-auto-fit/
Then it won't matter what size container this is rendered into. Instead, item sizes would dictate how many can fit

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a feature/holdover from quarry. I wasn't really sure of the justification. I could go either way personally.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to let it adjust to the container it's in. It's really tough to maintain the layout without overflow edge cases otherwise. You'd have to use container queries and resize observers, which isn't common knowledge yet nor is it great for performance.

@dannify do you have more information here though for requirements?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

team chatted about this yesterday, we feel like we don't need these two options for now and instead the SelectBoxes should fill the available room via what @snowystinger described above. I see you mentioned it is a hold over from quarry, do you know if it is a hard requirement?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't a hard requirement afaik. I'll be removing it shortly (and the gutterWidths).

@yihuiliao yihuiliao changed the title wip: S2 SelectBox Implementation feat: S2 SelectBox Aug 15, 2025
Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work, overall pretty solid job! Left some comments, not all are required for this coming alpha I'd say. I think in particular the API/prop questions we should chat about

);

return (
<ListBox
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

think you'll need to spread the remainder of the props here, props like disabledKeys/autoFocus/other Listbox props aren't being applied.

Comment on lines +294 to +296
const gridStyles = style({
display: 'grid',
outline: 'none',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strange, I see the default browser outline persist if the selectbox is disabled and autofocused:

Image

import {useControlledState} from '@react-stately/utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface SelectBoxGroupProps<T> extends StyleProps, Omit<ListBoxProps<T>, keyof GlobalDOMAttributes | 'layout' | 'dragAndDropHooks' | 'renderEmptyState' | 'dependencies' | 'items' | 'children' | 'selectionMode'>{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should SelectBoxGroup actually support onAction? If so, clicking on the checkbox doesn't seem to actually toggle selection when selectionBehavior is "toggle" if there is an onAction provided.

I think we would also want the highlightSelection api for S2 instead of React Aria Component's selectionBehavior

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also should we support a empty state aka if the user doesn't provided any options?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm i was thinking about the empty state but design suggests that it select box groups work best with 3 - 9 items. i don't think that's a hard rule so entirely possible to have less than 3 but seems to suggest that select box group likely won't be empty but maybe more of a design question

showCheckbox?: boolean
}

export interface SelectBoxProps extends StyleProps {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this extend ListBoxItemProps? At the very least should support id and textValue (for typeahead maybe? not sure if that is something we want for SelectBoxGroup)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh I see value is being used as the id, was it intentional that we wanted to change that API? Funnily enough it echos some of the discussion that the team was having earlier for mutli select pickers and comboboxes, but not sure if we wanted to change to using it immediately for SelectBox

Comment on lines +111 to +114
default: 170,
orientation: {
horizontal: 368
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume these values came from the designs? Did they have tokens associated with them? If so, mind using them directly if they exist or adding a comment listing what token name it is so we can track?

Comment on lines +276 to +290
it('supports different gutter widths', () => {
render(
<SelectBoxGroup
aria-label="Gutter width test"
selectionMode="single"
onSelectionChange={() => {}}
selectedKeys={new Set()}
gutterWidth="compact">
<SelectBox value="option1">
<Text slot="label">Option 1</Text>
</SelectBox>
</SelectBoxGroup>
);
expect(screen.getByRole('listbox')).toBeInTheDocument();
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably should be a chromatic test, same with the columns one since chromatic is much better suited for testing visual cases like that

Comment on lines +608 to +614
// Navigate to second option
await userEvent.keyboard('{ArrowDown}');

// Check that navigation works by verifying an option has focus
const option1 = screen.getByRole('option', {name: 'Option 1'});
expect(option1).toHaveFocus();
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this supposed to move focus to option 2?

consoleSpy.mockRestore();
});

it('does not warn with valid number of children', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see a warning implemented in SelectBoxGroup, was there supposed to be one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I removed it in a previous commit after feedback from @yihuiliao. I'll remove the test.

Comment on lines +686 to +688
// Just verify the listbox has an aria-labelledby attribute
expect(listbox).toHaveAttribute('aria-labelledby');
expect(listbox.getAttribute('aria-labelledby')).toBeTruthy();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hm, seems to intentionally only check for the existence of aria-labelledby, but as noted above the SelectBoxGroup should accept a user defined aria-labelledby/aria-label

expect(screen.getByText('With description')).toBeInTheDocument();
});

it('handles different value types', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this test? I think your other tests already cover checking this value case right?


const gridStyles = style({
display: 'grid',
outline: 'none',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
outline: 'none',
outlineStyle: 'none',

not sure why it doesn't throw an error but it doesn't look like it's applying if you use outline. it has to be outlineStyle

const noIllustration = ':not(:has([slot=illustration]))';
const selectBoxStyles = style({
...focusRing(),
outlineOffset: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2025-08-15 at 3 53 39 PM

sorry for the back and forth with the focus ring stuff but did you talk to design about it? seems like in the token file that there is an outline offset. not sure if the border is also supposed to be dark. i assume that border during darker is for selection

onSelectionChange?: (keys: Selection) => void
}

export const SelectBoxContext = createContext<SelectBoxContextValue>({orientation: 'vertical'});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to pass {orientation: 'vertical'} when initializing the context? seems like you default to vertical later anyway

also i don't think we need to export this and keep this as an internal context

@@ -72,6 +72,7 @@ export {RangeCalendar, RangeCalendarContext} from './RangeCalendar';
export {RangeSlider, RangeSliderContext} from './RangeSlider';
export {SearchField, SearchFieldContext} from './SearchField';
export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl';
export {SelectBox, SelectBoxContext, SelectBoxGroup} from './SelectBoxGroup';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we remove the SelectBoxContext from being exported? instead, export the SelectBoxGroupContext

* SelectBox is a single selectable item in a SelectBoxGroup.
*/
export function SelectBox(props: SelectBoxProps): ReactNode {
let {children, value, isDisabled: individualDisabled = false, UNSAFE_style} = props;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let {children, value, isDisabled: individualDisabled = false, UNSAFE_style} = props;
let {children, value, isDisabled: individualDisabled = false, UNSAFE_style, ...otherProps} = props;

and then you can spread otherProps on ListBoxItem

<ListBoxItem
id={value}
isDisabled={isDisabled}
textValue={value}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think rather having us set the textValue to value here, we'll have the user apply what the textValue should be. this should be taken care of if you spread otherProps here

...renderProps
}, props.styles)}
style={UNSAFE_style}>
{(renderProps) => (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{(renderProps) => (
{({isSelected, isDisabled, isHovered}) => (

nothing wrong with what you have, just make things a little more readable since you won't have to so renderProps.isSelected, renderProps.isDisabled, etc.

Comment on lines +391 to +404
let {
children,
onSelectionChange,
selectedKeys: controlledSelectedKeys,
defaultSelectedKeys,
selectionMode = 'single',
orientation = 'vertical',
numColumns = 2,
gutterWidth = 'default',
isDisabled = false,
showCheckbox = false,
UNSAFE_className,
UNSAFE_style
} = props;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let {
children,
onSelectionChange,
selectedKeys: controlledSelectedKeys,
defaultSelectedKeys,
selectionMode = 'single',
orientation = 'vertical',
numColumns = 2,
gutterWidth = 'default',
isDisabled = false,
showCheckbox = false,
UNSAFE_className,
UNSAFE_style
} = props;
let {
children,
onSelectionChange,
selectedKeys: controlledSelectedKeys,
defaultSelectedKeys,
selectionMode = 'single',
orientation = 'vertical',
numColumns = 2,
gutterWidth = 'default',
isDisabled = false,
showCheckbox = false,
UNSAFE_className,
UNSAFE_style,
...otherProps
} = props;

then you can spread them on ListBox like Daniel suggested

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving for testing

Copy link
Member

@yihuiliao yihuiliao left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

approving for testing, you can create a new pr that addresses the latest comments as follow-up

@yihuiliao yihuiliao added this pull request to the merge queue Aug 18, 2025
Merged via the queue into adobe:main with commit b2a4bda Aug 18, 2025
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

S2 SelectBox Implementation
5 participants