From e4d4c17bd95862c373300d923d8376266e0dbc5a Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Mon, 14 Jul 2025 10:26:59 -0700 Subject: [PATCH 1/9] S2 SelectBox initial implementation --- packages/@react-spectrum/s2/src/SelectBox.tsx | 220 +++++++++++++++ .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 258 ++++++++++++++++++ packages/@react-spectrum/s2/src/index.ts | 4 + .../s2/stories/SelectBox.stories.tsx | 169 ++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 packages/@react-spectrum/s2/src/SelectBox.tsx create mode 100644 packages/@react-spectrum/s2/src/SelectBoxGroup.tsx create mode 100644 packages/@react-spectrum/s2/stories/SelectBox.stories.tsx diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx new file mode 100644 index 00000000000..ed828f21030 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -0,0 +1,220 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Radio as AriaRadio, Checkbox as AriaCheckbox, ContextValue, RadioProps, CheckboxProps} from 'react-aria-components'; +import {FocusableRef, FocusableRefValue, SpectrumLabelableProps, HelpTextProps} from '@react-types/shared'; +import {Checkbox} from './Checkbox'; +import {forwardRef, ReactNode, useContext, useRef, createContext} from 'react'; +import {useFocusableRef} from '@react-spectrum/utils'; +import {SelectBoxContext} from './SelectBoxGroup'; +import {style, focusRing, baseColor} from '../style' with {type: 'macro'}; +import {controlFont, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {useSpectrumContextProps} from './useSpectrumContextProps'; +import React from 'react'; + +export interface SelectBoxProps extends + Omit, StyleProps { + /** + * The value of the SelectBox. + */ + value: string, + /** + * The label for the element. + */ + children?: ReactNode, + /** + * Whether the SelectBox is disabled. + */ + isDisabled?: boolean +} + +export const SelectBoxItemContext = createContext, FocusableRefValue>>(null); + +// Simple basic styling with proper dark mode support +const selectBoxStyles = style({ + ...focusRing(), + display: 'flex', + flexDirection: 'column', + lineHeight: 'title', + justifyContent: 'center', + flexShrink: 0, + alignItems: 'center', + fontFamily: 'sans', + font: 'ui', + //vertical orientation + size: { + default: { + size: { + S: 120, + M: 170, + L: 220, + XL: 270 + } + }, + //WIP horizontal orientation + orientation: { + horizontal: { + size: { + S: 280, + M: 368, + L: 420, + XL: 480 + } + } + } + }, + minWidth: { + default: { + size: { + S: 100, + M: 144, + L: 180, + XL: 220 + } + }, + orientation: { + horizontal: { + size: { + S: 160, + M: 188, + L: 220, + XL: 250 + } + } + } + }, + maxWidth: { + default: { + size: { + S: 140, + M: 200, + L: 260, + XL: 320 + } + }, + orientation: { + horizontal: { + size: { + S: 360, + M: 420, + L: 480, + XL: 540 + } + } + } + }, + minHeight: { + default: { + size: { + S: 100, + M: 144, + L: 180, + XL: 220 + } + }, + orientation: { + horizontal: 80 + } + }, + maxHeight: { + default: { + size: { + S: 140, + M: 200, + L: 260, + XL: 320 + } + }, + orientation: { + horizontal: 240 + } + }, + padding: { + size: { + S: 16, + M: 24, + L: 32, + XL: 40 + } + }, + borderRadius: 'lg', + backgroundColor: 'layer-2', + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isSelected: 'elevated', + forcedColors: 'none' + }, + position: 'relative', + borderWidth: 2, + borderStyle: { + default: 'solid', + isSelected: 'solid' + }, + borderColor: { + default: 'transparent', + isSelected: 'gray-900', + isFocusVisible: 'transparent' + }, + transition: 'default' +}, getAllowedOverrides()); + +const checkboxContainer = style({ + position: 'absolute', + top: 16, + left: 16 +}, getAllowedOverrides()); + +/** + * SelectBox components allow users to select options from a list. + * They can behave as radio buttons (single selection) or checkboxes (multiple selection). + */ +export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { + [props, ref] = useSpectrumContextProps(props, ref, SelectBoxItemContext); + let {children, value, isDisabled = false, UNSAFE_className = '', UNSAFE_style} = props; + let inputRef = useRef(null); + let domRef = useFocusableRef(ref, inputRef); + + let groupContext = useContext(SelectBoxContext); + let { + allowMultiSelect = false, + size = 'M', + orientation = 'vertical' + } = groupContext || {}; + + const Selector = allowMultiSelect ? AriaCheckbox : AriaRadio; + + return ( + UNSAFE_className + selectBoxStyles({...renderProps, size, orientation}, props.styles)}> + {renderProps => ( + <> + {(renderProps.isSelected || renderProps.isHovered) && ( +
+ +
+ )} + {children} + + )} +
+ ); +}); \ No newline at end of file diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx new file mode 100644 index 00000000000..781eb228108 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -0,0 +1,258 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + RadioGroup as AriaRadioGroup, + CheckboxGroup as AriaCheckboxGroup, + Label, + ContextValue +} from 'react-aria-components'; +import {DOMRef, DOMRefValue, HelpTextProps, Orientation, SpectrumLabelableProps} from '@react-types/shared'; +import {StyleProps, getAllowedOverrides} from './style-utils' with {type: 'macro'}; +import React, {createContext, forwardRef, ReactNode, useState, useEffect, useMemo, ReactElement} from 'react'; +import {style} from '../style' with {type: 'macro'}; +import {useDOMRef} from '@react-spectrum/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export type SelectBoxValueType = string | string[]; + +export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, HelpTextProps { + /** + * The SelectBox elements contained within the SelectBoxGroup. + */ + children: ReactNode, + /** + * Handler that is called when the selection changes. + */ + onSelectionChange: (val: SelectBoxValueType) => void, + /** + * The selection mode for the SelectBoxGroup. + * @default 'single' + */ + selectionMode?: 'single' | 'multiple', + /** + * The current selected value (controlled). + */ + value?: SelectBoxValueType, + /** + * The default selected value. + */ + defaultValue?: SelectBoxValueType, + /** + * The size of the SelectBoxGroup. + * @default 'M' + */ + size?: 'S' | 'M' | 'L' | 'XL', + /** + * The axis the SelectBox elements should align with. + * @default 'vertical' + */ + orientation?: Orientation, + /** + * Whether the SelectBoxGroup should be displayed with an emphasized style. + */ + isEmphasized?: boolean, + /** + * Number of columns to display the SelectBox elements in. + * @default 2 + */ + numColumns?: number, + /** + * Gap between grid items. + * @default 'default' + */ + gutterWidth?: 'default' | 'compact' | 'spacious', + /** + * Whether the SelectBoxGroup is required. + */ + isRequired?: boolean, + /** + * Whether the SelectBoxGroup is disabled. + */ + isDisabled?: boolean +} + +interface SelectBoxContextValue { + allowMultiSelect?: boolean, + value?: SelectBoxValueType, + size?: 'S' | 'M' | 'L' | 'XL', + orientation?: Orientation, + isEmphasized?: boolean +} + +// Utility functions +const unwrapValue = (value: SelectBoxValueType | undefined): string | undefined => { + if (Array.isArray(value)) { + return value[0]; + } + return value; +}; + +const ensureArray = (value: SelectBoxValueType | undefined): string[] => { + if (value === undefined) return []; + if (Array.isArray(value)) return value; + return [value]; +}; + +export const SelectBoxContext = createContext({ + size: 'M', + orientation: 'vertical' +}); + +export const SelectBoxGroupContext = createContext, DOMRefValue>>(null); + +const gridStyles = style({ + display: 'grid', + gridAutoRows: '1fr', + gap: { + gutterWidth: { + default: 16, + compact: 8, + spacious: 24 + } + } +}, getAllowedOverrides()); + + +// Selector Group component +interface SelectorGroupProps { + allowMultiSelect: boolean; + children: ReactNode; + style?: React.CSSProperties; + className?: string; + onChange: (value: SelectBoxValueType) => void; + value?: SelectBoxValueType; + defaultValue?: SelectBoxValueType; + isRequired?: boolean; + isDisabled?: boolean; +} + +const SelectorGroup = forwardRef(function SelectorGroupComponent({ + allowMultiSelect, + children, + className, + onChange, + value, + style, + defaultValue, + isRequired, + isDisabled, +}, ref) { + const props = { + isRequired, + isDisabled, + className, + style, + children, + onChange, + ref, + }; + + return allowMultiSelect ? ( + + ) : ( + + ); +}); + +/** + * SelectBox groups allow users to select one or more options from a list. + * All possible options are exposed up front for users to compare. + */ +export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); + + let { + label, + children, + onSelectionChange, + defaultValue, + selectionMode = 'single', + size = 'M', + orientation = 'vertical', + isEmphasized, + numColumns = 2, + gutterWidth = 'default', + isRequired = false, + isDisabled = false, + UNSAFE_style, + } = props; + + const [value, setValue] = useState(defaultValue); + const allowMultiSelect = selectionMode === 'multiple'; + + const domRef = useDOMRef(ref); + + const getChildrenToRender = () => { + const childrenToRender = React.Children.toArray(children).filter((x) => x); + try { + const childrenLength = childrenToRender.length; + if (childrenLength <= 0) { + throw new Error('Invalid content. SelectBox must have at least a title.'); + } + if (childrenLength > 9) { + throw new Error('Invalid content. SelectBox cannot have more than 9 children.'); + } + } catch (e) { + console.error(e); + } + return childrenToRender; + }; + + useEffect(() => { + if (value !== undefined) { + onSelectionChange(value); + } + }, [onSelectionChange, value]); + + // Context value + const selectBoxContextValue = useMemo( + () => ({ + allowMultiSelect, + value, + size, + orientation, + isEmphasized + }), + [allowMultiSelect, value, size, orientation, isEmphasized] + ); + + return ( + + + {getChildrenToRender().map((child, _) => { + return child as ReactElement; + })} + + + ); +}); \ No newline at end of file diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index ab01656ff77..843be0d6af3 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -69,6 +69,8 @@ export {Provider} from './Provider'; export {Radio} from './Radio'; export {RadioGroup, RadioGroupContext} from './RadioGroup'; export {RangeCalendar, RangeCalendarContext} from './RangeCalendar'; +export {SelectBox} from './SelectBox'; +export {SelectBoxGroup, SelectBoxContext} from './SelectBoxGroup'; export {RangeSlider, RangeSliderContext} from './RangeSlider'; export {SearchField, SearchFieldContext} from './SearchField'; export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl'; @@ -146,6 +148,8 @@ export type {ProgressCircleProps} from './ProgressCircle'; export type {ProviderProps} from './Provider'; export type {RadioProps} from './Radio'; export type {RadioGroupProps} from './RadioGroup'; +export type {SelectBoxProps} from './SelectBox'; +export type {SelectBoxGroupProps, SelectBoxValueType} from './SelectBoxGroup'; export type {SearchFieldProps} from './SearchField'; export type {SegmentedControlProps, SegmentedControlItemProps} from './SegmentedControl'; export type {SliderProps} from './Slider'; diff --git a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx new file mode 100644 index 00000000000..511be078104 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx @@ -0,0 +1,169 @@ +/************************************************************************* + * ADOBE CONFIDENTIAL + * ___________________ + * + * Copyright 2025 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + **************************************************************************/ + +import React from "react"; +import { action } from "@storybook/addon-actions"; +import type { Meta, StoryObj } from "@storybook/react"; +import { SelectBox, SelectBoxGroup, Text, createIcon } from "../src"; +import Server from "../spectrum-illustrations/linear/Server"; +import StarSVG from "../s2wf-icons/S2_Icon_Star_20_N.svg"; + +const StarIcon = createIcon(StarSVG); + +const meta: Meta = { + component: SelectBoxGroup, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + onSelectionChange: { table: { category: "Events" } }, + label: { control: { type: "text" } }, + description: { control: { type: "text" } }, + errorMessage: { control: { type: "text" } }, + children: { table: { disable: true } }, + }, + title: "SelectBox", +}; + +export default meta; +type Story = StoryObj; + +export const Example: Story = { + args: { + label: "Choose an option", + orientation: "vertical", + necessityIndicator: "label", + size: "M", + labelPosition: "side", + }, + render: (args) => ( + console.log("Selection changed:", v)} + > + + + Select Box Label + + + + Select Box Label + + + + Select Box Label + + + ), +}; + +export const SingleSelectNumColumns: Story = { + args: { + numColumns: 2, + label: "Favorite city", + size: "XL", + gutterWidth: "default", + }, + render: (args) => { + return ( + action("onSelectionChange")(v)} + > + + + Paris + France + + + + Rome + Italy + + + + San Francisco + USA + + + ); + }, + name: "Multiple columns", +}; + +export const MultipleSelection: Story = { + args: { + numColumns: 1, + label: "Favorite cities", + selectionMode: "multiple", + }, + render: (args) => { + return ( + action("onSelectionChange")(v)} + > + + {/* */} + Paris + France + + + {/* */} + Rome + Italy + + + {/* */} + San Francisco + USA + + + ); + }, + name: "Multiple selection mode", +}; + +export const HorizontalOrientation: Story = { + args: { + orientation: "horizontal", + label: "Favorite cities", + }, + render: (args) => { + return ( + action("onSelectionChange")(v)} + > + + Paris + France + + + Rome + Italy + + + San Francisco + USA + + + ); + }, + name: "Horizontal orientation", +}; From a431ce0160631ecf76e06253243b7362a26767db Mon Sep 17 00:00:00 2001 From: DPandyan Date: Mon, 14 Jul 2025 15:23:32 -0700 Subject: [PATCH 2/9] Added tests and fixed linting --- packages/@react-spectrum/s2/src/SelectBox.tsx | 141 ++++--- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 51 +-- .../s2/stories/SelectBox.stories.tsx | 68 ++-- .../s2/test/SelectBox.test.tsx | 363 ++++++++++++++++++ 4 files changed, 488 insertions(+), 135 deletions(-) create mode 100644 packages/@react-spectrum/s2/test/SelectBox.test.tsx diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index ed828f21030..f257a99ae42 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -9,16 +9,15 @@ * governing permissions and limitations under the License. */ -import {Radio as AriaRadio, Checkbox as AriaCheckbox, ContextValue, RadioProps, CheckboxProps} from 'react-aria-components'; -import {FocusableRef, FocusableRefValue, SpectrumLabelableProps, HelpTextProps} from '@react-types/shared'; +import {Checkbox as AriaCheckbox, Radio as AriaRadio, CheckboxProps, ContextValue, RadioProps} from 'react-aria-components'; import {Checkbox} from './Checkbox'; -import {forwardRef, ReactNode, useContext, useRef, createContext} from 'react'; -import {useFocusableRef} from '@react-spectrum/utils'; +import {FocusableRef, FocusableRefValue} from '@react-types/shared'; +import {focusRing, style} from '../style' with {type: 'macro'}; +import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import React, {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; import {SelectBoxContext} from './SelectBoxGroup'; -import {style, focusRing, baseColor} from '../style' with {type: 'macro'}; -import {controlFont, getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import {useFocusableRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import React from 'react'; export interface SelectBoxProps extends Omit, StyleProps { @@ -33,7 +32,15 @@ export interface SelectBoxProps extends /** * Whether the SelectBox is disabled. */ - isDisabled?: boolean + isDisabled?: boolean, + /** + * Whether the SelectBox is selected (controlled). + */ + isSelected?: boolean, + /** + * Handler called when the SelectBox selection changes. + */ + onChange?: (isSelected: boolean) => void } export const SelectBoxItemContext = createContext, FocusableRefValue>>(null); @@ -49,102 +56,76 @@ const selectBoxStyles = style({ alignItems: 'center', fontFamily: 'sans', font: 'ui', - //vertical orientation - size: { - default: { - size: { - S: 120, - M: 170, - L: 220, - XL: 270 - } - }, - //WIP horizontal orientation - orientation: { - horizontal: { - size: { - S: 280, - M: 368, - L: 420, - XL: 480 - } - } - } - }, - minWidth: { + + // Vertical orientation (default) - Fixed square dimensions + width: { default: { size: { - S: 100, - M: 144, - L: 180, - XL: 220 + XS: 100, + S: 128, + M: 136, + L: 160, + XL: 192 } }, orientation: { horizontal: { size: { - S: 160, - M: 188, - L: 220, - XL: 250 + XS: 'auto', + S: 'auto', + M: 'auto', + L: 'auto', + XL: 'auto' } } } }, - maxWidth: { + + height: { default: { size: { - S: 140, - M: 200, - L: 260, - XL: 320 + XS: 100, + S: 128, + M: 136, + L: 160, + XL: 192 } }, orientation: { horizontal: { size: { - S: 360, - M: 420, - L: 480, - XL: 540 + XS: 'auto', + S: 'auto', + M: 'auto', + L: 'auto', + XL: 'auto' } } } }, - minHeight: { - default: { - size: { - S: 100, - M: 144, - L: 180, - XL: 220 - } - }, + + minWidth: { orientation: { - horizontal: 80 + horizontal: 160 } }, - maxHeight: { - default: { - size: { - S: 140, - M: 200, - L: 260, - XL: 320 - } - }, + + maxWidth: { orientation: { - horizontal: 240 + horizontal: 272 } }, + padding: { size: { + XS: 12, S: 16, - M: 24, - L: 32, - XL: 40 + M: 20, + L: 24, + XL: 28 } }, + borderRadius: 'lg', backgroundColor: 'layer-2', boxShadow: { @@ -179,7 +160,7 @@ const checkboxContainer = style({ */ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxItemContext); - let {children, value, isDisabled = false, UNSAFE_className = '', UNSAFE_style} = props; + let {children, value, isDisabled = false, isSelected, onChange, UNSAFE_className = '', UNSAFE_style} = props; let inputRef = useRef(null); let domRef = useFocusableRef(ref, inputRef); @@ -188,14 +169,23 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele allowMultiSelect = false, size = 'M', orientation = 'vertical' - } = groupContext || {}; + } = groupContext; const Selector = allowMultiSelect ? AriaCheckbox : AriaRadio; + // Handle controlled selection + const handleSelectionChange = (selected: boolean) => { + if (onChange) { + onChange(selected); + } + }; + return ( + size={size} /> )} {children} @@ -217,4 +206,4 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele )} ); -}); \ No newline at end of file +}); diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 781eb228108..17db7606753 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -10,14 +10,13 @@ */ import { - RadioGroup as AriaRadioGroup, CheckboxGroup as AriaCheckboxGroup, - Label, + RadioGroup as AriaRadioGroup, ContextValue } from 'react-aria-components'; import {DOMRef, DOMRefValue, HelpTextProps, Orientation, SpectrumLabelableProps} from '@react-types/shared'; -import {StyleProps, getAllowedOverrides} from './style-utils' with {type: 'macro'}; -import React, {createContext, forwardRef, ReactNode, useState, useEffect, useMemo, ReactElement} from 'react'; +import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; +import React, {createContext, forwardRef, ReactElement, ReactNode, useEffect, useMemo, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -97,8 +96,12 @@ const unwrapValue = (value: SelectBoxValueType | undefined): string | undefined }; const ensureArray = (value: SelectBoxValueType | undefined): string[] => { - if (value === undefined) return []; - if (Array.isArray(value)) return value; + if (value === undefined) { + return []; + } + if (Array.isArray(value)) { + return value; + } return [value]; }; @@ -124,15 +127,16 @@ const gridStyles = style({ // Selector Group component interface SelectorGroupProps { - allowMultiSelect: boolean; - children: ReactNode; - style?: React.CSSProperties; - className?: string; - onChange: (value: SelectBoxValueType) => void; - value?: SelectBoxValueType; - defaultValue?: SelectBoxValueType; - isRequired?: boolean; - isDisabled?: boolean; + allowMultiSelect: boolean, + children: ReactNode, + style?: React.CSSProperties, + className?: string, + onChange: (value: SelectBoxValueType) => void, + value?: SelectBoxValueType, + defaultValue?: SelectBoxValueType, + isRequired?: boolean, + isDisabled?: boolean, + label?: ReactNode } const SelectorGroup = forwardRef(function SelectorGroupComponent({ @@ -145,6 +149,7 @@ const SelectorGroup = forwardRef(function Se defaultValue, isRequired, isDisabled, + label }, ref) { const props = { isRequired, @@ -153,7 +158,7 @@ const SelectorGroup = forwardRef(function Se style, children, onChange, - ref, + ref }; return allowMultiSelect ? ( @@ -161,13 +166,13 @@ const SelectorGroup = forwardRef(function Se {...props} value={ensureArray(value)} defaultValue={ensureArray(defaultValue)} - /> + aria-label={label ? String(label) : undefined} /> ) : ( + aria-label={label ? String(label) : undefined} /> ); }); @@ -191,7 +196,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p gutterWidth = 'default', isRequired = false, isDisabled = false, - UNSAFE_style, + UNSAFE_style } = props; const [value, setValue] = useState(defaultValue); @@ -241,18 +246,18 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p onChange={setValue} isRequired={isRequired} isDisabled={isDisabled} + label={label} ref={domRef} className={gridStyles({gutterWidth, orientation}, props.styles)} style={{ ...UNSAFE_style, gridTemplateColumns: `repeat(${numColumns}, 1fr)` - }} - > + }}> - {getChildrenToRender().map((child, _) => { + {getChildrenToRender().map((child) => { return child as ReactElement; })} ); -}); \ No newline at end of file +}); diff --git a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx index 511be078104..2e0c0b2696e 100644 --- a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx @@ -15,12 +15,12 @@ * from Adobe. **************************************************************************/ -import React from "react"; -import { action } from "@storybook/addon-actions"; -import type { Meta, StoryObj } from "@storybook/react"; -import { SelectBox, SelectBoxGroup, Text, createIcon } from "../src"; -import Server from "../spectrum-illustrations/linear/Server"; -import StarSVG from "../s2wf-icons/S2_Icon_Star_20_N.svg"; +import {action} from '@storybook/addon-actions'; +import {createIcon, SelectBox, SelectBoxGroup, Text} from '../src'; +import type {Meta, StoryObj} from '@storybook/react'; +import React from 'react'; +import Server from '../spectrum-illustrations/linear/Server'; +import StarSVG from '../s2wf-icons/S2_Icon_Star_20_N.svg'; const StarIcon = createIcon(StarSVG); @@ -31,13 +31,13 @@ const meta: Meta = { }, tags: ['autodocs'], argTypes: { - onSelectionChange: { table: { category: "Events" } }, - label: { control: { type: "text" } }, - description: { control: { type: "text" } }, - errorMessage: { control: { type: "text" } }, - children: { table: { disable: true } }, + onSelectionChange: {table: {category: 'Events'}}, + label: {control: {type: 'text'}}, + description: {control: {type: 'text'}}, + errorMessage: {control: {type: 'text'}}, + children: {table: {disable: true}} }, - title: "SelectBox", + title: 'SelectBox' }; export default meta; @@ -45,17 +45,16 @@ type Story = StoryObj; export const Example: Story = { args: { - label: "Choose an option", - orientation: "vertical", - necessityIndicator: "label", - size: "M", - labelPosition: "side", + label: 'Choose an option', + orientation: 'vertical', + necessityIndicator: 'label', + size: 'M', + labelPosition: 'side' }, render: (args) => ( console.log("Selection changed:", v)} - > + onSelectionChange={(v) => console.log('Selection changed:', v)}> Select Box Label @@ -69,22 +68,21 @@ export const Example: Story = { Select Box Label - ), + ) }; export const SingleSelectNumColumns: Story = { args: { numColumns: 2, - label: "Favorite city", - size: "XL", - gutterWidth: "default", + label: 'Favorite city', + size: 'XL', + gutterWidth: 'default' }, render: (args) => { return ( action("onSelectionChange")(v)} - > + onSelectionChange={(v) => action('onSelectionChange')(v)}> Paris @@ -103,21 +101,20 @@ export const SingleSelectNumColumns: Story = { ); }, - name: "Multiple columns", + name: 'Multiple columns' }; export const MultipleSelection: Story = { args: { numColumns: 1, - label: "Favorite cities", - selectionMode: "multiple", + label: 'Favorite cities', + selectionMode: 'multiple' }, render: (args) => { return ( action("onSelectionChange")(v)} - > + onSelectionChange={(v) => action('onSelectionChange')(v)}> {/* */} Paris @@ -136,20 +133,19 @@ export const MultipleSelection: Story = { ); }, - name: "Multiple selection mode", + name: 'Multiple selection mode' }; export const HorizontalOrientation: Story = { args: { - orientation: "horizontal", - label: "Favorite cities", + orientation: 'horizontal', + label: 'Favorite cities' }, render: (args) => { return ( action("onSelectionChange")(v)} - > + onSelectionChange={(v) => action('onSelectionChange')(v)}> Paris France @@ -165,5 +161,5 @@ export const HorizontalOrientation: Story = { ); }, - name: "Horizontal orientation", + name: 'Horizontal orientation' }; diff --git a/packages/@react-spectrum/s2/test/SelectBox.test.tsx b/packages/@react-spectrum/s2/test/SelectBox.test.tsx new file mode 100644 index 00000000000..dbc55035cd3 --- /dev/null +++ b/packages/@react-spectrum/s2/test/SelectBox.test.tsx @@ -0,0 +1,363 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/react'; +import {SelectBox} from '../src/SelectBox'; +import {SelectBoxGroup} from '../src/SelectBoxGroup'; +import userEvent from '@testing-library/user-event'; + +// Mock all style-related imports +jest.mock('../style', () => ({ + style: jest.fn(() => jest.fn(() => 'mocked-style')), // Return a function that can be called + focusRing: jest.fn(() => ({})), + baseColor: jest.fn(() => ({})), + color: jest.fn(() => '#000000'), + raw: jest.fn((strings) => strings.join('')), + lightDark: jest.fn(() => '#000000') +})); + +jest.mock('../src/style-utils', () => ({ + controlFont: {}, + getAllowedOverrides: jest.fn(() => ({})) +})); + +jest.mock('@react-spectrum/utils', () => ({ + useFocusableRef: jest.fn((ref) => ref), + useDOMRef: jest.fn((ref) => ref) +})); + +jest.mock('../src/useSpectrumContextProps', () => ({ + useSpectrumContextProps: jest.fn((props, ref) => [props, ref]) +})); + +jest.mock('../src/Checkbox', () => ({ + Checkbox: ({value, isSelected, isDisabled, size}) => + React.createElement('div', { + role: 'checkbox', + 'aria-checked': isSelected, + 'aria-disabled': isDisabled, + 'data-testid': `checkbox-${value}`, + 'data-size': size + }, 'Mock Checkbox') +})); + +// Test helpers +function SingleSelectBox() { + const [value, setValue] = React.useState(''); + return ( + setValue(val as string)} + value={value} + label="Single select test"> + Option 1 + Option 2 + Option 3 + + ); +} + +function MultiSelectBox() { + const [value, setValue] = React.useState([]); + return ( + setValue(val as string[])} + value={value} + label="Multi select test"> + Option 1 + Option 2 + Option 3 + + ); +} + +function DisabledSelectBox() { + return ( + {}} + isDisabled + label="Disabled select test"> + Option 1 + Option 2 + + ); +} + +describe('SelectBox', () => { + describe('Basic functionality', () => { + it('renders single select mode', () => { + render(); + expect(screen.getAllByRole('radio')).toHaveLength(3); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('renders multiple select mode', () => { + render(); + expect(screen.getAllByRole('checkbox')).toHaveLength(3); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('handles multiple selection', async () => { + render(); + const option1 = screen.getByDisplayValue('option1'); + const option2 = screen.getByDisplayValue('option2'); + + await userEvent.click(option1); + await userEvent.click(option2); + + expect(option1).toBeChecked(); + expect(option2).toBeChecked(); + }); + + it('handles disabled state', () => { + render(); + const inputs = screen.getAllByRole('radio'); + inputs.forEach(input => { + expect(input).toBeDisabled(); + }); + }); + }); + + describe('Props and configuration', () => { + it('supports different sizes', () => { + render( + {}} size="L" label="Size test"> + Option 1 + + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + + it('supports horizontal orientation', () => { + render( + {}} orientation="horizontal" label="Orientation test"> + Option 1 + + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + + it('supports custom number of columns', () => { + render( + {}} numColumns={3} label="Columns test"> + Option 1 + + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + + it('supports labels with aria-label', () => { + render( + {}} label="Choose an option"> + Option 1 + + ); + + expect(screen.getByLabelText('Choose an option')).toBeInTheDocument(); + }); + + it('supports required state', () => { + render( + {}} isRequired label="Required test"> + Option 1 + + ); + const group = screen.getByRole('radiogroup'); + expect(group).toBeRequired(); + }); + }); + + describe('Controlled and uncontrolled behavior', () => { + it('handles default value', () => { + render( + {}} defaultValue="option1" label="Default value test"> + Option 1 + Option 2 + + ); + + const option1 = screen.getByDisplayValue('option1'); + expect(option1).toBeChecked(); + }); + + it('handles multiple selection with default values', () => { + render( + {}} + defaultValue={['option1', 'option2']} + label="Multiple default test"> + Option 1 + Option 2 + Option 3 + + ); + + const option1 = screen.getByDisplayValue('option1'); + const option2 = screen.getByDisplayValue('option2'); + const option3 = screen.getByDisplayValue('option3'); + + expect(option1).toBeChecked(); + expect(option2).toBeChecked(); + expect(option3).not.toBeChecked(); + }); + }); + + describe('Individual SelectBox behavior', () => { + it('shows checkbox indicator when hovered', async () => { + render( + {}} label="Hover test"> + Option 1 + + ); + const option1 = screen.getByDisplayValue('option1'); + await userEvent.hover(option1); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-option1')).toBeInTheDocument(); + }); + }); + + it('handles disabled individual items', () => { + render( + {}} label="Individual disabled test"> + Option 1 + Option 2 + + ); + + const option1 = screen.getByDisplayValue('option1'); + const option2 = screen.getByDisplayValue('option2'); + + expect(option1).toBeDisabled(); + expect(option2).not.toBeDisabled(); + }); + }); + + describe('Children validation', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + }); + + it('validates minimum children', () => { + render( + {}} label="Min children test"> + {[]} + + ); + + expect(console.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('at least a title') + }) + ); + }); + + it('validates maximum children', () => { + const manyChildren = Array.from({length: 10}, (_, i) => ( + Option {i} + )); + + render( + {}} label="Max children test"> + {manyChildren} + + ); + + expect(console.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('more than 9 children') + }) + ); + }); + }); + + describe('Accessibility', () => { + it('has proper ARIA roles', () => { + render( + {}} label="ARIA test"> + Option 1 + Option 2 + Option 3 + + ); + expect(screen.getByRole('radiogroup')).toBeInTheDocument(); + expect(screen.getAllByRole('radio')).toHaveLength(3); + }); + + it('has proper ARIA roles for multiple selection', () => { + render( + {}} label="ARIA multi test"> + Option 1 + Option 2 + Option 3 + + ); + expect(screen.getByRole('group')).toBeInTheDocument(); + expect(screen.getAllByRole('checkbox')).toHaveLength(3); + }); + + it('associates labels correctly', () => { + render( + {}} label="Choose option"> + Option 1 + + ); + + expect(screen.getByLabelText('Choose option')).toBeInTheDocument(); + }); + }); + + describe('Edge cases', () => { + it('handles empty value', () => { + render( + {}} label="Empty value test"> + Empty + + ); + + const option = screen.getByDisplayValue(''); + expect(option).toHaveAttribute('value', ''); + }); + + it('handles complex children', () => { + render( + {}} label="Complex children test"> + +
+

Title

+

Description

+
+
+
+ ); + + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('handles different gutter widths', () => { + render( + {}} gutterWidth="compact" label="Gutter test"> + Option 1 + + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + + it('handles emphasized style', () => { + render( + {}} isEmphasized label="Emphasized test"> + Option 1 + + ); + expect(screen.getByRole('radio')).toBeInTheDocument(); + }); + }); +}); + + From 7c6a91bb7f0044029a3c60f6eeb532853f1d5905 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Mon, 14 Jul 2025 15:59:23 -0700 Subject: [PATCH 3/9] updated tests to remove styles --- .../s2/test/SelectBox.test.tsx | 68 ++++++------------- 1 file changed, 19 insertions(+), 49 deletions(-) diff --git a/packages/@react-spectrum/s2/test/SelectBox.test.tsx b/packages/@react-spectrum/s2/test/SelectBox.test.tsx index dbc55035cd3..9adc3639b7c 100644 --- a/packages/@react-spectrum/s2/test/SelectBox.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBox.test.tsx @@ -4,42 +4,6 @@ import {SelectBox} from '../src/SelectBox'; import {SelectBoxGroup} from '../src/SelectBoxGroup'; import userEvent from '@testing-library/user-event'; -// Mock all style-related imports -jest.mock('../style', () => ({ - style: jest.fn(() => jest.fn(() => 'mocked-style')), // Return a function that can be called - focusRing: jest.fn(() => ({})), - baseColor: jest.fn(() => ({})), - color: jest.fn(() => '#000000'), - raw: jest.fn((strings) => strings.join('')), - lightDark: jest.fn(() => '#000000') -})); - -jest.mock('../src/style-utils', () => ({ - controlFont: {}, - getAllowedOverrides: jest.fn(() => ({})) -})); - -jest.mock('@react-spectrum/utils', () => ({ - useFocusableRef: jest.fn((ref) => ref), - useDOMRef: jest.fn((ref) => ref) -})); - -jest.mock('../src/useSpectrumContextProps', () => ({ - useSpectrumContextProps: jest.fn((props, ref) => [props, ref]) -})); - -jest.mock('../src/Checkbox', () => ({ - Checkbox: ({value, isSelected, isDisabled, size}) => - React.createElement('div', { - role: 'checkbox', - 'aria-checked': isSelected, - 'aria-disabled': isDisabled, - 'data-testid': `checkbox-${value}`, - 'data-size': size - }, 'Mock Checkbox') -})); - -// Test helpers function SingleSelectBox() { const [value, setValue] = React.useState(''); return ( @@ -99,8 +63,9 @@ describe('SelectBox', () => { it('handles multiple selection', async () => { render(); - const option1 = screen.getByDisplayValue('option1'); - const option2 = screen.getByDisplayValue('option2'); + const checkboxes = screen.getAllByRole('checkbox'); + const option1 = checkboxes.find(cb => cb.getAttribute('value') === 'option1')!; + const option2 = checkboxes.find(cb => cb.getAttribute('value') === 'option2')!; await userEvent.click(option1); await userEvent.click(option2); @@ -176,7 +141,8 @@ describe('SelectBox', () => { ); - const option1 = screen.getByDisplayValue('option1'); + const radios = screen.getAllByRole('radio'); + const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; expect(option1).toBeChecked(); }); @@ -193,9 +159,10 @@ describe('SelectBox', () => { ); - const option1 = screen.getByDisplayValue('option1'); - const option2 = screen.getByDisplayValue('option2'); - const option3 = screen.getByDisplayValue('option3'); + const checkboxes = screen.getAllByRole('checkbox'); + const option1 = checkboxes.find(cb => cb.getAttribute('value') === 'option1')!; + const option2 = checkboxes.find(cb => cb.getAttribute('value') === 'option2')!; + const option3 = checkboxes.find(cb => cb.getAttribute('value') === 'option3')!; expect(option1).toBeChecked(); expect(option2).toBeChecked(); @@ -210,11 +177,13 @@ describe('SelectBox', () => { Option 1 ); - const option1 = screen.getByDisplayValue('option1'); - await userEvent.hover(option1); + + const label = screen.getByText('Option 1').closest('label')!; + await userEvent.hover(label); await waitFor(() => { - expect(screen.getByTestId('checkbox-option1')).toBeInTheDocument(); + const checkboxes = screen.getAllByRole('checkbox'); + expect(checkboxes.length).toBeGreaterThan(0); }); }); @@ -226,8 +195,9 @@ describe('SelectBox', () => { ); - const option1 = screen.getByDisplayValue('option1'); - const option2 = screen.getByDisplayValue('option2'); + const radios = screen.getAllByRole('radio'); + const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; + const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; expect(option1).toBeDisabled(); expect(option2).not.toBeDisabled(); @@ -320,8 +290,8 @@ describe('SelectBox', () => { ); - const option = screen.getByDisplayValue(''); - expect(option).toHaveAttribute('value', ''); + const radio = screen.getByRole('radio'); + expect(radio).toHaveAttribute('value', ''); }); it('handles complex children', () => { From b0a9b78351106b7149aee7ad32f0db8e7a4cdd13 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Tue, 15 Jul 2025 13:23:13 -0700 Subject: [PATCH 4/9] selectbox refactor and tests refactor --- packages/@react-spectrum/s2/src/SelectBox.tsx | 142 ++++++++++++-- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 61 ++++-- .../s2/test/SelectBox.test.tsx | 182 +++++++++++++++--- 3 files changed, 326 insertions(+), 59 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index f257a99ae42..1ce9d35d8c5 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -49,7 +49,12 @@ export const SelectBoxItemContext = createContext UNSAFE_className + selectBoxStyles({...renderProps, size, orientation}, props.styles)}> - {renderProps => ( - <> - {(renderProps.isSelected || renderProps.isHovered) && ( -
- -
- )} - {children} - - )} + {renderProps => { + // Separate icon and text content from children + const childrenArray = React.Children.toArray(children); + const iconElement = childrenArray.find((child: any) => child?.props?.slot === 'icon'); + const textElement = childrenArray.find((child: any) => child?.props?.slot === 'text'); + const descriptionElement = childrenArray.find((child: any) => child?.props?.slot === 'description'); + const otherChildren = childrenArray.filter((child: any) => + !['icon', 'text', 'description'].includes(child?.props?.slot) + ); + + return ( + <> + {(renderProps.isSelected || renderProps.isHovered) && ( +
+ +
+ )} + + {orientation === 'horizontal' ? ( + <> + {iconElement && ( +
+ {iconElement} +
+ )} +
+
+ {textElement} + {descriptionElement && ( +
+ {descriptionElement} +
+ )} +
+
+ + ) : ( + <> + {iconElement && ( +
+ {iconElement} +
+ )} +
+ {textElement} + {/* Description is hidden in vertical orientation */} +
+ + )} + + {/* Render any other children that don't have slots */} + {otherChildren.length > 0 && otherChildren} + + ); + }} ); }); diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 17db7606753..439c8bc87cd 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -16,7 +16,7 @@ import { } from 'react-aria-components'; import {DOMRef, DOMRefValue, HelpTextProps, Orientation, SpectrumLabelableProps} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import React, {createContext, forwardRef, ReactElement, ReactNode, useEffect, useMemo, useState} from 'react'; +import React, {createContext, forwardRef, ReactElement, ReactNode, useMemo, useState} from 'react'; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -40,11 +40,7 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, /** * The current selected value (controlled). */ - value?: SelectBoxValueType, - /** - * The default selected value. - */ - defaultValue?: SelectBoxValueType, + value: SelectBoxValueType, /** * The size of the SelectBoxGroup. * @default 'M' @@ -132,8 +128,7 @@ interface SelectorGroupProps { style?: React.CSSProperties, className?: string, onChange: (value: SelectBoxValueType) => void, - value?: SelectBoxValueType, - defaultValue?: SelectBoxValueType, + value: SelectBoxValueType, isRequired?: boolean, isDisabled?: boolean, label?: ReactNode @@ -146,7 +141,6 @@ const SelectorGroup = forwardRef(function Se onChange, value, style, - defaultValue, isRequired, isDisabled, label @@ -165,13 +159,11 @@ const SelectorGroup = forwardRef(function Se ) : ( ); }); @@ -179,6 +171,41 @@ const SelectorGroup = forwardRef(function Se /** * SelectBox groups allow users to select one or more options from a list. * All possible options are exposed up front for users to compare. + * + * SelectBoxGroup is a controlled component that requires a `value` prop and + * `onSelectionChange` callback. + * + * @example + * ```tsx + * // Single selection + * function SingleSelectExample() { + * const [selected, setSelected] = React.useState('option1'); + * return ( + * + * Option 1 + * Option 2 + * + * ); + * } + * + * // Multiple selection + * function MultiSelectExample() { + * const [selected, setSelected] = React.useState(['option1']); + * return ( + * + * Option 1 + * Option 2 + * + * ); + * } + * ``` */ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); @@ -187,7 +214,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p label, children, onSelectionChange, - defaultValue, + value, selectionMode = 'single', size = 'M', orientation = 'vertical', @@ -199,7 +226,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p UNSAFE_style } = props; - const [value, setValue] = useState(defaultValue); const allowMultiSelect = selectionMode === 'multiple'; const domRef = useDOMRef(ref); @@ -220,12 +246,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p return childrenToRender; }; - useEffect(() => { - if (value !== undefined) { - onSelectionChange(value); - } - }, [onSelectionChange, value]); - // Context value const selectBoxContextValue = useMemo( () => ({ @@ -242,8 +262,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p {}} + value="" isDisabled label="Disabled select test"> Option 1 @@ -86,7 +87,7 @@ describe('SelectBox', () => { describe('Props and configuration', () => { it('supports different sizes', () => { render( - {}} size="L" label="Size test"> + {}} value="" size="L" label="Size test"> Option 1 ); @@ -95,7 +96,7 @@ describe('SelectBox', () => { it('supports horizontal orientation', () => { render( - {}} orientation="horizontal" label="Orientation test"> + {}} value="" orientation="horizontal" label="Orientation test"> Option 1 ); @@ -104,7 +105,7 @@ describe('SelectBox', () => { it('supports custom number of columns', () => { render( - {}} numColumns={3} label="Columns test"> + {}} value="" numColumns={3} label="Columns test"> Option 1 ); @@ -113,7 +114,7 @@ describe('SelectBox', () => { it('supports labels with aria-label', () => { render( - {}} label="Choose an option"> + {}} value="" label="Choose an option"> Option 1 ); @@ -123,7 +124,7 @@ describe('SelectBox', () => { it('supports required state', () => { render( - {}} isRequired label="Required test"> + {}} value="" isRequired label="Required test"> Option 1 ); @@ -132,10 +133,10 @@ describe('SelectBox', () => { }); }); - describe('Controlled and uncontrolled behavior', () => { - it('handles default value', () => { + describe('Controlled behavior', () => { + it('handles initial value selection', () => { render( - {}} defaultValue="option1" label="Default value test"> + {}} value="option1" label="Initial value test"> Option 1 Option 2 @@ -146,13 +147,13 @@ describe('SelectBox', () => { expect(option1).toBeChecked(); }); - it('handles multiple selection with default values', () => { + it('handles multiple selection with initial values', () => { render( {}} - defaultValue={['option1', 'option2']} - label="Multiple default test"> + value={['option1', 'option2']} + label="Multiple initial test"> Option 1 Option 2 Option 3 @@ -170,10 +171,142 @@ describe('SelectBox', () => { }); }); + describe('Controlled values', () => { + it('handles controlled single selection', async () => { + const ControlledSingleSelect = () => { + const [value, setValue] = React.useState('option1'); + return ( + setValue(val as string)} + value={value} + label="Controlled single select"> + Option 1 + Option 2 + Option 3 + + ); + }; + + render(); + const radios = screen.getAllByRole('radio'); + const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; + const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + + expect(option1).toBeChecked(); + expect(option2).not.toBeChecked(); + + await userEvent.click(option2); + expect(option2).toBeChecked(); + expect(option1).not.toBeChecked(); + }); + + it('handles controlled multiple selection', async () => { + const ControlledMultiSelect = () => { + const [value, setValue] = React.useState(['option1']); + return ( + setValue(val as string[])} + value={value} + label="Controlled multi select"> + Option 1 + Option 2 + Option 3 + + ); + }; + + render(); + const checkboxes = screen.getAllByRole('checkbox'); + const option1 = checkboxes.find(cb => cb.getAttribute('value') === 'option1')!; + const option2 = checkboxes.find(cb => cb.getAttribute('value') === 'option2')!; + + expect(option1).toBeChecked(); + expect(option2).not.toBeChecked(); + + await userEvent.click(option2); + expect(option1).toBeChecked(); + expect(option2).toBeChecked(); + }); + + it('controlled value works as expected', () => { + render( + {}} + value="option2" + label="Controlled test"> + Option 1 + Option 2 + + ); + + const radios = screen.getAllByRole('radio'); + const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; + const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + + expect(option1).not.toBeChecked(); + expect(option2).toBeChecked(); + }); + + it('calls onSelectionChange when controlled value changes', async () => { + const onSelectionChange = jest.fn(); + const ControlledWithCallback = () => { + const [value, setValue] = React.useState('option1'); + return ( + { + setValue(val as string); + onSelectionChange(val); + }} + value={value} + label="Controlled callback test"> + Option 1 + Option 2 + + ); + }; + + render(); + const radios = screen.getAllByRole('radio'); + const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + + await userEvent.click(option2); + expect(onSelectionChange).toHaveBeenCalledWith('option2'); + }); + + it('handles external controlled value changes', () => { + const ControlledExternal = ({externalValue}: {externalValue: string}) => ( + {}} + value={externalValue} + label="External controlled test"> + Option 1 + Option 2 + + ); + + const {rerender} = render(); + const radios = screen.getAllByRole('radio'); + const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; + const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + + expect(option1).toBeChecked(); + expect(option2).not.toBeChecked(); + + rerender(); + expect(option1).not.toBeChecked(); + expect(option2).toBeChecked(); + }); + }); + describe('Individual SelectBox behavior', () => { it('shows checkbox indicator when hovered', async () => { render( - {}} label="Hover test"> + {}} value="" label="Hover test"> Option 1 ); @@ -189,7 +322,12 @@ describe('SelectBox', () => { it('handles disabled individual items', () => { render( - {}} label="Individual disabled test"> + {}} + value="option2" + label="Individual disabled test" + > Option 1 Option 2 @@ -215,7 +353,7 @@ describe('SelectBox', () => { it('validates minimum children', () => { render( - {}} label="Min children test"> + {}} value="" label="Min children test"> {[]} ); @@ -233,7 +371,7 @@ describe('SelectBox', () => { )); render( - {}} label="Max children test"> + {}} value="" label="Max children test"> {manyChildren} ); @@ -249,7 +387,7 @@ describe('SelectBox', () => { describe('Accessibility', () => { it('has proper ARIA roles', () => { render( - {}} label="ARIA test"> + {}} value="" label="ARIA test"> Option 1 Option 2 Option 3 @@ -261,7 +399,7 @@ describe('SelectBox', () => { it('has proper ARIA roles for multiple selection', () => { render( - {}} label="ARIA multi test"> + {}} value={[]} label="ARIA multi test"> Option 1 Option 2 Option 3 @@ -273,7 +411,7 @@ describe('SelectBox', () => { it('associates labels correctly', () => { render( - {}} label="Choose option"> + {}} value="" label="Choose option"> Option 1 ); @@ -285,7 +423,7 @@ describe('SelectBox', () => { describe('Edge cases', () => { it('handles empty value', () => { render( - {}} label="Empty value test"> + {}} value="" label="Empty value test"> Empty ); @@ -296,7 +434,7 @@ describe('SelectBox', () => { it('handles complex children', () => { render( - {}} label="Complex children test"> + {}} value="" label="Complex children test">

Title

@@ -312,7 +450,7 @@ describe('SelectBox', () => { it('handles different gutter widths', () => { render( - {}} gutterWidth="compact" label="Gutter test"> + {}} value="" gutterWidth="compact" label="Gutter test"> Option 1 ); @@ -321,7 +459,7 @@ describe('SelectBox', () => { it('handles emphasized style', () => { render( - {}} isEmphasized label="Emphasized test"> + {}} value="" isEmphasized label="Emphasized test"> Option 1 ); From 151e4f6f626f6f7811ea95e1602d7f869afd58f1 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Tue, 15 Jul 2025 16:27:07 -0700 Subject: [PATCH 5/9] stories changes and various edits --- packages/@react-spectrum/s2/src/SelectBox.tsx | 87 ++++------ .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 156 ++++++++---------- packages/@react-spectrum/s2/src/index.ts | 8 +- .../s2/stories/SelectBox.stories.tsx | 46 +++++- 4 files changed, 148 insertions(+), 149 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 1ce9d35d8c5..0fd79694656 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -61,8 +61,6 @@ const selectBoxStyles = style({ alignItems: 'center', fontFamily: 'sans', font: 'ui', - - // Vertical orientation (default) - Fixed square dimensions width: { default: { size: { @@ -74,18 +72,9 @@ const selectBoxStyles = style({ } }, orientation: { - horizontal: { - size: { - XS: 'auto', - S: 'auto', - M: 'auto', - L: 'auto', - XL: 'auto' - } - } + horizontal: 'auto' } }, - height: { default: { size: { @@ -97,30 +86,19 @@ const selectBoxStyles = style({ } }, orientation: { - horizontal: { - size: { - XS: 'auto', - S: 'auto', - M: 'auto', - L: 'auto', - XL: 'auto' - } - } + horizontal: 'auto' } }, - minWidth: { orientation: { horizontal: 160 } }, - maxWidth: { orientation: { horizontal: 272 } }, - padding: { size: { XS: 12, @@ -130,9 +108,15 @@ const selectBoxStyles = style({ XL: 28 } }, - borderRadius: 'lg', - backgroundColor: 'layer-2', + backgroundColor: { + default: 'layer-2', + isDisabled: 'layer-1', + }, + color: { + isEmphasized: 'gray-900', + isDisabled: 'disabled' + }, boxShadow: { default: 'emphasized', isHovered: 'elevated', @@ -141,17 +125,13 @@ const selectBoxStyles = style({ }, position: 'relative', borderWidth: 2, - borderStyle: { - default: 'solid', - isSelected: 'solid' - }, + borderStyle: 'solid', borderColor: { + // isHovered: 'gray-900', + // isSelected: 'gray-900', default: 'transparent', - isSelected: 'gray-900', - isFocusVisible: 'transparent' }, transition: 'default', - gap: { orientation: { horizontal: 'text-to-visual' @@ -186,7 +166,17 @@ const iconContainer = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', - flexShrink: 0 + size: { + XS: 16, + S: 20, + M: 24, + L: 28, + XL: 32 + }, + flexShrink: 0, + color: { + isDisabled: 'disabled' + } }, getAllowedOverrides()); const textContainer = style({ @@ -198,7 +188,10 @@ const textContainer = style({ horizontal: 'start' } }, - gap: 'text-to-visual' + gap: 'text-to-visual', + color: { + isDisabled: 'disabled' + } }, getAllowedOverrides()); const descriptionText = style({ @@ -209,7 +202,10 @@ const descriptionText = style({ } }, font: 'ui-sm', - color: 'gray-600', + color: { + default: 'gray-600', + isDisabled: 'disabled' + }, lineHeight: 'body' }, getAllowedOverrides()); @@ -229,28 +225,20 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele let inputRef = useRef(null); let domRef = useFocusableRef(ref, inputRef); - let groupContext = useContext(SelectBoxContext); let { allowMultiSelect = false, size = 'M', orientation = 'vertical' - } = groupContext; + } = useContext(SelectBoxContext); const Selector = allowMultiSelect ? AriaCheckbox : AriaRadio; - // Handle controlled selection - const handleSelectionChange = (selected: boolean) => { - if (onChange) { - onChange(selected); - } - }; - return ( onChange?.(isSelected ?? false)} ref={domRef} inputRef={inputRef} style={UNSAFE_style} @@ -267,13 +255,13 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele return ( <> - {(renderProps.isSelected || renderProps.isHovered) && ( + {(renderProps.isSelected || renderProps.isHovered || renderProps.isFocusVisible) && (
+ size={size === 'XS' ? 'S' : size} />
)} @@ -304,12 +292,9 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele )}
{textElement} - {/* Description is hidden in vertical orientation */}
)} - - {/* Render any other children that don't have slots */} {otherChildren.length > 0 && otherChildren} ); diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 439c8bc87cd..4cabddebeae 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -41,11 +41,15 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, * The current selected value (controlled). */ value: SelectBoxValueType, + /** + * The default selected value. + */ + defaultValue?: SelectBoxValueType, /** * The size of the SelectBoxGroup. * @default 'M' */ - size?: 'S' | 'M' | 'L' | 'XL', + size?: 'XS' | 'S' | 'M' | 'L' | 'XL', /** * The axis the SelectBox elements should align with. * @default 'vertical' @@ -77,13 +81,11 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, interface SelectBoxContextValue { allowMultiSelect?: boolean, - value?: SelectBoxValueType, - size?: 'S' | 'M' | 'L' | 'XL', + size?: 'XS' | 'S' | 'M' | 'L' | 'XL', orientation?: Orientation, isEmphasized?: boolean } -// Utility functions const unwrapValue = (value: SelectBoxValueType | undefined): string | undefined => { if (Array.isArray(value)) { return value[0]; @@ -121,92 +123,25 @@ const gridStyles = style({ }, getAllowedOverrides()); -// Selector Group component interface SelectorGroupProps { allowMultiSelect: boolean, children: ReactNode, - style?: React.CSSProperties, - className?: string, + UNSAFE_className?: string, + UNSAFE_style?: React.CSSProperties, + styles?: StyleProps, onChange: (value: SelectBoxValueType) => void, - value: SelectBoxValueType, + value?: SelectBoxValueType, + defaultValue?: SelectBoxValueType, isRequired?: boolean, isDisabled?: boolean, label?: ReactNode } -const SelectorGroup = forwardRef(function SelectorGroupComponent({ - allowMultiSelect, - children, - className, - onChange, - value, - style, - isRequired, - isDisabled, - label -}, ref) { - const props = { - isRequired, - isDisabled, - className, - style, - children, - onChange, - ref - }; - - return allowMultiSelect ? ( - - ) : ( - - ); -}); - /** * SelectBox groups allow users to select one or more options from a list. * All possible options are exposed up front for users to compare. - * - * SelectBoxGroup is a controlled component that requires a `value` prop and - * `onSelectionChange` callback. - * - * @example - * ```tsx - * // Single selection - * function SingleSelectExample() { - * const [selected, setSelected] = React.useState('option1'); - * return ( - * - * Option 1 - * Option 2 - * - * ); - * } - * - * // Multiple selection - * function MultiSelectExample() { - * const [selected, setSelected] = React.useState(['option1']); - * return ( - * - * Option 1 - * Option 2 - * - * ); - * } - * ``` */ + export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); @@ -214,7 +149,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p label, children, onSelectionChange, - value, + defaultValue, selectionMode = 'single', size = 'M', orientation = 'vertical', @@ -250,33 +185,72 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const selectBoxContextValue = useMemo( () => ({ allowMultiSelect, - value, size, orientation, isEmphasized }), - [allowMultiSelect, value, size, orientation, isEmphasized] + [allowMultiSelect, size, orientation, isEmphasized] ); return ( - - {getChildrenToRender().map((child) => { - return child as ReactElement; - })} - + UNSAFE_style={UNSAFE_style}> +
+ + {getChildrenToRender().map((child) => { + return child as ReactElement; + })} + +
); }); + +const SelectorGroup = forwardRef(function SelectorGroupComponent({ + allowMultiSelect, + children, + UNSAFE_className, + onChange, + value, + defaultValue, + UNSAFE_style, + isRequired, + isDisabled, + label +}, ref) { + const baseProps = { + isRequired, + isDisabled, + UNSAFE_className, + UNSAFE_style, + children, + onChange, + ref + }; + + return allowMultiSelect ? ( + + ) : ( + + ); +}); \ No newline at end of file diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 843be0d6af3..efa16c2fe0a 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -69,11 +69,11 @@ export {Provider} from './Provider'; export {Radio} from './Radio'; export {RadioGroup, RadioGroupContext} from './RadioGroup'; export {RangeCalendar, RangeCalendarContext} from './RangeCalendar'; -export {SelectBox} from './SelectBox'; -export {SelectBoxGroup, SelectBoxContext} from './SelectBoxGroup'; export {RangeSlider, RangeSliderContext} from './RangeSlider'; export {SearchField, SearchFieldContext} from './SearchField'; export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl'; +export {SelectBox} from './SelectBox'; +export {SelectBoxGroup, SelectBoxContext} from './SelectBoxGroup'; export {Slider, SliderContext} from './Slider'; export {Skeleton, useIsSkeleton} from './Skeleton'; export {SkeletonCollection} from './SkeletonCollection'; @@ -148,10 +148,10 @@ export type {ProgressCircleProps} from './ProgressCircle'; export type {ProviderProps} from './Provider'; export type {RadioProps} from './Radio'; export type {RadioGroupProps} from './RadioGroup'; -export type {SelectBoxProps} from './SelectBox'; -export type {SelectBoxGroupProps, SelectBoxValueType} from './SelectBoxGroup'; export type {SearchFieldProps} from './SearchField'; export type {SegmentedControlProps, SegmentedControlItemProps} from './SegmentedControl'; +export type {SelectBoxProps} from './SelectBox'; +export type {SelectBoxGroupProps, SelectBoxValueType} from './SelectBoxGroup'; export type {SliderProps} from './Slider'; export type {RangeCalendarProps} from './RangeCalendar'; export type {RangeSliderProps} from './RangeSlider'; diff --git a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx index 2e0c0b2696e..c897bb79dc5 100644 --- a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx @@ -56,15 +56,18 @@ export const Example: Story = { {...args} onSelectionChange={(v) => console.log('Selection changed:', v)}> - + + Select Box Label Select Box Label - + + Select Box Label Select Box Label - + + Select Box Label Select Box Label
@@ -163,3 +166,40 @@ export const HorizontalOrientation: Story = { }, name: 'Horizontal orientation' }; + +export const IndividualDisabled: Story = { + args: { + numColumns: 2, + label: 'Choose options (some disabled)', + selectionMode: 'multiple' + }, + render: (args) => { + return ( + action('onSelectionChange')(v)}> + + + Available Option + This option is enabled + + + + Disabled Option + This option is disabled + + + + Another Available + This option is also enabled + + + + Another Disabled + This option is also disabled + + + ); + }, + name: 'Individual disabled SelectBoxes' +}; From d64c17be0ea24311aa399b38522f0ef5a1658aba Mon Sep 17 00:00:00 2001 From: DPandyan Date: Wed, 16 Jul 2025 16:56:30 -0700 Subject: [PATCH 6/9] replaced aria components with a gridlist --- packages/@react-spectrum/s2/src/SelectBox.tsx | 210 +++++---- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 381 ++++++++++++----- .../s2/stories/SelectBox.stories.tsx | 205 --------- .../s2/stories/SelectBoxGroup.stories.tsx | 403 ++++++++++++++++++ ...ctBox.test.tsx => SelectBoxGroup.test.tsx} | 41 +- 5 files changed, 814 insertions(+), 426 deletions(-) delete mode 100644 packages/@react-spectrum/s2/stories/SelectBox.stories.tsx create mode 100644 packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx rename packages/@react-spectrum/s2/test/{SelectBox.test.tsx => SelectBoxGroup.test.tsx} (95%) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 0fd79694656..c70854584bb 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -9,18 +9,16 @@ * governing permissions and limitations under the License. */ -import {Checkbox as AriaCheckbox, Radio as AriaRadio, CheckboxProps, ContextValue, RadioProps} from 'react-aria-components'; import {Checkbox} from './Checkbox'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; -import {focusRing, style} from '../style' with {type: 'macro'}; +import {focusRing, style, lightDark} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import React, {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; import {SelectBoxContext} from './SelectBoxGroup'; import {useFocusableRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface SelectBoxProps extends - Omit, StyleProps { +export interface SelectBoxProps extends StyleProps { /** * The value of the SelectBox. */ @@ -32,22 +30,13 @@ export interface SelectBoxProps extends /** * Whether the SelectBox is disabled. */ - isDisabled?: boolean, - /** - * Whether the SelectBox is selected (controlled). - */ - isSelected?: boolean, - /** - * Handler called when the SelectBox selection changes. - */ - onChange?: (isSelected: boolean) => void + isDisabled?: boolean } -export const SelectBoxItemContext = createContext, FocusableRefValue>>(null); +export const SelectBoxItemContext = createContext(null); // Simple basic styling with proper dark mode support const selectBoxStyles = style({ - ...focusRing(), display: 'flex', flexDirection: { default: 'column', @@ -59,7 +48,6 @@ const selectBoxStyles = style({ justifyContent: 'center', flexShrink: 0, alignItems: 'center', - fontFamily: 'sans', font: 'ui', width: { default: { @@ -112,6 +100,7 @@ const selectBoxStyles = style({ backgroundColor: { default: 'layer-2', isDisabled: 'layer-1', + isSelected: 'layer-2' }, color: { isEmphasized: 'gray-900', @@ -123,20 +112,27 @@ const selectBoxStyles = style({ isSelected: 'elevated', forcedColors: 'none' }, + outlineStyle: 'none', position: 'relative', borderWidth: 2, borderStyle: 'solid', borderColor: { - // isHovered: 'gray-900', - // isSelected: 'gray-900', + // default: 'transparent', + // isSelected: lightDark('accent-900', 'accent-700') default: 'transparent', + isSelected: 'gray-900', + isFocusVisible: 'blue-900' }, transition: 'default', gap: { orientation: { horizontal: 'text-to-visual' } - } + }, + cursor: { + default: 'pointer', + isDisabled: 'default' + }, }, getAllowedOverrides()); const contentContainer = style({ @@ -215,90 +211,118 @@ const checkboxContainer = style({ left: 16 }, getAllowedOverrides()); +// Context for passing GridListItem render props down to SelectBox +const SelectBoxRenderPropsContext = createContext<{ + isHovered?: boolean; + isFocusVisible?: boolean; + isPressed?: boolean; +}>({}); + /** * SelectBox components allow users to select options from a list. - * They can behave as radio buttons (single selection) or checkboxes (multiple selection). + * Works as content within a GridListItem for automatic grid navigation. */ -export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { +export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxItemContext); - let {children, value, isDisabled = false, isSelected, onChange, UNSAFE_className = '', UNSAFE_style} = props; - let inputRef = useRef(null); - let domRef = useFocusableRef(ref, inputRef); + let {children, value, isDisabled: individualDisabled = false, UNSAFE_className = '', UNSAFE_style} = props; + let divRef = useRef(null); + let domRef = useFocusableRef(ref, divRef); + let contextValue = useContext(SelectBoxContext); let { - allowMultiSelect = false, - size = 'M', - orientation = 'vertical' - } = useContext(SelectBoxContext); + size = 'M', // Match SelectBoxGroup default + orientation = 'vertical', + selectedKeys, + isDisabled: groupDisabled = false + } = contextValue; + + // Access GridListItem render props from context + let renderProps = useContext(SelectBoxRenderPropsContext); + + // Merge individual and group disabled states + const isDisabled = individualDisabled || groupDisabled; + + // Determine if this item is selected based on the parent's selectedKeys + const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); - const Selector = allowMultiSelect ? AriaCheckbox : AriaRadio; + // Show checkbox when selected, disabled, or hovered + const showCheckbox = isSelected || isDisabled || renderProps.isHovered; return ( - onChange?.(isSelected ?? false)} +
UNSAFE_className + selectBoxStyles({...renderProps, size, orientation}, props.styles)}> - {renderProps => { - // Separate icon and text content from children - const childrenArray = React.Children.toArray(children); - const iconElement = childrenArray.find((child: any) => child?.props?.slot === 'icon'); - const textElement = childrenArray.find((child: any) => child?.props?.slot === 'text'); - const descriptionElement = childrenArray.find((child: any) => child?.props?.slot === 'description'); - const otherChildren = childrenArray.filter((child: any) => - !['icon', 'text', 'description'].includes(child?.props?.slot) - ); - - return ( - <> - {(renderProps.isSelected || renderProps.isHovered || renderProps.isFocusVisible) && ( -
- -
- )} - - {orientation === 'horizontal' ? ( - <> - {iconElement && ( -
- {iconElement} -
- )} -
-
- {textElement} - {descriptionElement && ( -
- {descriptionElement} -
- )} -
-
- - ) : ( - <> - {iconElement && ( -
- {iconElement} -
- )} -
- {textElement} + className={selectBoxStyles({ + size, + orientation, + isDisabled, + isSelected, + isHovered: renderProps.isHovered || false, + isFocusVisible: renderProps.isFocusVisible || false + }, props.styles)} + style={UNSAFE_style}> + + {/* Show selection indicator */} + {showCheckbox && ( +
+
+ +
+
+ )} + + {/* Content layout */} + {orientation === 'horizontal' ? ( + <> + {/* Icon */} + {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && ( +
+ {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')} +
+ )} + + {/* Content container for horizontal layout */} +
+
+ {/* Text */} + {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')} + + {/* Description */} + {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description') && ( +
+ {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description')}
- - )} - {otherChildren.length > 0 && otherChildren} - - ); - }} - + )} +
+
+ + ) : ( + <> + {/* Icon */} + {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && ( +
+ {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')} +
+ )} + + {/* Text container for vertical layout */} +
+ {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')} +
+ + )} + + {/* Other children */} + {React.Children.toArray(children).filter((child: any) => + !['icon', 'text', 'description'].includes(child?.props?.slot) + )} +
); }); + +// Export the context for use in SelectBoxGroup +export {SelectBoxRenderPropsContext}; diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 4cabddebeae..f3dea51753c 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -10,16 +10,19 @@ */ import { - CheckboxGroup as AriaCheckboxGroup, - RadioGroup as AriaRadioGroup, - ContextValue + GridList, + GridListItem, + ContextValue, + Text } from 'react-aria-components'; -import {DOMRef, DOMRefValue, HelpTextProps, Orientation, SpectrumLabelableProps} from '@react-types/shared'; +import {DOMRef, DOMRefValue, HelpTextProps, Orientation, SpectrumLabelableProps, Key, Selection} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import React, {createContext, forwardRef, ReactElement, ReactNode, useMemo, useState} from 'react'; +import React, {createContext, forwardRef, ReactElement, ReactNode, useMemo, useId, useEffect, useRef} from 'react'; import {style} from '../style' with {type: 'macro'}; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; +import {useControlledState} from '@react-stately/utils'; +import {SelectBoxRenderPropsContext} from './SelectBox'; export type SelectBoxValueType = string | string[]; @@ -40,7 +43,7 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, /** * The current selected value (controlled). */ - value: SelectBoxValueType, + value?: SelectBoxValueType, /** * The default selected value. */ @@ -76,31 +79,56 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, /** * Whether the SelectBoxGroup is disabled. */ - isDisabled?: boolean + isDisabled?: boolean, + /** + * The name of the form field. + */ + name?: string, + /** + * The error message to display when validation fails. + */ + errorMessage?: ReactNode, + /** + * Whether the SelectBoxGroup is in an invalid state. + */ + isInvalid?: boolean } interface SelectBoxContextValue { allowMultiSelect?: boolean, size?: 'XS' | 'S' | 'M' | 'L' | 'XL', orientation?: Orientation, - isEmphasized?: boolean + isEmphasized?: boolean, + isDisabled?: boolean, + selectedKeys?: Selection, + onSelectionChange?: (keys: Selection) => void } -const unwrapValue = (value: SelectBoxValueType | undefined): string | undefined => { +const convertValueToSelection = (value: SelectBoxValueType | undefined, selectionMode: 'single' | 'multiple'): Selection => { + if (value === undefined) { + return selectionMode === 'multiple' ? new Set() : new Set(); + } + if (Array.isArray(value)) { - return value[0]; + return new Set(value); } - return value; + + return selectionMode === 'multiple' ? new Set([value]) : new Set([value]); }; -const ensureArray = (value: SelectBoxValueType | undefined): string[] => { - if (value === undefined) { - return []; +const convertSelectionToValue = (selection: Selection, selectionMode: 'single' | 'multiple'): SelectBoxValueType => { + // Handle 'all' selection + if (selection === 'all') { + return selectionMode === 'multiple' ? [] : ''; } - if (Array.isArray(value)) { - return value; + + const keys = Array.from(selection).map(key => String(key)); + + if (selectionMode === 'multiple') { + return keys; } - return [value]; + + return keys[0] || ''; }; export const SelectBoxContext = createContext({ @@ -119,29 +147,77 @@ const gridStyles = style({ compact: 8, spacious: 24 } + }, + // Override default GridList styles to work with our grid layout + '&[role="grid"]': { + display: 'grid' } }, getAllowedOverrides()); +const containerStyles = style({ + display: 'flex', + flexDirection: 'column', + gap: 8 +}, getAllowedOverrides()); -interface SelectorGroupProps { - allowMultiSelect: boolean, - children: ReactNode, - UNSAFE_className?: string, - UNSAFE_style?: React.CSSProperties, - styles?: StyleProps, - onChange: (value: SelectBoxValueType) => void, - value?: SelectBoxValueType, - defaultValue?: SelectBoxValueType, +const errorMessageStyles = style({ + color: 'negative', + font: 'ui-sm' +}, getAllowedOverrides()); + +interface FormIntegrationProps { + name?: string, + value: SelectBoxValueType, isRequired?: boolean, - isDisabled?: boolean, - label?: ReactNode + isInvalid?: boolean +} + +// Hidden form inputs for form integration +function FormIntegration({name, value, isRequired, isInvalid}: FormIntegrationProps) { + if (!name) return null; + + if (Array.isArray(value)) { + return ( + <> + {value.map((val, index) => ( + + ))} + {value.length === 0 && isRequired && ( + + )} + + ); + } + + return ( + + ); } /** * SelectBox groups allow users to select one or more options from a list. * All possible options are exposed up front for users to compare. + * Built with GridList for automatic grid-based keyboard navigation. */ - export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); @@ -158,99 +234,190 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p gutterWidth = 'default', isRequired = false, isDisabled = false, + name, + errorMessage, + isInvalid = false, UNSAFE_style } = props; - const allowMultiSelect = selectionMode === 'multiple'; - const domRef = useDOMRef(ref); + const gridId = useId(); + const errorId = useId(); - const getChildrenToRender = () => { - const childrenToRender = React.Children.toArray(children).filter((x) => x); - try { - const childrenLength = childrenToRender.length; - if (childrenLength <= 0) { - throw new Error('Invalid content. SelectBox must have at least a title.'); - } - if (childrenLength > 9) { - throw new Error('Invalid content. SelectBox cannot have more than 9 children.'); + // Convert between our API and GridList selection API + const [selectedKeys, setSelectedKeys] = useControlledState( + props.value !== undefined ? convertValueToSelection(props.value, selectionMode) : undefined, + convertValueToSelection(defaultValue, selectionMode), + (selection) => { + const value = convertSelectionToValue(selection, selectionMode); + + onSelectionChange(value); + } + ); + + + + // Handle validation + const validationErrors = useMemo(() => { + const errors: string[] = []; + + const selectionSize = selectedKeys === 'all' ? 1 : selectedKeys.size; + if (isRequired && selectionSize === 0) { + errors.push('Selection is required'); + } + + return errors; + }, [isRequired, selectedKeys]); + + const hasValidationErrors = isInvalid || validationErrors.length > 0; + + // Extract SelectBox children and convert to GridListItems + const childrenArray = React.Children.toArray(children).filter((x) => x); + + // Build disabled keys set for individual disabled items + const disabledKeys = useMemo(() => { + if (isDisabled) { + return 'all'; // Entire group is disabled + } + + const disabled = new Set(); + childrenArray.forEach((child, index) => { + if (React.isValidElement(child)) { + const childElement = child as ReactElement<{value?: string, isDisabled?: boolean}>; + const childValue = childElement.props?.value || String(index); + if (childElement.props?.isDisabled) { + disabled.add(childValue); + } } - } catch (e) { - console.error(e); + }); + + return disabled.size > 0 ? disabled : undefined; + }, [isDisabled, childrenArray]); + + // Validate children count + useEffect(() => { + if (childrenArray.length <= 0) { + console.error('Invalid content. SelectBox must have at least one item.'); + } + if (childrenArray.length > 9) { + console.error('Invalid content. SelectBox cannot have more than 9 children.'); } - return childrenToRender; - }; + }, [childrenArray.length]); - // Context value + // Context value for child SelectBox components const selectBoxContextValue = useMemo( - () => ({ - allowMultiSelect, - size, - orientation, - isEmphasized - }), - [allowMultiSelect, size, orientation, isEmphasized] + () => { + const contextValue = { + allowMultiSelect: selectionMode === 'multiple', + size, + orientation, + isEmphasized, + isDisabled, + selectedKeys, + onSelectionChange: setSelectedKeys + }; + return contextValue; + }, + [selectionMode, size, orientation, isEmphasized, isDisabled, selectedKeys, setSelectedKeys] ); + const currentValue = convertSelectionToValue(selectedKeys, selectionMode); + return ( - -
+ + {/* Form integration */} + + + {/* Label */} + {label && ( + + {label} + {isRequired && *} + + )} + + {/* Grid List with automatic grid navigation */} + {} : setSelectedKeys} + disabledKeys={disabledKeys} + aria-labelledby={label ? `${gridId}-label` : undefined} + aria-describedby={hasValidationErrors && errorMessage ? errorId : undefined} + aria-invalid={hasValidationErrors} + aria-required={isRequired} className={gridStyles({gutterWidth, orientation}, props.styles)} style={{ gridTemplateColumns: `repeat(${numColumns}, 1fr)` }}> - - {getChildrenToRender().map((child) => { - return child as ReactElement; - })} - -
-
+ + {childrenArray.map((child, index) => { + if (!React.isValidElement(child)) return null; + + const childElement = child as ReactElement<{value?: string}>; + const childValue = childElement.props?.value || String(index); + + // Extract text content for accessibility + const getTextValue = (element: ReactElement): string => { + const elementProps = (element as any).props; + const children = React.Children.toArray(elementProps.children) as ReactElement[]; + const textSlot = children.find((child: any) => + React.isValidElement(child) && (child as any).props?.slot === 'text' + ); + + if (React.isValidElement(textSlot)) { + return String((textSlot as any).props.children || ''); + } + + // Fallback to any text content + const textContent = children + .filter((child: any) => typeof child === 'string') + .join(' '); + + return textContent || childValue; + }; + + const textValue = getTextValue(childElement); + + // Convert SelectBox to GridListItem with render props + return ( + + {(renderProps) => ( + + + {child} + + + )} + + ); + })} + + + {/* Error message */} + {hasValidationErrors && errorMessage && ( + + {errorMessage} + + )} +
); }); - -const SelectorGroup = forwardRef(function SelectorGroupComponent({ - allowMultiSelect, - children, - UNSAFE_className, - onChange, - value, - defaultValue, - UNSAFE_style, - isRequired, - isDisabled, - label -}, ref) { - const baseProps = { - isRequired, - isDisabled, - UNSAFE_className, - UNSAFE_style, - children, - onChange, - ref - }; - - return allowMultiSelect ? ( - - ) : ( - - ); -}); \ No newline at end of file diff --git a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx deleted file mode 100644 index c897bb79dc5..00000000000 --- a/packages/@react-spectrum/s2/stories/SelectBox.stories.tsx +++ /dev/null @@ -1,205 +0,0 @@ -/************************************************************************* - * ADOBE CONFIDENTIAL - * ___________________ - * - * Copyright 2025 Adobe - * All Rights Reserved. - * - * NOTICE: All information contained herein is, and remains - * the property of Adobe and its suppliers, if any. The intellectual - * and technical concepts contained herein are proprietary to Adobe - * and its suppliers and are protected by all applicable intellectual - * property laws, including trade secret and copyright laws. - * Dissemination of this information or reproduction of this material - * is strictly forbidden unless prior written permission is obtained - * from Adobe. - **************************************************************************/ - -import {action} from '@storybook/addon-actions'; -import {createIcon, SelectBox, SelectBoxGroup, Text} from '../src'; -import type {Meta, StoryObj} from '@storybook/react'; -import React from 'react'; -import Server from '../spectrum-illustrations/linear/Server'; -import StarSVG from '../s2wf-icons/S2_Icon_Star_20_N.svg'; - -const StarIcon = createIcon(StarSVG); - -const meta: Meta = { - component: SelectBoxGroup, - parameters: { - layout: 'centered' - }, - tags: ['autodocs'], - argTypes: { - onSelectionChange: {table: {category: 'Events'}}, - label: {control: {type: 'text'}}, - description: {control: {type: 'text'}}, - errorMessage: {control: {type: 'text'}}, - children: {table: {disable: true}} - }, - title: 'SelectBox' -}; - -export default meta; -type Story = StoryObj; - -export const Example: Story = { - args: { - label: 'Choose an option', - orientation: 'vertical', - necessityIndicator: 'label', - size: 'M', - labelPosition: 'side' - }, - render: (args) => ( - console.log('Selection changed:', v)}> - - - Select Box Label - Select Box Label - - - - Select Box Label - Select Box Label - - - - Select Box Label - Select Box Label - - - ) -}; - -export const SingleSelectNumColumns: Story = { - args: { - numColumns: 2, - label: 'Favorite city', - size: 'XL', - gutterWidth: 'default' - }, - render: (args) => { - return ( - action('onSelectionChange')(v)}> - - - Paris - France - - - - Rome - Italy - - - - San Francisco - USA - - - ); - }, - name: 'Multiple columns' -}; - -export const MultipleSelection: Story = { - args: { - numColumns: 1, - label: 'Favorite cities', - selectionMode: 'multiple' - }, - render: (args) => { - return ( - action('onSelectionChange')(v)}> - - {/* */} - Paris - France - - - {/* */} - Rome - Italy - - - {/* */} - San Francisco - USA - - - ); - }, - name: 'Multiple selection mode' -}; - -export const HorizontalOrientation: Story = { - args: { - orientation: 'horizontal', - label: 'Favorite cities' - }, - render: (args) => { - return ( - action('onSelectionChange')(v)}> - - Paris - France - - - Rome - Italy - - - San Francisco - USA - - - ); - }, - name: 'Horizontal orientation' -}; - -export const IndividualDisabled: Story = { - args: { - numColumns: 2, - label: 'Choose options (some disabled)', - selectionMode: 'multiple' - }, - render: (args) => { - return ( - action('onSelectionChange')(v)}> - - - Available Option - This option is enabled - - - - Disabled Option - This option is disabled - - - - Another Available - This option is also enabled - - - - Another Disabled - This option is also disabled - - - ); - }, - name: 'Individual disabled SelectBoxes' -}; diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx new file mode 100644 index 00000000000..813247812fe --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -0,0 +1,403 @@ +/************************************************************************* + * ADOBE CONFIDENTIAL + * ___________________ + * + * Copyright 2025 Adobe + * All Rights Reserved. + * + * NOTICE: All information contained herein is, and remains + * the property of Adobe and its suppliers, if any. The intellectual + * and technical concepts contained herein are proprietary to Adobe + * and its suppliers and are protected by all applicable intellectual + * property laws, including trade secret and copyright laws. + * Dissemination of this information or reproduction of this material + * is strictly forbidden unless prior written permission is obtained + * from Adobe. + **************************************************************************/ + +import type { Meta, StoryObj } from "@storybook/react"; +import { SelectBox, SelectBoxGroup, Text, createIcon } from "../src"; +import { action } from "@storybook/addon-actions"; +import React from "react"; +import Server from "../spectrum-illustrations/linear/Server"; +import AlertNotice from "../spectrum-illustrations/linear/AlertNotice"; +import Paperairplane from "../spectrum-illustrations/linear/Paperairplane"; +import StarSVG from "../s2wf-icons/S2_Icon_Star_20_N.svg"; +import StarFilledSVG from "../s2wf-icons/S2_Icon_StarFilled_20_N.svg"; + +const StarIcon = createIcon(StarSVG); +const StarFilledIcon = createIcon(StarFilledSVG); + +const meta: Meta = { + title: "SelectBoxGroup (Collection)", + component: SelectBoxGroup, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + selectionMode: { + control: "select", + options: ["single", "multiple"], + }, + size: { + control: "select", + options: ["XS", "S", "M", "L", "XL"], + }, + orientation: { + control: "select", + options: ["vertical", "horizontal"], + }, + numColumns: { + control: { type: "number", min: 1, max: 4 }, + }, + gutterWidth: { + control: "select", + options: ["compact", "default", "spacious"], + }, + }, + args: { + selectionMode: "single", + size: "M", + orientation: "vertical", + numColumns: 2, + gutterWidth: "default", + isRequired: false, + isDisabled: false, + isEmphasized: false, + }, +}; + +export default meta; +type Story = StoryObj; + +// Basic Stories +export const Default: Story = { + args: { + label: "Choose your cloud service", + }, + render: (args) => ( + + + + Amazon Web Services + Reliable cloud infrastructure + + + + Microsoft Azure + Enterprise cloud solutions + + + + Google Cloud Platform + Modern cloud services + + + + Oracle Cloud + Database-focused cloud + + + ), +}; + +export const MultipleSelection: Story = { + args: { + selectionMode: "multiple", + label: "Select your preferred services", + defaultValue: ["aws", "gcp"], + necessityIndicator: "icon", + }, + render: (args) => ( + + + + Amazon Web Services + + + + Microsoft Azure + + + + Google Cloud Platform + + + + Oracle Cloud + + + ), +}; + +// Grid Navigation Testing +export const GridNavigation: Story = { + args: { + label: "Test Grid Navigation (Use Arrow Keys)", + numColumns: 3, + }, + render: (args) => ( +
+

+ Focus any item and use arrow keys to navigate: +
• ↑↓ moves vertically (same column) +
• ←→ moves horizontally (same row) +

+ + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 6 + + +
+ ), +}; + +// Form Integration +export const FormIntegration: Story = { + args: { + label: "Select your option", + name: "user_preference", + isRequired: true, + }, + render: (args) => ( +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + action("Form submitted")(Object.fromEntries(formData)); + }} + > + + + Option 1 + + + Option 2 + + + Option 3 + + + +
+ ), +}; + +export const FormValidation: Story = { + args: { + label: "Required Selection", + isRequired: true, + errorMessage: "Please select at least one option", + isInvalid: true, + }, + render: (args) => ( + + + Option 1 + + + Option 2 + + + ), +}; + +// Size Variations +export const SizeVariations: Story = { + render: () => ( +
+ {(["XS", "S", "M", "L", "XL"] as const).map((size) => ( + + + + Option 1 + + + + Option 2 + + + ))} +
+ ), +}; + +// Horizontal Orientation +export const HorizontalOrientation: Story = { + args: { + orientation: "horizontal", + label: "Favorite cities", + numColumns: 1, + }, + render: (args) => ( + + + Paris + France + + + Rome + Italy + + + Tokyo + Japan + + + ), +}; + +// Disabled States +export const DisabledGroup: Story = { + args: { + label: "Disabled Group", + isDisabled: true, + defaultValue: "option1", + }, + render: (args) => ( + + + Option 1 + + + Option 2 + + + ), +}; + +export const IndividualDisabled: Story = { + args: { + label: "Some items disabled", + defaultValue: "option2", + }, + render: (args) => ( + + + Option 1 (Disabled) + + + Option 2 + + + Option 3 (Disabled) + + + Option 4 + + + ), +}; + +// Controlled Mode +export const Controlled: Story = { + render: () => { + const [value, setValue] = React.useState("option2"); + + return ( +
+

Current value: {value}

+ setValue(val as string)} + > + + Option 1 + + + Option 2 + + + Option 3 + + + + +
+ ); + }, +}; + +// Dynamic Icons +export const DynamicIcons: Story = { + args: { + label: "Rate these items", + }, + render: (args) => { + const [selectedValues, setSelectedValues] = React.useState>( + new Set(), + ); + + return ( + { + const values = Array.isArray(val) ? val : [val]; + setSelectedValues(new Set(values)); + action("onSelectionChange")(val); + }} + > + {["item1", "item2", "item3", "item4"].map((value) => ( + + {selectedValues.has(value) ? ( + + ) : ( + + )} + Item {value.slice(-1)} + + ))} + + ); + }, +}; + +// Multiple Columns +export const MultipleColumns: Story = { + args: { + label: "Choose options", + numColumns: 4, + gutterWidth: "spacious", + }, + render: (args) => ( +
+ + {Array.from({ length: 8 }, (_, i) => ( + + Option {i + 1} + + ))} + +
+ ), +}; diff --git a/packages/@react-spectrum/s2/test/SelectBox.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx similarity index 95% rename from packages/@react-spectrum/s2/test/SelectBox.test.tsx rename to packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index a9591a0fae4..2fc4319723b 100644 --- a/packages/@react-spectrum/s2/test/SelectBox.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -230,25 +230,25 @@ describe('SelectBox', () => { expect(option2).toBeChecked(); }); - it('controlled value works as expected', () => { - render( - {}} - value="option2" - label="Controlled test"> - Option 1 - Option 2 - - ); - - const radios = screen.getAllByRole('radio'); - const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; - const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; - - expect(option1).not.toBeChecked(); - expect(option2).toBeChecked(); - }); + it('controlled value works as expected', () => { + render( + {}} + value="option2" + label="Controlled test"> + Option 1 + Option 2 + + ); + + const radios = screen.getAllByRole('radio'); + const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; + const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + + expect(option1).not.toBeChecked(); + expect(option2).toBeChecked(); + }); it('calls onSelectionChange when controlled value changes', async () => { const onSelectionChange = jest.fn(); @@ -326,8 +326,7 @@ describe('SelectBox', () => { selectionMode="single" onSelectionChange={() => {}} value="option2" - label="Individual disabled test" - > + label="Individual disabled test"> Option 1 Option 2
From 23590e24cc66e35cea0dea524b0c24882b31ce89 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Thu, 17 Jul 2025 14:31:29 -0700 Subject: [PATCH 7/9] fixed borders and redid stories/tests --- packages/@react-spectrum/s2/src/SelectBox.tsx | 55 +- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 47 +- .../s2/stories/SelectBoxGroup.stories.tsx | 298 ++++---- .../s2/test/SelectBoxGroup.test.tsx | 709 ++++++++++++------ 4 files changed, 654 insertions(+), 455 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index c70854584bb..9216333f689 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -10,11 +10,11 @@ */ import {Checkbox} from './Checkbox'; -import {FocusableRef, FocusableRefValue} from '@react-types/shared'; -import {focusRing, style, lightDark} from '../style' with {type: 'macro'}; +import {FocusableRef} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import React, {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; import {SelectBoxContext} from './SelectBoxGroup'; +import {style} from '../style' with {type: 'macro'}; import {useFocusableRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -35,7 +35,6 @@ export interface SelectBoxProps extends StyleProps { export const SelectBoxItemContext = createContext(null); -// Simple basic styling with proper dark mode support const selectBoxStyles = style({ display: 'flex', flexDirection: { @@ -99,8 +98,8 @@ const selectBoxStyles = style({ borderRadius: 'lg', backgroundColor: { default: 'layer-2', - isDisabled: 'layer-1', - isSelected: 'layer-2' + isSelected: 'layer-2', + isDisabled: 'layer-1' }, color: { isEmphasized: 'gray-900', @@ -110,18 +109,18 @@ const selectBoxStyles = style({ default: 'emphasized', isHovered: 'elevated', isSelected: 'elevated', - forcedColors: 'none' + forcedColors: 'none', + isDisabled: 'emphasized' }, outlineStyle: 'none', position: 'relative', borderWidth: 2, borderStyle: 'solid', borderColor: { - // default: 'transparent', - // isSelected: lightDark('accent-900', 'accent-700') default: 'transparent', isSelected: 'gray-900', - isFocusVisible: 'blue-900' + isFocusVisible: 'blue-900', + isDisabled: 'transparent' }, transition: 'default', gap: { @@ -132,7 +131,7 @@ const selectBoxStyles = style({ cursor: { default: 'pointer', isDisabled: 'default' - }, + } }, getAllowedOverrides()); const contentContainer = style({ @@ -172,6 +171,9 @@ const iconContainer = style({ flexShrink: 0, color: { isDisabled: 'disabled' + }, + opacity: { + isDisabled: 0.4 } }, getAllowedOverrides()); @@ -211,11 +213,10 @@ const checkboxContainer = style({ left: 16 }, getAllowedOverrides()); -// Context for passing GridListItem render props down to SelectBox const SelectBoxRenderPropsContext = createContext<{ - isHovered?: boolean; - isFocusVisible?: boolean; - isPressed?: boolean; + isHovered?: boolean, + isFocusVisible?: boolean, + isPressed?: boolean }>({}); /** @@ -224,29 +225,23 @@ const SelectBoxRenderPropsContext = createContext<{ */ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxItemContext); - let {children, value, isDisabled: individualDisabled = false, UNSAFE_className = '', UNSAFE_style} = props; + let {children, value, isDisabled: individualDisabled = false, UNSAFE_style} = props; let divRef = useRef(null); let domRef = useFocusableRef(ref, divRef); let contextValue = useContext(SelectBoxContext); let { - size = 'M', // Match SelectBoxGroup default + size = 'M', orientation = 'vertical', selectedKeys, isDisabled: groupDisabled = false } = contextValue; - // Access GridListItem render props from context let renderProps = useContext(SelectBoxRenderPropsContext); - // Merge individual and group disabled states const isDisabled = individualDisabled || groupDisabled; - - // Determine if this item is selected based on the parent's selectedKeys const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); - - // Show checkbox when selected, disabled, or hovered - const showCheckbox = isSelected || isDisabled || renderProps.isHovered; + const showCheckbox = isSelected || (!isDisabled && renderProps.isHovered); return (
- {/* Show selection indicator */} {showCheckbox && (
@@ -269,29 +263,22 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele isSelected={isSelected} isDisabled={isDisabled} size={size === 'XS' ? 'S' : size} - isReadOnly - /> + isReadOnly />
)} - - {/* Content layout */} {orientation === 'horizontal' ? ( <> - {/* Icon */} {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && (
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')}
)} - {/* Content container for horizontal layout */}
- {/* Text */} {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')} - {/* Description */} {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description') && (
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description')} @@ -302,21 +289,18 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele ) : ( <> - {/* Icon */} {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && (
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')}
)} - {/* Text container for vertical layout */}
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')}
)} - {/* Other children */} {React.Children.toArray(children).filter((child: any) => !['icon', 'text', 'description'].includes(child?.props?.slot) )} @@ -324,5 +308,4 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele ); }); -// Export the context for use in SelectBoxGroup export {SelectBoxRenderPropsContext}; diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index f3dea51753c..2f6f6fe1226 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -10,19 +10,19 @@ */ import { + ContextValue, GridList, GridListItem, - ContextValue, Text } from 'react-aria-components'; -import {DOMRef, DOMRefValue, HelpTextProps, Orientation, SpectrumLabelableProps, Key, Selection} from '@react-types/shared'; +import {DOMRef, DOMRefValue, HelpTextProps, Orientation, Selection, SpectrumLabelableProps} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import React, {createContext, forwardRef, ReactElement, ReactNode, useMemo, useId, useEffect, useRef} from 'react'; +import React, {createContext, forwardRef, ReactElement, ReactNode, useEffect, useId, useMemo} from 'react'; +import {SelectBoxRenderPropsContext} from './SelectBox'; import {style} from '../style' with {type: 'macro'}; +import {useControlledState} from '@react-stately/utils'; import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -import {useControlledState} from '@react-stately/utils'; -import {SelectBoxRenderPropsContext} from './SelectBox'; export type SelectBoxValueType = string | string[]; @@ -117,7 +117,6 @@ const convertValueToSelection = (value: SelectBoxValueType | undefined, selectio }; const convertSelectionToValue = (selection: Selection, selectionMode: 'single' | 'multiple'): SelectBoxValueType => { - // Handle 'all' selection if (selection === 'all') { return selectionMode === 'multiple' ? [] : ''; } @@ -148,7 +147,6 @@ const gridStyles = style({ spacious: 24 } }, - // Override default GridList styles to work with our grid layout '&[role="grid"]': { display: 'grid' } @@ -172,9 +170,10 @@ interface FormIntegrationProps { isInvalid?: boolean } -// Hidden form inputs for form integration function FormIntegration({name, value, isRequired, isInvalid}: FormIntegrationProps) { - if (!name) return null; + if (!name) { + return null; + } if (Array.isArray(value)) { return ( @@ -186,8 +185,7 @@ function FormIntegration({name, value, isRequired, isInvalid}: FormIntegrationPr name={name} value={val} required={isRequired && index === 0} - aria-invalid={isInvalid} - /> + aria-invalid={isInvalid} /> ))} {value.length === 0 && isRequired && ( + aria-invalid={isInvalid} /> )} ); @@ -208,8 +205,7 @@ function FormIntegration({name, value, isRequired, isInvalid}: FormIntegrationPr name={name} value={value || ''} required={isRequired} - aria-invalid={isInvalid} - /> + aria-invalid={isInvalid} /> ); } @@ -244,7 +240,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const gridId = useId(); const errorId = useId(); - // Convert between our API and GridList selection API const [selectedKeys, setSelectedKeys] = useControlledState( props.value !== undefined ? convertValueToSelection(props.value, selectionMode) : undefined, convertValueToSelection(defaultValue, selectionMode), @@ -256,8 +251,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p ); - - // Handle validation const validationErrors = useMemo(() => { const errors: string[] = []; @@ -271,13 +264,11 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const hasValidationErrors = isInvalid || validationErrors.length > 0; - // Extract SelectBox children and convert to GridListItems const childrenArray = React.Children.toArray(children).filter((x) => x); - // Build disabled keys set for individual disabled items const disabledKeys = useMemo(() => { if (isDisabled) { - return 'all'; // Entire group is disabled + return 'all'; } const disabled = new Set(); @@ -294,7 +285,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p return disabled.size > 0 ? disabled : undefined; }, [isDisabled, childrenArray]); - // Validate children count useEffect(() => { if (childrenArray.length <= 0) { console.error('Invalid content. SelectBox must have at least one item.'); @@ -304,7 +294,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p } }, [childrenArray.length]); - // Context value for child SelectBox components const selectBoxContextValue = useMemo( () => { const contextValue = { @@ -329,15 +318,12 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p style={UNSAFE_style} ref={domRef}> - {/* Form integration */} + isInvalid={hasValidationErrors} /> - {/* Label */} {label && ( {label} @@ -345,7 +331,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p )} - {/* Grid List with automatic grid navigation */} {childrenArray.map((child, index) => { - if (!React.isValidElement(child)) return null; + if (!React.isValidElement(child)) {return null;} const childElement = child as ReactElement<{value?: string}>; const childValue = childElement.props?.value || String(index); - // Extract text content for accessibility const getTextValue = (element: ReactElement): string => { const elementProps = (element as any).props; const children = React.Children.toArray(elementProps.children) as ReactElement[]; @@ -379,7 +363,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p return String((textSlot as any).props.children || ''); } - // Fallback to any text content const textContent = children .filter((child: any) => typeof child === 'string') .join(' '); @@ -389,7 +372,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const textValue = getTextValue(childElement); - // Convert SelectBox to GridListItem with render props return ( {(renderProps) => ( @@ -409,7 +391,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p })} - {/* Error message */} {hasValidationErrors && errorMessage && ( = { - title: "SelectBoxGroup (Collection)", + title: 'SelectBoxGroup (Collection)', component: SelectBoxGroup, parameters: { - layout: "centered", + layout: 'centered' }, - tags: ["autodocs"], + tags: ['autodocs'], argTypes: { selectionMode: { - control: "select", - options: ["single", "multiple"], + control: 'select', + options: ['single', 'multiple'] }, size: { - control: "select", - options: ["XS", "S", "M", "L", "XL"], + control: 'select', + options: ['XS', 'S', 'M', 'L', 'XL'] }, orientation: { - control: "select", - options: ["vertical", "horizontal"], + control: 'select', + options: ['vertical', 'horizontal'] }, numColumns: { - control: { type: "number", min: 1, max: 4 }, + control: {type: 'number', min: 1, max: 4} }, gutterWidth: { - control: "select", - options: ["compact", "default", "spacious"], - }, + control: 'select', + options: ['compact', 'default', 'spacious'] + } }, args: { - selectionMode: "single", - size: "M", - orientation: "vertical", + selectionMode: 'single', + size: 'M', + orientation: 'vertical', numColumns: 2, - gutterWidth: "default", + gutterWidth: 'default', isRequired: false, isDisabled: false, - isEmphasized: false, - }, + isEmphasized: false + } }; export default meta; @@ -74,10 +74,10 @@ type Story = StoryObj; // Basic Stories export const Default: Story = { args: { - label: "Choose your cloud service", + label: 'Choose your cloud service' }, render: (args) => ( - + Amazon Web Services @@ -99,18 +99,18 @@ export const Default: Story = { Database-focused cloud - ), + ) }; export const MultipleSelection: Story = { args: { - selectionMode: "multiple", - label: "Select your preferred services", - defaultValue: ["aws", "gcp"], - necessityIndicator: "icon", + selectionMode: 'multiple', + label: 'Select your preferred services', + defaultValue: ['aws', 'gcp'], + necessityIndicator: 'icon' }, render: (args) => ( - + Amazon Web Services @@ -128,23 +128,23 @@ export const MultipleSelection: Story = { Oracle Cloud - ), + ) }; // Grid Navigation Testing export const GridNavigation: Story = { args: { - label: "Test Grid Navigation (Use Arrow Keys)", - numColumns: 3, + label: 'Test Grid Navigation (Use Arrow Keys)', + numColumns: 3 }, render: (args) => ( -
-

- Focus any item and use arrow keys to navigate: -
• ↑↓ moves vertically (same column) -
• ←→ moves horizontally (same row) +

+

+ Focus any item (best done by clicking to the left of the group and hitting the tab key) and use arrow keys to navigate: + {/*
• ↑↓ moves vertically (same column) +
• ←→ moves horizontally (same row) */}

- + Item 1 @@ -165,25 +165,24 @@ export const GridNavigation: Story = {
- ), + ) }; // Form Integration export const FormIntegration: Story = { args: { - label: "Select your option", - name: "user_preference", - isRequired: true, + label: 'Select your option', + name: 'user_preference', + isRequired: true }, render: (args) => (
{ e.preventDefault(); const formData = new FormData(e.currentTarget); - action("Form submitted")(Object.fromEntries(formData)); - }} - > - + action('Form submitted')(Object.fromEntries(formData)); + }}> + Option 1 @@ -194,22 +193,22 @@ export const FormIntegration: Story = { Option 3 - - ), + ) }; export const FormValidation: Story = { args: { - label: "Required Selection", + label: 'Required Selection', isRequired: true, - errorMessage: "Please select at least one option", - isInvalid: true, + errorMessage: 'Please select at least one option', + isInvalid: true }, render: (args) => ( - + Option 1 @@ -217,20 +216,19 @@ export const FormValidation: Story = { Option 2 - ), + ) }; // Size Variations export const SizeVariations: Story = { render: () => ( -
- {(["XS", "S", "M", "L", "XL"] as const).map((size) => ( +
+ {(['XS', 'S', 'M', 'L', 'XL'] as const).map((size) => ( + onSelectionChange={action(`onSelectionChange-${size}`)}> Option 1 @@ -242,18 +240,18 @@ export const SizeVariations: Story = { ))}
- ), + ) }; // Horizontal Orientation export const HorizontalOrientation: Story = { args: { - orientation: "horizontal", - label: "Favorite cities", - numColumns: 1, + orientation: 'horizontal', + label: 'Favorite cities', + numColumns: 1 }, render: (args) => ( - + Paris France @@ -267,35 +265,37 @@ export const HorizontalOrientation: Story = { Japan - ), + ) }; // Disabled States export const DisabledGroup: Story = { args: { - label: "Disabled Group", + label: 'Disabled Group', isDisabled: true, - defaultValue: "option1", + defaultValue: 'option1' }, render: (args) => ( - + - Option 1 + + Selected then Disabled - Option 2 + + Disabled - ), + ) }; export const IndividualDisabled: Story = { args: { - label: "Some items disabled", - defaultValue: "option2", + label: 'Some items disabled', + defaultValue: 'option2' }, render: (args) => ( - + Option 1 (Disabled) @@ -309,95 +309,93 @@ export const IndividualDisabled: Story = { Option 4 - ), + ) }; -// Controlled Mode -export const Controlled: Story = { - render: () => { - const [value, setValue] = React.useState("option2"); +// Controlled Mode - Convert to proper component to use React hooks +function ControlledStory() { + const [value, setValue] = React.useState('option2'); - return ( -
-

Current value: {value}

- setValue(val as string)} - > - - Option 1 - - - Option 2 - - - Option 3 - - - - -
- ); - }, + return ( +
+

Current value: {value}

+ setValue(val as string)}> + + Option 1 + + + Option 2 + + + Option 3 + + + + +
+ ); +} + +export const Controlled: Story = { + render: () => }; -// Dynamic Icons -export const DynamicIcons: Story = { - args: { - label: "Rate these items", - }, - render: (args) => { - const [selectedValues, setSelectedValues] = React.useState>( - new Set(), - ); +// Dynamic Icons - Convert to proper component to use React hooks +function DynamicIconsStory() { + const [selectedValues, setSelectedValues] = React.useState>( + new Set() + ); - return ( - { - const values = Array.isArray(val) ? val : [val]; - setSelectedValues(new Set(values)); - action("onSelectionChange")(val); - }} - > - {["item1", "item2", "item3", "item4"].map((value) => ( - - {selectedValues.has(value) ? ( - - ) : ( - - )} - Item {value.slice(-1)} - - ))} - - ); - }, + return ( + { + const values = Array.isArray(val) ? val : [val]; + setSelectedValues(new Set(values)); + action('onSelectionChange')(val); + }}> + {['item1', 'item2', 'item3', 'item4'].map((value) => ( + + {selectedValues.has(value) ? ( + + ) : ( + + )} + Item {value.slice(-1)} + + ))} + + ); +} + +export const DynamicIcons: Story = { + render: () => }; // Multiple Columns export const MultipleColumns: Story = { args: { - label: "Choose options", + label: 'Choose options', numColumns: 4, - gutterWidth: "spacious", + gutterWidth: 'spacious' }, render: (args) => ( -
- - {Array.from({ length: 8 }, (_, i) => ( +
+ + {Array.from({length: 8}, (_, i) => ( Option {i + 1} ))}
- ), + ) }; diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 2fc4319723b..e00e0f74652 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import {render, screen, waitFor} from '@testing-library/react'; +import {render, screen, waitFor, act} from '@testing-library/react'; import {SelectBox} from '../src/SelectBox'; import {SelectBoxGroup} from '../src/SelectBoxGroup'; +import {Text} from '../src'; import userEvent from '@testing-library/user-event'; function SingleSelectBox() { @@ -12,9 +13,15 @@ function SingleSelectBox() { onSelectionChange={(val) => setValue(val as string)} value={value} label="Single select test"> - Option 1 - Option 2 - Option 3 + + Option 1 + + + Option 2 + + + Option 3 +
); } @@ -27,9 +34,15 @@ function MultiSelectBox() { onSelectionChange={(val) => setValue(val as string[])} value={value} label="Multi select test"> - Option 1 - Option 2 - Option 3 + + Option 1 + + + Option 2 + + + Option 3 + ); } @@ -42,109 +55,254 @@ function DisabledSelectBox() { value="" isDisabled label="Disabled select test"> - Option 1 - Option 2 + + Option 1 + + + Option 2 + ); } -describe('SelectBox', () => { +describe('SelectBoxGroup', () => { describe('Basic functionality', () => { - it('renders single select mode', () => { + it('renders as a grid with rows', () => { render(); - expect(screen.getAllByRole('radio')).toHaveLength(3); + expect(screen.getByRole('grid', {name: 'Single select test'})).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(3); expect(screen.getByText('Option 1')).toBeInTheDocument(); }); - it('renders multiple select mode', () => { + it('renders multiple selection mode', () => { render(); - expect(screen.getAllByRole('checkbox')).toHaveLength(3); + expect(screen.getByRole('grid', {name: 'Multi select test'})).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(3); expect(screen.getByText('Option 1')).toBeInTheDocument(); }); + it('handles selection in single mode', async () => { + render(); + const rows = screen.getAllByRole('row'); + const option1 = rows.find(row => row.textContent?.includes('Option 1'))!; + + await userEvent.click(option1); + expect(option1).toHaveAttribute('aria-selected', 'true'); + }); + it('handles multiple selection', async () => { render(); - const checkboxes = screen.getAllByRole('checkbox'); - const option1 = checkboxes.find(cb => cb.getAttribute('value') === 'option1')!; - const option2 = checkboxes.find(cb => cb.getAttribute('value') === 'option2')!; + const rows = screen.getAllByRole('row'); + const option1 = rows.find(row => row.textContent?.includes('Option 1'))!; + const option2 = rows.find(row => row.textContent?.includes('Option 2'))!; await userEvent.click(option1); await userEvent.click(option2); - expect(option1).toBeChecked(); - expect(option2).toBeChecked(); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); }); it('handles disabled state', () => { render(); - const inputs = screen.getAllByRole('radio'); - inputs.forEach(input => { - expect(input).toBeDisabled(); - }); + const grid = screen.getByRole('grid'); + expect(grid).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBeGreaterThan(0); }); }); - describe('Props and configuration', () => { - it('supports different sizes', () => { + describe('Visual checkbox indicators', () => { + it('shows checkbox when item is selected', async () => { render( - {}} value="" size="L" label="Size test"> - Option 1 + {}} + value="option1" + label="Checkbox test"> + + Option 1 + + + Option 2 + ); - expect(screen.getByRole('radio')).toBeInTheDocument(); + + const selectedRow = screen.getByRole('row', {name: 'Option 1'}); + expect(selectedRow).toHaveAttribute('aria-selected', 'true'); + + const checkbox = selectedRow.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); }); - it('supports horizontal orientation', () => { + it('shows checkbox on hover for non-disabled items', async () => { render( - {}} value="" orientation="horizontal" label="Orientation test"> - Option 1 + {}} + value="" + label="Hover test"> + + Option 1 + ); - expect(screen.getByRole('radio')).toBeInTheDocument(); + + const row = screen.getByRole('row', {name: 'Option 1'}); + + await userEvent.hover(row); + await waitFor(() => { + const checkbox = row.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeInTheDocument(); + }); }); - it('supports custom number of columns', () => { + it('does not show checkbox on hover for disabled items', async () => { render( - {}} value="" numColumns={3} label="Columns test"> - Option 1 + {}} + value="" + label="Disabled hover test"> + + Option 1 + ); - expect(screen.getByRole('radio')).toBeInTheDocument(); + + const row = screen.getByRole('row', {name: 'Option 1'}); + + await userEvent.hover(row); + + await waitFor(() => { + const checkbox = row.querySelector('input[type="checkbox"]'); + expect(checkbox).not.toBeInTheDocument(); + }, {timeout: 1000}); }); - it('supports labels with aria-label', () => { + it('shows checkbox for disabled but selected items', () => { render( - {}} value="" label="Choose an option"> - Option 1 + {}} + defaultValue="option1" + label="Disabled selected test"> + + Option 1 + ); + + const row = screen.getByRole('row', {name: 'Option 1'}); - expect(screen.getByLabelText('Choose an option')).toBeInTheDocument(); + const checkbox = row.querySelector('input[type="checkbox"]'); + expect(checkbox).toBeInTheDocument(); + expect(checkbox).toBeChecked(); + }); + }); + + describe('Props and configuration', () => { + it('supports different sizes', () => { + render( + {}} + value="" + size="L" + label="Size test"> + + Option 1 + + + ); + expect(screen.getByRole('grid', {name: 'Size test'})).toBeInTheDocument(); + }); + + it('supports horizontal orientation', () => { + render( + {}} + value="" + orientation="horizontal" + label="Orientation test"> + + Option 1 + + + ); + expect(screen.getByRole('grid', {name: 'Orientation test'})).toBeInTheDocument(); }); it('supports required state', () => { render( - {}} value="" isRequired label="Required test"> - Option 1 + {}} + value="" + isRequired + label="Required test"> + + Option 1 + + + ); + const grid = screen.getByRole('grid', {name: 'Required test required'}); + expect(grid).toBeInTheDocument(); + + expect(screen.getByText('Required test')).toBeInTheDocument(); + expect(screen.getByText('*')).toBeInTheDocument(); + }); + + it('supports error message and validation', () => { + render( + {}} + value="" + isInvalid + errorMessage="Please select an option" + label="Validation test"> + + Option 1 + ); - const group = screen.getByRole('radiogroup'); - expect(group).toBeRequired(); + const grid = screen.getByRole('grid', {name: 'Validation test'}); + expect(grid).toBeInTheDocument(); + + expect(screen.getByText('Please select an option')).toBeInTheDocument(); + + const errorMessage = screen.getByText('Please select an option'); + expect(grid.getAttribute('aria-describedby')).toBe(errorMessage.id); }); }); describe('Controlled behavior', () => { it('handles initial value selection', () => { render( - {}} value="option1" label="Initial value test"> - Option 1 - Option 2 + {}} + value="option1" + label="Initial value test"> + + Option 1 + + + Option 2 + ); - const radios = screen.getAllByRole('radio'); - const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; - expect(option1).toBeChecked(); + const option1 = screen.getByRole('row', {name: 'Option 1'}); + const option2 = screen.getByRole('row', {name: 'Option 2'}); + + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'false'); }); it('handles multiple selection with initial values', () => { @@ -154,190 +312,221 @@ describe('SelectBox', () => { onSelectionChange={() => {}} value={['option1', 'option2']} label="Multiple initial test"> - Option 1 - Option 2 - Option 3 + + Option 1 + + + Option 2 + + + Option 3 + ); - const checkboxes = screen.getAllByRole('checkbox'); - const option1 = checkboxes.find(cb => cb.getAttribute('value') === 'option1')!; - const option2 = checkboxes.find(cb => cb.getAttribute('value') === 'option2')!; - const option3 = checkboxes.find(cb => cb.getAttribute('value') === 'option3')!; + const option1 = screen.getByRole('row', {name: 'Option 1'}); + const option2 = screen.getByRole('row', {name: 'Option 2'}); + const option3 = screen.getByRole('row', {name: 'Option 3'}); - expect(option1).toBeChecked(); - expect(option2).toBeChecked(); - expect(option3).not.toBeChecked(); + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option3).toHaveAttribute('aria-selected', 'false'); }); - }); - describe('Controlled values', () => { - it('handles controlled single selection', async () => { - const ControlledSingleSelect = () => { - const [value, setValue] = React.useState('option1'); - return ( - setValue(val as string)} - value={value} - label="Controlled single select"> - Option 1 - Option 2 - Option 3 - - ); - }; - - render(); - const radios = screen.getAllByRole('radio'); - const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; - const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; - - expect(option1).toBeChecked(); - expect(option2).not.toBeChecked(); - - await userEvent.click(option2); - expect(option2).toBeChecked(); - expect(option1).not.toBeChecked(); - }); - - it('handles controlled multiple selection', async () => { - const ControlledMultiSelect = () => { - const [value, setValue] = React.useState(['option1']); - return ( - setValue(val as string[])} - value={value} - label="Controlled multi select"> - Option 1 - Option 2 - Option 3 - - ); - }; - - render(); - const checkboxes = screen.getAllByRole('checkbox'); - const option1 = checkboxes.find(cb => cb.getAttribute('value') === 'option1')!; - const option2 = checkboxes.find(cb => cb.getAttribute('value') === 'option2')!; - - expect(option1).toBeChecked(); - expect(option2).not.toBeChecked(); - - await userEvent.click(option2); - expect(option1).toBeChecked(); - expect(option2).toBeChecked(); - }); - - it('controlled value works as expected', () => { + it('calls onSelectionChange when selection changes', async () => { + const onSelectionChange = jest.fn(); render( {}} - value="option2" - label="Controlled test"> - Option 1 - Option 2 + onSelectionChange={onSelectionChange} + value="" + label="Callback test"> + + Option 1 + + + Option 2 + ); - const radios = screen.getAllByRole('radio'); - const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; - const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + const option1 = screen.getByRole('row', {name: 'Option 1'}); + await userEvent.click(option1); - expect(option1).not.toBeChecked(); - expect(option2).toBeChecked(); + expect(onSelectionChange).toHaveBeenCalledWith('option1'); }); - it('calls onSelectionChange when controlled value changes', async () => { + it('calls onSelectionChange with array for multiple selection', async () => { const onSelectionChange = jest.fn(); - const ControlledWithCallback = () => { - const [value, setValue] = React.useState('option1'); - return ( - { - setValue(val as string); - onSelectionChange(val); - }} - value={value} - label="Controlled callback test"> - Option 1 - Option 2 - - ); - }; - - render(); - const radios = screen.getAllByRole('radio'); - const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + render( + + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('row', {name: 'Option 1'}); + await userEvent.click(option1); - await userEvent.click(option2); - expect(onSelectionChange).toHaveBeenCalledWith('option2'); + expect(onSelectionChange).toHaveBeenCalledWith(['option1']); + }); + }); + + describe('Form integration', () => { + it('creates hidden inputs for form submission', () => { + const {container} = render( + {}} + value={['option1', 'option2']} + name="test-field" + label="Form test"> + + Option 1 + + + Option 2 + + + ); + + const hiddenInputs = container.querySelectorAll('input[type="hidden"][name="test-field"]'); + expect(hiddenInputs).toHaveLength(2); + expect(hiddenInputs[0]).toHaveValue('option1'); + expect(hiddenInputs[1]).toHaveValue('option2'); }); - it('handles external controlled value changes', () => { - const ControlledExternal = ({externalValue}: {externalValue: string}) => ( + it('creates single hidden input for single selection', () => { + const {container} = render( {}} - value={externalValue} - label="External controlled test"> - Option 1 - Option 2 + value="option1" + name="test-field" + label="Single form test"> + + Option 1 + ); - const {rerender} = render(); - const radios = screen.getAllByRole('radio'); - const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; - const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; - - expect(option1).toBeChecked(); - expect(option2).not.toBeChecked(); - - rerender(); - expect(option1).not.toBeChecked(); - expect(option2).toBeChecked(); + const hiddenInput = container.querySelector('input[type="hidden"][name="test-field"]'); + expect(hiddenInput).toBeInTheDocument(); + expect(hiddenInput).toHaveValue('option1'); }); }); describe('Individual SelectBox behavior', () => { - it('shows checkbox indicator when hovered', async () => { + it('handles disabled individual items', () => { render( - {}} value="" label="Hover test"> - Option 1 + {}} + value="" + label="Individual disabled test"> + + Option 1 + + + Option 2 + ); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBe(2); + }); + + it('prevents interaction with disabled items', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + ); + + const option1 = screen.getByRole('row', {name: 'Option 1'}); + await userEvent.click(option1); - const label = screen.getByText('Option 1').closest('label')!; - await userEvent.hover(label); - - await waitFor(() => { - const checkboxes = screen.getAllByRole('checkbox'); - expect(checkboxes.length).toBeGreaterThan(0); - }); + expect(onSelectionChange).not.toHaveBeenCalled(); }); + }); - it('handles disabled individual items', () => { + describe('Grid navigation', () => { + it('supports keyboard navigation', async () => { render( {}} - value="option2" - label="Individual disabled test"> - Option 1 - Option 2 + value="" + numColumns={2} + label="Navigation test"> + + Option 1 + + + Option 2 + + + Option 3 + + + Option 4 + + + ); + + const grid = screen.getByRole('grid'); + const rows = screen.getAllByRole('row'); + + expect(grid).toBeInTheDocument(); + expect(rows).toHaveLength(4); + + expect(grid).toHaveStyle('grid-template-columns: repeat(2, 1fr)'); + + expect(screen.getByRole('row', {name: 'Option 1'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Option 2'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Option 3'})).toBeInTheDocument(); + expect(screen.getByRole('row', {name: 'Option 4'})).toBeInTheDocument(); + }); + + it('supports space key selection', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + ); - const radios = screen.getAllByRole('radio'); - const option1 = radios.find(radio => radio.getAttribute('value') === 'option1')!; - const option2 = radios.find(radio => radio.getAttribute('value') === 'option2')!; + const grid = screen.getByRole('grid'); + await act(async () => { + grid.focus(); + }); - expect(option1).toBeDisabled(); - expect(option2).not.toBeDisabled(); + await act(async () => { + await userEvent.keyboard(' '); + }); + expect(onSelectionChange).toHaveBeenCalledWith('option1'); }); }); @@ -358,15 +547,15 @@ describe('SelectBox', () => { ); expect(console.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('at least a title') - }) + 'Invalid content. SelectBox must have at least one item.' ); }); it('validates maximum children', () => { const manyChildren = Array.from({length: 10}, (_, i) => ( - Option {i} + + Option {i} + )); render( @@ -376,93 +565,141 @@ describe('SelectBox', () => { ); expect(console.error).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining('more than 9 children') - }) + 'Invalid content. SelectBox cannot have more than 9 children.' ); }); }); describe('Accessibility', () => { - it('has proper ARIA roles', () => { + it('has proper grid structure', () => { render( - {}} value="" label="ARIA test"> - Option 1 - Option 2 - Option 3 + {}} + value="" + label="ARIA test"> + + Option 1 + + + Option 2 + ); - expect(screen.getByRole('radiogroup')).toBeInTheDocument(); - expect(screen.getAllByRole('radio')).toHaveLength(3); + + expect(screen.getByRole('grid', {name: 'ARIA test'})).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(2); + expect(screen.getAllByRole('gridcell')).toHaveLength(2); }); - it('has proper ARIA roles for multiple selection', () => { + it('associates labels correctly', () => { render( - {}} value={[]} label="ARIA multi test"> - Option 1 - Option 2 - Option 3 + {}} + value="" + label="Choose option"> + + Option 1 + ); - expect(screen.getByRole('group')).toBeInTheDocument(); - expect(screen.getAllByRole('checkbox')).toHaveLength(3); + + const grid = screen.getByRole('grid', {name: 'Choose option'}); + expect(grid).toBeInTheDocument(); }); - it('associates labels correctly', () => { + it('supports aria-describedby for error messages', () => { render( - {}} value="" label="Choose option"> - Option 1 + {}} + value="" + isInvalid + errorMessage="Error occurred" + label="Error test"> + + Option 1 + ); - expect(screen.getByLabelText('Choose option')).toBeInTheDocument(); + const grid = screen.getByRole('grid'); + const errorMessage = screen.getByText('Error occurred'); + + expect(grid).toHaveAttribute('aria-describedby'); + expect(errorMessage).toBeInTheDocument(); }); }); describe('Edge cases', () => { - it('handles empty value', () => { + it('handles complex children with slots', () => { render( - {}} value="" label="Empty value test"> - Empty + {}} + value="" + orientation="horizontal" + label="Complex children test"> + +
Icon
+ Complex Option + With description +
); - const radio = screen.getByRole('radio'); - expect(radio).toHaveAttribute('value', ''); + expect(screen.getByText('Complex Option')).toBeInTheDocument(); + expect(screen.getByText('With description')).toBeInTheDocument(); }); - it('handles complex children', () => { + it('handles empty string values', () => { render( - {}} value="" label="Complex children test"> - -
-

Title

-

Description

-
+ {}} + value="" + label="Empty value test"> + + Empty Value ); - expect(screen.getByText('Title')).toBeInTheDocument(); - expect(screen.getByText('Description')).toBeInTheDocument(); + const row = screen.getByRole('row', {name: 'Empty Value'}); + expect(row).toBeInTheDocument(); }); it('handles different gutter widths', () => { render( - {}} value="" gutterWidth="compact" label="Gutter test"> - Option 1 + {}} + value="" + gutterWidth="compact" + label="Gutter test"> + + Option 1 + ); - expect(screen.getByRole('radio')).toBeInTheDocument(); + expect(screen.getByRole('grid', {name: 'Gutter test'})).toBeInTheDocument(); }); it('handles emphasized style', () => { render( - {}} value="" isEmphasized label="Emphasized test"> - Option 1 + {}} + value="" + isEmphasized + label="Emphasized test"> + + Option 1 + ); - expect(screen.getByRole('radio')).toBeInTheDocument(); + expect(screen.getByRole('grid', {name: 'Emphasized test'})).toBeInTheDocument(); }); }); }); From 1a7e962fa7de31557615451e2ceb25b4aa565c5d Mon Sep 17 00:00:00 2001 From: DPandyan Date: Thu, 17 Jul 2025 14:45:13 -0700 Subject: [PATCH 8/9] removed extraneous overrides and isEmphasized prop --- packages/@react-spectrum/s2/src/SelectBox.tsx | 16 ++++++++-------- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 9 +-------- .../s2/stories/SelectBoxGroup.stories.tsx | 1 - .../s2/test/SelectBoxGroup.test.tsx | 1 - 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 9216333f689..155db40d755 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -175,7 +175,7 @@ const iconContainer = style({ opacity: { isDisabled: 0.4 } -}, getAllowedOverrides()); +}); const textContainer = style({ display: 'flex', @@ -205,13 +205,13 @@ const descriptionText = style({ isDisabled: 'disabled' }, lineHeight: 'body' -}, getAllowedOverrides()); +}); const checkboxContainer = style({ position: 'absolute', top: 16, left: 16 -}, getAllowedOverrides()); +}); const SelectBoxRenderPropsContext = createContext<{ isHovered?: boolean, @@ -257,7 +257,7 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele style={UNSAFE_style}> {showCheckbox && ( -
+
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && ( -
+
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')}
)} @@ -280,7 +280,7 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')} {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description') && ( -
+
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description')}
)} @@ -290,12 +290,12 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele ) : ( <> {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && ( -
+
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')}
)} -
+
{React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')}
diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 2f6f6fe1226..4f786c4aa76 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -58,10 +58,6 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, * @default 'vertical' */ orientation?: Orientation, - /** - * Whether the SelectBoxGroup should be displayed with an emphasized style. - */ - isEmphasized?: boolean, /** * Number of columns to display the SelectBox elements in. * @default 2 @@ -98,7 +94,6 @@ interface SelectBoxContextValue { allowMultiSelect?: boolean, size?: 'XS' | 'S' | 'M' | 'L' | 'XL', orientation?: Orientation, - isEmphasized?: boolean, isDisabled?: boolean, selectedKeys?: Selection, onSelectionChange?: (keys: Selection) => void @@ -225,7 +220,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p selectionMode = 'single', size = 'M', orientation = 'vertical', - isEmphasized, numColumns = 2, gutterWidth = 'default', isRequired = false, @@ -300,14 +294,13 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p allowMultiSelect: selectionMode === 'multiple', size, orientation, - isEmphasized, isDisabled, selectedKeys, onSelectionChange: setSelectedKeys }; return contextValue; }, - [selectionMode, size, orientation, isEmphasized, isDisabled, selectedKeys, setSelectedKeys] + [selectionMode, size, orientation, isDisabled, selectedKeys, setSelectedKeys] ); const currentValue = convertSelectionToValue(selectedKeys, selectionMode); diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 97c903a9bcc..ac2e83a8a85 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -64,7 +64,6 @@ const meta: Meta = { gutterWidth: 'default', isRequired: false, isDisabled: false, - isEmphasized: false } }; diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index e00e0f74652..bb14a3a5e03 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -692,7 +692,6 @@ describe('SelectBoxGroup', () => { selectionMode="single" onSelectionChange={() => {}} value="" - isEmphasized label="Emphasized test"> Option 1 From 29734bb7626da03efc3c6f338f6b01ee5948a33b Mon Sep 17 00:00:00 2001 From: DPandyan Date: Fri, 18 Jul 2025 11:36:25 -0700 Subject: [PATCH 9/9] lint and removed XS --- packages/@react-spectrum/s2/src/SelectBox.tsx | 27 ++--- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 20 +++- .../s2/stories/SelectBoxGroup.stories.tsx | 109 ++++++++++-------- .../s2/test/SelectBoxGroup.test.tsx | 48 +++++++- 4 files changed, 130 insertions(+), 74 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 155db40d755..de1b3f1a496 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -51,7 +51,6 @@ const selectBoxStyles = style({ width: { default: { size: { - XS: 100, S: 128, M: 136, L: 160, @@ -65,7 +64,6 @@ const selectBoxStyles = style({ height: { default: { size: { - XS: 100, S: 128, M: 136, L: 160, @@ -88,7 +86,6 @@ const selectBoxStyles = style({ }, padding: { size: { - XS: 12, S: 16, M: 20, L: 24, @@ -207,11 +204,6 @@ const descriptionText = style({ lineHeight: 'body' }); -const checkboxContainer = style({ - position: 'absolute', - top: 16, - left: 16 -}); const SelectBoxRenderPropsContext = createContext<{ isHovered?: boolean, @@ -257,14 +249,17 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele style={UNSAFE_style}> {showCheckbox && ( -
-
- -
+
+
)} {orientation === 'horizontal' ? ( diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 4f786c4aa76..efc5346ac45 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -16,6 +16,7 @@ import { Text } from 'react-aria-components'; import {DOMRef, DOMRefValue, HelpTextProps, Orientation, Selection, SpectrumLabelableProps} from '@react-types/shared'; +import {FieldLabel} from './Field'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import React, {createContext, forwardRef, ReactElement, ReactNode, useEffect, useId, useMemo} from 'react'; import {SelectBoxRenderPropsContext} from './SelectBox'; @@ -52,7 +53,7 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, * The size of the SelectBoxGroup. * @default 'M' */ - size?: 'XS' | 'S' | 'M' | 'L' | 'XL', + size?: 'S' | 'M' | 'L' | 'XL', /** * The axis the SelectBox elements should align with. * @default 'vertical' @@ -87,12 +88,16 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, /** * Whether the SelectBoxGroup is in an invalid state. */ - isInvalid?: boolean + isInvalid?: boolean, + /** + * Contextual help text for the SelectBoxGroup. + */ + contextualHelp?: ReactNode } interface SelectBoxContextValue { allowMultiSelect?: boolean, - size?: 'XS' | 'S' | 'M' | 'L' | 'XL', + size?: 'S' | 'M' | 'L' | 'XL', orientation?: Orientation, isDisabled?: boolean, selectedKeys?: Selection, @@ -214,6 +219,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p let { label, + contextualHelp, children, onSelectionChange, defaultValue, @@ -318,10 +324,12 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p isInvalid={hasValidationErrors} /> {label && ( - + {label} - {isRequired && *} - + )} = { - title: 'SelectBoxGroup (Collection)', + title: 'SelectBoxGroup', component: SelectBoxGroup, parameters: { layout: 'centered' @@ -42,7 +42,7 @@ const meta: Meta = { }, size: { control: 'select', - options: ['XS', 'S', 'M', 'L', 'XL'] + options: ['S', 'M', 'L', 'XL'] }, orientation: { control: 'select', @@ -63,7 +63,7 @@ const meta: Meta = { numColumns: 2, gutterWidth: 'default', isRequired: false, - isDisabled: false, + isDisabled: false } }; @@ -133,15 +133,13 @@ export const MultipleSelection: Story = { // Grid Navigation Testing export const GridNavigation: Story = { args: { - label: 'Test Grid Navigation (Use Arrow Keys)', + label: 'Test Grid Navigation', numColumns: 3 }, render: (args) => (

- Focus any item (best done by clicking to the left of the group and hitting the tab key) and use arrow keys to navigate: - {/*
• ↑↓ moves vertically (same column) -
• ←→ moves horizontally (same row) */} + Focus any item (best done by clicking to the side of the group and hitting the tab key) and using arrow keys to navigate:

@@ -167,38 +165,6 @@ export const GridNavigation: Story = { ) }; -// Form Integration -export const FormIntegration: Story = { - args: { - label: 'Select your option', - name: 'user_preference', - isRequired: true - }, - render: (args) => ( -
{ - e.preventDefault(); - const formData = new FormData(e.currentTarget); - action('Form submitted')(Object.fromEntries(formData)); - }}> - - - Option 1 - - - Option 2 - - - Option 3 - - - -
- ) -}; - export const FormValidation: Story = { args: { label: 'Required Selection', @@ -222,7 +188,7 @@ export const FormValidation: Story = { export const SizeVariations: Story = { render: () => (
- {(['XS', 'S', 'M', 'L', 'XL'] as const).map((size) => ( + {(['S', 'M', 'L', 'XL'] as const).map((size) => ( @@ -346,9 +311,8 @@ export const Controlled: Story = { render: () => }; -// Dynamic Icons - Convert to proper component to use React hooks function DynamicIconsStory() { - const [selectedValues, setSelectedValues] = React.useState>( + const [selectedValues, setSelectedValues] = useState>( new Set() ); @@ -379,7 +343,6 @@ export const DynamicIcons: Story = { render: () => }; -// Multiple Columns export const MultipleColumns: Story = { args: { label: 'Choose options', @@ -398,3 +361,55 @@ export const MultipleColumns: Story = {
) }; + +function FormSubmissionExample() { + const [selectedValues, setSelectedValues] = useState(['newsletter']); + const [submittedData, setSubmittedData] = useState(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const preferences = formData.getAll('preferences') as string[]; + setSubmittedData(preferences); + action('form-submitted')(preferences); + }; + + return ( +
+
+ setSelectedValues(val as string[])} + name="preferences" + label="Email Preferences" + isRequired> + + Newsletter + + + Marketing Updates + + + Product News + + + Security Alerts + + + + +
+ + {submittedData && ( + Submitted: {submittedData.join(', ')} + )} +
+ ); +} + +export const FormSubmission: Story = { + render: () => +}; diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index bb14a3a5e03..66451d74b8c 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -1,8 +1,8 @@ +import {act, render, screen, waitFor} from '@testing-library/react'; +import {Button, Text} from '../src'; import React from 'react'; -import {render, screen, waitFor, act} from '@testing-library/react'; import {SelectBox} from '../src/SelectBox'; import {SelectBoxGroup} from '../src/SelectBoxGroup'; -import {Text} from '../src'; import userEvent from '@testing-library/user-event'; function SingleSelectBox() { @@ -250,11 +250,9 @@ describe('SelectBoxGroup', () => {
); - const grid = screen.getByRole('grid', {name: 'Required test required'}); + const grid = screen.getByRole('grid', {name: 'Required test'}); expect(grid).toBeInTheDocument(); - expect(screen.getByText('Required test')).toBeInTheDocument(); - expect(screen.getByText('*')).toBeInTheDocument(); }); it('supports error message and validation', () => { @@ -422,6 +420,46 @@ describe('SelectBoxGroup', () => { expect(hiddenInput).toBeInTheDocument(); expect(hiddenInput).toHaveValue('option1'); }); + + it('works with form submission using S2 Button', async () => { + const onSubmit = jest.fn(); + render( +
{ + e.preventDefault(); + const formData = new FormData(e.currentTarget); + const values = formData.getAll('preferences'); + onSubmit(values); + }}> + {}} + value={['option1', 'option3']} + name="preferences" + label="User Preferences"> + + Newsletter + + + Marketing + + + Updates + + + +
+ ); + + const submitButton = screen.getByRole('button', {name: 'Submit Preferences'}); + expect(submitButton).toBeInTheDocument(); + + await userEvent.click(submitButton); + + expect(onSubmit).toHaveBeenCalledWith(['option1', 'option3']); + }); }); describe('Individual SelectBox behavior', () => {