From e4d4c17bd95862c373300d923d8376266e0dbc5a Mon Sep 17 00:00:00 2001 From: Daniel Pandyan Date: Mon, 14 Jul 2025 10:26:59 -0700 Subject: [PATCH 01/24] 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 02/24] 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 03/24] 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 04/24] 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 05/24] 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 06/24] 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 07/24] 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 08/24] 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 09/24] 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', () => { From cab2c79db92cee52b42c38c956a778c7d3acb1b4 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Mon, 21 Jul 2025 12:24:22 -0700 Subject: [PATCH 10/24] test-utils-internal swap --- packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 66451d74b8c..40a43b2c5ce 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -1,4 +1,4 @@ -import {act, render, screen, waitFor} from '@testing-library/react'; +import {act, render, screen, waitFor} from '@react-spectrum/test-utils-internal'; import {Button, Text} from '../src'; import React from 'react'; import {SelectBox} from '../src/SelectBox'; From 2f1c99d978daa1a965149a11b2356272914f8a56 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Mon, 21 Jul 2025 12:24:22 -0700 Subject: [PATCH 11/24] test-utils-internal swap --- packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 66451d74b8c..a4bc5ce383a 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -1,8 +1,7 @@ -import {act, render, screen, waitFor} from '@testing-library/react'; +import {act, render, screen, waitFor} from '@react-spectrum/test-utils-internal'; import {Button, Text} from '../src'; import React from 'react'; -import {SelectBox} from '../src/SelectBox'; -import {SelectBoxGroup} from '../src/SelectBoxGroup'; +import {SelectBox, SelectBoxGroup} from '../src'; import userEvent from '@testing-library/user-event'; function SingleSelectBox() { From ea0f8d5d3b6236acfa3980f7aae3768ba800e6e0 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Tue, 22 Jul 2025 10:02:58 -0700 Subject: [PATCH 12/24] swapped useId library --- packages/@react-spectrum/s2/src/SelectBox.tsx | 3 ++- packages/@react-spectrum/s2/src/SelectBoxGroup.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index de1b3f1a496..09731adb919 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -259,7 +259,8 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele isSelected={isSelected} isDisabled={isDisabled} size={size} - isReadOnly /> + isReadOnly + excludeFromTabOrder />
)} {orientation === 'horizontal' ? ( diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index efc5346ac45..cc0c9c731d6 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -18,11 +18,12 @@ import { 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 React, {createContext, forwardRef, ReactElement, ReactNode, useEffect, 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 {useId} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; export type SelectBoxValueType = string | string[]; @@ -325,7 +326,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p {label && ( {label} @@ -338,7 +339,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p selectedKeys={selectedKeys} onSelectionChange={isDisabled ? () => {} : setSelectedKeys} disabledKeys={disabledKeys} - aria-labelledby={label ? `${gridId}-label` : undefined} + aria-labelledby={label ? gridId : undefined} aria-describedby={hasValidationErrors && errorMessage ? errorId : undefined} aria-invalid={hasValidationErrors} aria-required={isRequired} From 19a536ca3aa2c4fb3f9bd7f5cbbb340ee7514fe6 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Fri, 25 Jul 2025 19:45:19 -0700 Subject: [PATCH 13/24] swapped to listbox --- packages/@react-spectrum/s2/src/SelectBox.tsx | 100 ++- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 168 ++--- .../s2/stories/SelectBoxGroup.stories.tsx | 657 ++++++++++++------ 3 files changed, 572 insertions(+), 353 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 09731adb919..ddb2f77520a 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -10,10 +10,11 @@ */ import {Checkbox} from './Checkbox'; -import {FocusableRef} from '@react-types/shared'; +import {FocusableRef, FocusableRefValue} 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 {ContextValue} from 'react-aria-components'; +import {SelectBoxContext, SelectBoxGroupContext} from './SelectBoxGroup'; import {style} from '../style' with {type: 'macro'}; import {useFocusableRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -33,7 +34,7 @@ export interface SelectBoxProps extends StyleProps { isDisabled?: boolean } -export const SelectBoxItemContext = createContext(null); +export const SelectBoxSpectrumContext = createContext, FocusableRefValue>>(null); const selectBoxStyles = style({ display: 'flex', @@ -185,7 +186,11 @@ const textContainer = style({ }, gap: 'text-to-visual', color: { - isDisabled: 'disabled' + default: 'neutral', + isDisabled: { + default: 'gray-600', + forcedColors: 'GrayText' + } } }, getAllowedOverrides()); @@ -216,14 +221,13 @@ const SelectBoxRenderPropsContext = createContext<{ * Works as content within a GridListItem for automatic grid navigation. */ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { - [props, ref] = useSpectrumContextProps(props, ref, SelectBoxItemContext); + [props, ref] = useSpectrumContextProps(props, ref, SelectBoxSpectrumContext); 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', orientation = 'vertical', selectedKeys, isDisabled: groupDisabled = false @@ -231,10 +235,22 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele let renderProps = useContext(SelectBoxRenderPropsContext); + const size = 'M'; //Only medium size is supported const isDisabled = individualDisabled || groupDisabled; const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); const showCheckbox = isSelected || (!isDisabled && renderProps.isHovered); + const childrenArray = React.Children.toArray(children); + const iconSlot = childrenArray.find((child: any) => child?.props?.slot === 'icon'); + const textSlot = childrenArray.find((child: any) => child?.props?.slot === 'text'); + const descriptionSlot = childrenArray.find((child: any) => child?.props?.slot === 'description'); + const otherChildren = childrenArray.filter((child: any) => + !['icon', 'text', 'description'].includes(child?.props?.slot) + ); + + const hasIcon = !!iconSlot; + const hasDescription = !!descriptionSlot; + return (
+ excludeFromTabOrder + aria-label={isSelected ? 'Selected' : 'Select'} />
)} + {orientation === 'horizontal' ? ( + // Horizontal layout with all combinations <> - {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && ( + {hasIcon && (
- {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')} + {iconSlot}
)} -
-
- {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')} -
- )} + {hasIcon || hasDescription ? ( + // Standard horizontal layout with icon and/or description +
+
+ {textSlot} + + {hasDescription && ( +
+ {descriptionSlot} +
+ )} +
-
+ ) : ( + // Text-only horizontal layout (optimized) +
+
+ {textSlot} +
+
+ )} ) : ( + // Vertical layout with icon and/or description <> - {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon') && ( + {hasIcon && (
- {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'icon')} + {iconSlot}
)}
- {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'text')} + {textSlot}
+ + {hasDescription && ( +
+
+ {descriptionSlot} +
+
+ )} )} - {React.Children.toArray(children).filter((child: any) => - !['icon', 'text', 'description'].includes(child?.props?.slot) - )} + {otherChildren}
); }); diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index cc0c9c731d6..c62be8f63ec 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -11,8 +11,8 @@ import { ContextValue, - GridList, - GridListItem, + ListBox, + ListBoxItem, Text } from 'react-aria-components'; import {DOMRef, DOMRefValue, HelpTextProps, Orientation, Selection, SpectrumLabelableProps} from '@react-types/shared'; @@ -26,8 +26,6 @@ import {useDOMRef} from '@react-spectrum/utils'; import {useId} from '@react-aria/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export type SelectBoxValueType = string | string[]; - export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, HelpTextProps { /** * The SelectBox elements contained within the SelectBoxGroup. @@ -36,25 +34,20 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, /** * Handler that is called when the selection changes. */ - onSelectionChange: (val: SelectBoxValueType) => void, + onSelectionChange?: (selection: Selection) => void, /** * The selection mode for the SelectBoxGroup. * @default 'single' */ selectionMode?: 'single' | 'multiple', /** - * The current selected value (controlled). - */ - value?: SelectBoxValueType, - /** - * The default selected value. + * The currently selected keys in the collection (controlled). */ - defaultValue?: SelectBoxValueType, + selectedKeys?: Selection, /** - * The size of the SelectBoxGroup. - * @default 'M' + * The initial selected keys in the collection (uncontrolled). */ - size?: 'S' | 'M' | 'L' | 'XL', + defaultSelectedKeys?: Selection, /** * The axis the SelectBox elements should align with. * @default 'vertical' @@ -98,41 +91,15 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, interface SelectBoxContextValue { allowMultiSelect?: boolean, - size?: 'S' | 'M' | 'L' | 'XL', orientation?: Orientation, isDisabled?: boolean, selectedKeys?: Selection, onSelectionChange?: (keys: Selection) => void } -const convertValueToSelection = (value: SelectBoxValueType | undefined, selectionMode: 'single' | 'multiple'): Selection => { - if (value === undefined) { - return selectionMode === 'multiple' ? new Set() : new Set(); - } - - if (Array.isArray(value)) { - return new Set(value); - } - - return selectionMode === 'multiple' ? new Set([value]) : new Set([value]); -}; -const convertSelectionToValue = (selection: Selection, selectionMode: 'single' | 'multiple'): SelectBoxValueType => { - if (selection === 'all') { - return selectionMode === 'multiple' ? [] : ''; - } - - const keys = Array.from(selection).map(key => String(key)); - - if (selectionMode === 'multiple') { - return keys; - } - - return keys[0] || ''; -}; export const SelectBoxContext = createContext({ - size: 'M', orientation: 'vertical' }); @@ -148,9 +115,6 @@ const gridStyles = style({ spacious: 24 } }, - '&[role="grid"]': { - display: 'grid' - } }, getAllowedOverrides()); const containerStyles = style({ @@ -166,54 +130,15 @@ const errorMessageStyles = style({ interface FormIntegrationProps { name?: string, - value: SelectBoxValueType, + selectedKeys: Selection, + selectionMode: 'single' | 'multiple', isRequired?: boolean, isInvalid?: boolean } - -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. + * Built with ListBox for automatic grid-based keyboard navigation. */ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); @@ -223,9 +148,9 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p contextualHelp, children, onSelectionChange, - defaultValue, + selectedKeys: controlledSelectedKeys, + defaultSelectedKeys, selectionMode = 'single', - size = 'M', orientation = 'vertical', numColumns = 2, gutterWidth = 'default', @@ -242,13 +167,9 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const errorId = useId(); const [selectedKeys, setSelectedKeys] = useControlledState( - props.value !== undefined ? convertValueToSelection(props.value, selectionMode) : undefined, - convertValueToSelection(defaultValue, selectionMode), - (selection) => { - const value = convertSelectionToValue(selection, selectionMode); - - onSelectionChange(value); - } + controlledSelectedKeys, + defaultSelectedKeys || new Set(), + onSelectionChange ); @@ -264,7 +185,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p }, [isRequired, selectedKeys]); const hasValidationErrors = isInvalid || validationErrors.length > 0; - const childrenArray = React.Children.toArray(children).filter((x) => x); const disabledKeys = useMemo(() => { @@ -299,7 +219,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p () => { const contextValue = { allowMultiSelect: selectionMode === 'multiple', - size, orientation, isDisabled, selectedKeys, @@ -307,11 +226,9 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p }; return contextValue; }, - [selectionMode, size, orientation, isDisabled, selectedKeys, setSelectedKeys] + [selectionMode, orientation, isDisabled, selectedKeys, setSelectedKeys] ); - const currentValue = convertSelectionToValue(selectedKeys, selectionMode); - return (
@@ -333,7 +251,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p )} - + {(renderProps) => ( )} - + ); })} - + {hasValidationErrors && errorMessage && ( ); }); + +function FormIntegration({name, selectedKeys, selectionMode, isRequired, isInvalid}: FormIntegrationProps) { + if (!name) { + return null; + } + + // Convert Selection to array of strings for form submission + const values = selectedKeys === 'all' ? [] : Array.from(selectedKeys).map(String); + + if (selectionMode === 'multiple') { + return ( + <> + {values.map((val, index) => ( + + ))} + {values.length === 0 && isRequired && ( + + )} + + ); + } + + return ( + + ); +} diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index af6603067ec..9721ddcd327 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -24,6 +24,7 @@ import React, {useState} from 'react'; import Server from '../spectrum-illustrations/linear/Server'; import StarFilledSVG from '../s2wf-icons/S2_Icon_StarFilled_20_N.svg'; import StarSVG from '../s2wf-icons/S2_Icon_Star_20_N.svg'; +import type {Selection} from 'react-aria-components'; const StarIcon = createIcon(StarSVG); const StarFilledIcon = createIcon(StarFilledSVG); @@ -40,10 +41,6 @@ const meta: Meta = { control: 'select', options: ['single', 'multiple'] }, - size: { - control: 'select', - options: ['S', 'M', 'L', 'XL'] - }, orientation: { control: 'select', options: ['vertical', 'horizontal'] @@ -58,7 +55,6 @@ const meta: Meta = { }, args: { selectionMode: 'single', - size: 'M', orientation: 'vertical', numColumns: 2, gutterWidth: 'default', @@ -105,134 +101,67 @@ export const MultipleSelection: Story = { args: { selectionMode: 'multiple', label: 'Select your preferred services', - defaultValue: ['aws', 'gcp'], - necessityIndicator: 'icon' + defaultSelectedKeys: new Set(['aws', 'gcp']), + necessityIndicator: 'icon', + numColumns: 3, + gutterWidth: 'default' }, render: (args) => ( - - - - Amazon Web Services - - - - Microsoft Azure - - - - Google Cloud Platform - - - - Oracle Cloud - - - ) -}; - -// Grid Navigation Testing -export const GridNavigation: Story = { - args: { - label: 'Test Grid Navigation', - numColumns: 3 - }, - render: (args) => ( -
+

- Focus any item (best done by clicking to the side of the group and hitting the tab key) and using arrow keys to navigate: + Focus any item and use arrow keys for grid navigation:

- - Item 1 + + + Amazon Web Services + Reliable cloud infrastructure + + + + Microsoft Azure + Enterprise cloud solutions + + + + Google Cloud Platform + Modern cloud services - - Item 2 + + + Oracle Cloud + Database-focused cloud - - Item 3 + + + IBM Cloud + Hybrid cloud solutions - - Item 4 + + + Alibaba Cloud + Asia-focused services - - Item 5 + + + DigitalOcean + Developer-friendly platform - - Item 6 + + + Linode + Simple cloud computing + + + + Vultr + High performance cloud
) }; -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: () => ( -
- {(['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: { @@ -254,116 +183,131 @@ export const DisabledGroup: Story = { ) }; -export const IndividualDisabled: Story = { - args: { - label: 'Some items disabled', - defaultValue: 'option2' - }, - render: (args) => ( - - - Option 1 (Disabled) - - - Option 2 - - - Option 3 (Disabled) - - - Option 4 - - - ) -}; - -function ControlledStory() { - const [value, setValue] = useState('option2'); +function InteractiveExamplesStory() { + const [selectedKeys, setSelectedKeys] = useState(new Set(['enabled1', 'starred2'])); return ( -
-

Current value: {value}

+
+

Interactive Features Combined

+

+ Current selection: {selectedKeys === 'all' ? 'All' : Array.from(selectedKeys).join(', ') || 'None'} +

+ setValue(val as string)}> - - Option 1 + label="Combined Interactive Features" + selectionMode="multiple" + selectedKeys={selectedKeys} + numColumns={4} + gutterWidth="default" + onSelectionChange={(selection) => { + setSelectedKeys(selection); + action('onSelectionChange')(selection); + }}> + + {/* Enabled items with dynamic icons */} + + {selectedKeys !== 'all' && selectedKeys.has('enabled1') ? ( + + ) : ( + + )} + Enabled Item 1 + Status updates + + + + {selectedKeys !== 'all' && selectedKeys.has('enabled2') ? ( + + ) : ( + + )} + Enabled Item 2 + Click to toggle - - Option 2 + + {/* Disabled item */} + + + Disabled Item + Cannot select - - Option 3 + + + {selectedKeys !== 'all' && selectedKeys.has('starred1') ? ( + + ) : ( + + )} + Starred Item 1 + Click to star - - - -
- ); -} - -export const Controlled: Story = { - render: () => -}; - -function DynamicIconsStory() { - const [selectedValues, setSelectedValues] = 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) ? ( + + + {selectedKeys !== 'all' && selectedKeys.has('starred2') ? ( ) : ( )} - Item {value.slice(-1)} + Starred Item 2 + Click to star - ))} - + + {/* Another disabled item */} + + + Disabled Service + Cannot select + + + + {selectedKeys !== 'all' && selectedKeys.has('dynamic1') ? ( + + ) : ( + + )} + Dynamic Icon + Click to activate + + + + {selectedKeys !== 'all' && selectedKeys.has('controllable') ? ( + + ) : ( + + )} + Controllable + External control available + + + + +
+ + + +
+
); } -export const DynamicIcons: Story = { - render: () => -}; - -export const MultipleColumns: Story = { - args: { - label: 'Choose options', - numColumns: 4, - gutterWidth: 'spacious' - }, - render: (args) => ( -
- - {Array.from({length: 8}, (_, i) => ( - - Option {i + 1} - - ))} - -
- ) +export const InteractiveExamples: Story = { + render: () => }; -function FormSubmissionExample() { - const [selectedValues, setSelectedValues] = useState(['newsletter']); +function FormAndLayoutStory() { + const [selectedPreferences, setSelectedPreferences] = useState(new Set(['newsletter', 'security'])); const [submittedData, setSubmittedData] = useState(null); const handleSubmit = (e: React.FormEvent) => { @@ -375,41 +319,308 @@ function FormSubmissionExample() { }; return ( -
+
+

Form Integration with Multiple Columns

+

+ 8-item grid with form integration demonstrating both layout and submission capabilities. +

+
setSelectedValues(val as string[])} + selectedKeys={selectedPreferences} + onSelectionChange={(selection) => { + setSelectedPreferences(selection); + action('onSelectionChange')(selection); + }} name="preferences" - label="Email Preferences" - isRequired> + label="Communication Preferences (Required)" + isRequired + numColumns={4} + gutterWidth="spacious"> + + Newsletter + Weekly updates and news + - Marketing Updates + + Marketing + Product promotions + + Product News + Feature announcements + + Security Alerts + Important updates + + + + Events + Webinars & conferences + + + + + Surveys + Help us improve + + + + + Community + Forum notifications + + + + + Support + Help & assistance + + - +
+ + + + Selected: {selectedPreferences === 'all' ? 'All' : selectedPreferences.size} item{(selectedPreferences === 'all' || selectedPreferences.size !== 1) ? 's' : ''} + +
{submittedData && ( - Submitted: {submittedData.join(', ')} +
+
Form Submitted Successfully!
+
+ Preferences: {submittedData.length > 0 ? submittedData.join(', ') : 'None selected'} +
+
+ Form data would be sent to server with name "preferences" +
+
)}
); } -export const FormSubmission: Story = { - render: () => +export const FormAndLayout: Story = { + render: () => +}; + +export const AllSlotCombinations: Story = { + render: () => ( +
+

All Slot Combinations

+ + {/* Vertical Orientation */} +
+

Vertical Orientation

+
+ + {/* Text Only */} +
+

Text Only

+ + + Simple Text + + +
+ + {/* Icon + Text */} +
+

Icon + Text

+ + + + With Icon + + +
+ + {/* Text + Description */} +
+

Text + Description

+ + + Main Text + Additional description + + +
+ + {/* Icon + Text + Description */} +
+

Icon + Text + Description

+ + + + Full Vertical + Complete description + + +
+ +
+
+ + {/* Horizontal Orientation */} +
+

Horizontal Orientation

+
+ + {/* Text Only */} +
+

Text Only (Optimized)

+ + + Simple Horizontal Text + + +
+ + {/* Icon + Text */} +
+

Icon + Text

+ + + + Horizontal with Icon + + +
+ + {/* Text + Description */} +
+

Text + Description

+ + + Main Horizontal Text + Horizontal description text + + +
+ + {/* Icon + Text + Description */} +
+

Icon + Text + Description

+ + + + Complete Horizontal + Full horizontal layout with all elements + + +
+ +
+
+ + {/* Comparison Grid */} +
+

Side-by-Side Comparison

+ + + {/* Vertical examples */} + + V: Text Only + + + + + V: Icon + Text + + + + V: Text + Desc + Vertical description + + + + + V: All Elements + Complete vertical + + + + +
+ + + {/* Horizontal examples */} + + H: Text Only + + + + + H: Icon + Text + + + + H: Text + Description + Horizontal description + + + + + H: All Elements + Complete horizontal layout + + + +
+
+ +
+ ) }; From a77932972bc5e4d00487f09aeba0057c8b4eab8f Mon Sep 17 00:00:00 2001 From: DPandyan Date: Mon, 28 Jul 2025 04:32:49 -0700 Subject: [PATCH 14/24] added additional props and removed s2 checkbox --- packages/@react-spectrum/s2/src/SelectBox.tsx | 113 +++++-- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 33 +- packages/@react-spectrum/s2/src/index.ts | 2 +- .../s2/stories/SelectBoxGroup.stories.tsx | 241 +++++++++++-- .../s2/test/SelectBoxGroup.test.tsx | 317 ++++++++++++------ 5 files changed, 524 insertions(+), 182 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index ddb2f77520a..2a35deefeb2 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -9,7 +9,8 @@ * governing permissions and limitations under the License. */ -import {Checkbox} from './Checkbox'; +import Checkmark from '../ui-icons/Checkmark'; +import {box, iconStyles} from './Checkbox'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import React, {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; @@ -31,7 +32,22 @@ export interface SelectBoxProps extends StyleProps { /** * Whether the SelectBox is disabled. */ - isDisabled?: boolean + isDisabled?: boolean, + /** + * Whether to show the selection checkbox. + * @default false + */ + isCheckboxDisabled?: boolean, + /** + * Whether to show the label/text content. + * @default false + */ + isLabelDisabled?: boolean, + /** + * Whether to show the illustration/icon. + * @default false + */ + isIllustrationDisabled?: boolean } export const SelectBoxSpectrumContext = createContext, FocusableRefValue>>(null); @@ -222,7 +238,15 @@ const SelectBoxRenderPropsContext = createContext<{ */ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxSpectrumContext); - let {children, value, isDisabled: individualDisabled = false, UNSAFE_style} = props; + let { + children, + value, + isDisabled: individualDisabled = false, + isCheckboxDisabled = false, + isLabelDisabled = false, + isIllustrationDisabled = false, + UNSAFE_style + } = props; let divRef = useRef(null); let domRef = useFocusableRef(ref, divRef); @@ -230,15 +254,20 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele let { orientation = 'vertical', selectedKeys, - isDisabled: groupDisabled = false + isDisabled: groupDisabled = false, + isCheckboxDisabled: groupIsCheckboxDisabled = false, + isLabelDisabled: groupIsLabelDisabled = false, + isIllustrationDisabled: groupIsIllustrationDisabled = false } = contextValue; let renderProps = useContext(SelectBoxRenderPropsContext); const size = 'M'; //Only medium size is supported const isDisabled = individualDisabled || groupDisabled; + const finalShowCheckbox = !isCheckboxDisabled; + const finalShowLabel = !isLabelDisabled; + const finalShowIllustration = !isIllustrationDisabled; const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); - const showCheckbox = isSelected || (!isDisabled && renderProps.isHovered); const childrenArray = React.Children.toArray(children); const iconSlot = childrenArray.find((child: any) => child?.props?.slot === 'icon'); @@ -264,36 +293,52 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele }, props.styles)} style={UNSAFE_style}> - {showCheckbox && ( + {finalShowCheckbox && (isSelected || (!isDisabled && renderProps.isHovered)) && (
- + })} + aria-hidden="true"> +
+ {isSelected && ( + + )} +
)} {orientation === 'horizontal' ? ( // Horizontal layout with all combinations <> - {hasIcon && ( + {hasIcon && finalShowIllustration && (
{iconSlot}
)} - {hasIcon || hasDescription ? ( + {(hasIcon && finalShowIllustration) || hasDescription ? ( // Standard horizontal layout with icon and/or description
- {textSlot} + {finalShowLabel && textSlot} {hasDescription && (
@@ -303,33 +348,37 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele
) : ( - // Text-only horizontal layout (optimized) -
-
- {textSlot} + // Text-only horizontal layout + finalShowLabel && ( +
+
+ {textSlot} +
-
+ ) )} ) : ( // Vertical layout with icon and/or description <> - {hasIcon && ( + {hasIcon && finalShowIllustration && (
{iconSlot}
)} -
- {textSlot} -
+ {finalShowLabel && ( +
+ {textSlot} +
+ )} {hasDescription && (
void } - - -export const SelectBoxContext = createContext({ - orientation: 'vertical' -}); - +export const SelectBoxContext = createContext({ orientation: 'vertical' }); export const SelectBoxGroupContext = createContext, DOMRefValue>>(null); const gridStyles = style({ @@ -156,6 +166,9 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p gutterWidth = 'default', isRequired = false, isDisabled = false, + isCheckboxDisabled = false, + isLabelDisabled = false, + isIllustrationDisabled = false, name, errorMessage, isInvalid = false, @@ -221,12 +234,15 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p allowMultiSelect: selectionMode === 'multiple', orientation, isDisabled, + isCheckboxDisabled, + isLabelDisabled, + isIllustrationDisabled, selectedKeys, onSelectionChange: setSelectedKeys }; return contextValue; }, - [selectionMode, orientation, isDisabled, selectedKeys, setSelectedKeys] + [selectionMode, orientation, isDisabled, isCheckboxDisabled, isLabelDisabled, isIllustrationDisabled, selectedKeys, setSelectedKeys] ); return ( @@ -293,7 +309,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const textValue = getTextValue(childElement); return ( - + {(renderProps) => ( Focus any item and use arrow keys for grid navigation:

- - - - Amazon Web Services + + + + Amazon Web Services Reliable cloud infrastructure - - - - Microsoft Azure + + + + Microsoft Azure Enterprise cloud solutions - - - - Google Cloud Platform + + + + Google Cloud Platform Modern cloud services - - - - Oracle Cloud + + + + Oracle Cloud Database-focused cloud @@ -188,7 +189,7 @@ function InteractiveExamplesStory() { return (
-

Interactive Features Combined

+ Interactive Features Combined

Current selection: {selectedKeys === 'all' ? 'All' : Array.from(selectedKeys).join(', ') || 'None'}

@@ -277,7 +278,7 @@ function InteractiveExamplesStory() { )} Controllable External control available - + @@ -320,7 +321,7 @@ function FormAndLayoutStory() { return (
-

Form Integration with Multiple Columns

+ Form Integration with Multiple Columns

8-item grid with form integration demonstrating both layout and submission capabilities.

@@ -391,8 +392,8 @@ function FormAndLayoutStory() {
+ Save Preferences + + + + Option 1 + + + Option 2 + + +
+ ); + } + + render(); + + const button = screen.getByRole('button', {name: 'Select Option 2'}); + await userEvent.click(button); + + const option2 = screen.getByRole('option', {name: 'Option 2'}); + expect(option2).toHaveAttribute('aria-selected', 'true'); }); }); }); From eb96cbaa23e00c100e3bacb824358dc7be1ac46b Mon Sep 17 00:00:00 2001 From: DPandyan Date: Mon, 28 Jul 2025 15:31:49 -0700 Subject: [PATCH 15/24] revised tests, added additional props, swapped to UI checkmark to avoid accessibility issues --- packages/@react-spectrum/s2/src/SelectBox.tsx | 81 ++-- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 36 +- .../s2/stories/SelectBoxGroup.stories.tsx | 84 ++-- .../s2/test/SelectBoxGroup.test.tsx | 379 +++++++++++++++++- 4 files changed, 465 insertions(+), 115 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 2a35deefeb2..6355827e934 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -9,13 +9,13 @@ * governing permissions and limitations under the License. */ -import Checkmark from '../ui-icons/Checkmark'; import {box, iconStyles} from './Checkbox'; +import Checkmark from '../ui-icons/Checkmark'; +import {ContextValue} from 'react-aria-components'; import {FocusableRef, FocusableRefValue} from '@react-types/shared'; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import React, {createContext, forwardRef, ReactNode, useContext, useRef} from 'react'; -import {ContextValue} from 'react-aria-components'; -import {SelectBoxContext, SelectBoxGroupContext} from './SelectBoxGroup'; +import {SelectBoxContext} from './SelectBoxGroup'; import {style} from '../style' with {type: 'macro'}; import {useFocusableRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -34,20 +34,20 @@ export interface SelectBoxProps extends StyleProps { */ isDisabled?: boolean, /** - * Whether to show the selection checkbox. + * Whether to hide the selection checkbox. * @default false */ - isCheckboxDisabled?: boolean, + isCheckboxHidden?: boolean, /** - * Whether to show the label/text content. + * Whether to hide the label/text content. * @default false */ - isLabelDisabled?: boolean, + isLabelHidden?: boolean, /** - * Whether to show the illustration/icon. + * Whether to hide the illustration/icon. * @default false */ - isIllustrationDisabled?: boolean + isIllustrationHidden?: boolean } export const SelectBoxSpectrumContext = createContext, FocusableRefValue>>(null); @@ -242,9 +242,9 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele children, value, isDisabled: individualDisabled = false, - isCheckboxDisabled = false, - isLabelDisabled = false, - isIllustrationDisabled = false, + isCheckboxHidden = false, + isLabelHidden = false, + isIllustrationHidden = false, UNSAFE_style } = props; let divRef = useRef(null); @@ -255,18 +255,15 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele orientation = 'vertical', selectedKeys, isDisabled: groupDisabled = false, - isCheckboxDisabled: groupIsCheckboxDisabled = false, - isLabelDisabled: groupIsLabelDisabled = false, - isIllustrationDisabled: groupIsIllustrationDisabled = false + isCheckboxHidden: groupIsCheckboxHidden = false, + isLabelHidden: groupIsLabelHidden = false, + isIllustrationHidden: groupIsIllustrationHidden = false } = contextValue; let renderProps = useContext(SelectBoxRenderPropsContext); - const size = 'M'; //Only medium size is supported + const size = 'M'; // Only medium size is supported const isDisabled = individualDisabled || groupDisabled; - const finalShowCheckbox = !isCheckboxDisabled; - const finalShowLabel = !isLabelDisabled; - const finalShowIllustration = !isIllustrationDisabled; const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); const childrenArray = React.Children.toArray(children); @@ -293,7 +290,7 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele }, props.styles)} style={UNSAFE_style}> - {finalShowCheckbox && (isSelected || (!isDisabled && renderProps.isHovered)) && ( + {!(isCheckboxHidden || groupIsCheckboxHidden) && (isSelected || (!isDisabled && renderProps.isHovered)) && (
- {hasIcon && finalShowIllustration && ( + {hasIcon && !(isIllustrationHidden || groupIsIllustrationHidden) && (
{iconSlot}
)} - {(hasIcon && finalShowIllustration) || hasDescription ? ( + {(hasIcon && !(isIllustrationHidden || groupIsIllustrationHidden)) || hasDescription ? ( // Standard horizontal layout with icon and/or description
- {finalShowLabel && textSlot} + {!(isLabelHidden || groupIsLabelHidden) && textSlot} {hasDescription && (
@@ -349,15 +346,16 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele
) : ( // Text-only horizontal layout - finalShowLabel && ( -
+ !(isLabelHidden || groupIsLabelHidden) && ( +
{textSlot}
@@ -368,26 +366,27 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele ) : ( // Vertical layout with icon and/or description <> - {hasIcon && finalShowIllustration && ( + {hasIcon && !(isIllustrationHidden || groupIsIllustrationHidden) && (
{iconSlot}
)} - {finalShowLabel && ( + {!(isLabelHidden || groupIsLabelHidden) && (
{textSlot}
)} {hasDescription && ( -
+
{descriptionSlot}
diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 3a5c8cdca40..b0a14949878 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -72,17 +72,17 @@ export interface SelectBoxGroupProps extends StyleProps, SpectrumLabelableProps, */ isDisabled?: boolean, /** - * Whether to disable the selection checkbox for all SelectBoxes. + * Whether to hide the selection checkbox for all SelectBoxes. */ - isCheckboxDisabled?: boolean, + isCheckboxHidden?: boolean, /** - * Whether to disable the label/text content for all SelectBoxes. + * Whether to hide the label/text content for all SelectBoxes. */ - isLabelDisabled?: boolean, + isLabelHidden?: boolean, /** - * Whether to disable the illustration/icon for all SelectBoxes. + * Whether to hide the illustration/icon for all SelectBoxes. */ - isIllustrationDisabled?: boolean, + isIllustrationHidden?: boolean, /** * The name of the form field. */ @@ -105,14 +105,14 @@ interface SelectBoxContextValue { allowMultiSelect?: boolean, orientation?: Orientation, isDisabled?: boolean, - isCheckboxDisabled?: boolean, - isLabelDisabled?: boolean, - isIllustrationDisabled?: boolean, + isCheckboxHidden?: boolean, + isLabelHidden?: boolean, + isIllustrationHidden?: boolean, selectedKeys?: Selection, onSelectionChange?: (keys: Selection) => void } -export const SelectBoxContext = createContext({ orientation: 'vertical' }); +export const SelectBoxContext = createContext({orientation: 'vertical'}); export const SelectBoxGroupContext = createContext, DOMRefValue>>(null); const gridStyles = style({ @@ -124,7 +124,7 @@ const gridStyles = style({ compact: 8, spacious: 24 } - }, + } }, getAllowedOverrides()); const containerStyles = style({ @@ -166,9 +166,9 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p gutterWidth = 'default', isRequired = false, isDisabled = false, - isCheckboxDisabled = false, - isLabelDisabled = false, - isIllustrationDisabled = false, + isCheckboxHidden = false, + isLabelHidden = false, + isIllustrationHidden = false, name, errorMessage, isInvalid = false, @@ -234,15 +234,15 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p allowMultiSelect: selectionMode === 'multiple', orientation, isDisabled, - isCheckboxDisabled, - isLabelDisabled, - isIllustrationDisabled, + isCheckboxHidden, + isLabelHidden, + isIllustrationHidden, selectedKeys, onSelectionChange: setSelectedKeys }; return contextValue; }, - [selectionMode, orientation, isDisabled, isCheckboxDisabled, isLabelDisabled, isIllustrationDisabled, selectedKeys, setSelectedKeys] + [selectionMode, orientation, isDisabled, isCheckboxHidden, isLabelHidden, isIllustrationHidden, selectedKeys, setSelectedKeys] ); return ( diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 9b02df81d0a..002368a843d 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -21,10 +21,10 @@ import {Button, createIcon, Heading, SelectBox, SelectBoxGroup, Text} from '../s import type {Meta, StoryObj} from '@storybook/react'; import Paperairplane from '../spectrum-illustrations/linear/Paperairplane'; import React, {useState} from 'react'; +import type {Selection} from 'react-aria-components'; import Server from '../spectrum-illustrations/linear/Server'; import StarFilledSVG from '../s2wf-icons/S2_Icon_StarFilled_20_N.svg'; import StarSVG from '../s2wf-icons/S2_Icon_Star_20_N.svg'; -import type {Selection} from 'react-aria-components'; const StarIcon = createIcon(StarSVG); const StarFilledIcon = createIcon(StarFilledSVG); @@ -112,25 +112,25 @@ export const MultipleSelection: Story = {

Focus any item and use arrow keys for grid navigation:

- - - - Amazon Web Services + + + + Amazon Web Services Reliable cloud infrastructure - - - - Microsoft Azure + + + + Microsoft Azure Enterprise cloud solutions - - - - Google Cloud Platform + + + + Google Cloud Platform Modern cloud services - - - - Oracle Cloud + + + + Oracle Cloud Database-focused cloud @@ -168,7 +168,7 @@ export const DisabledGroup: Story = { args: { label: 'Disabled Group', isDisabled: true, - defaultValue: 'option1' + defaultSelectedKeys: new Set(['option1']) }, render: (args) => ( @@ -204,7 +204,6 @@ function InteractiveExamplesStory() { setSelectedKeys(selection); action('onSelectionChange')(selection); }}> - {/* Enabled items with dynamic icons */} {selectedKeys !== 'all' && selectedKeys.has('enabled1') ? ( @@ -215,7 +214,6 @@ function InteractiveExamplesStory() { Enabled Item 1 Status updates - {selectedKeys !== 'all' && selectedKeys.has('enabled2') ? ( @@ -225,14 +223,12 @@ function InteractiveExamplesStory() { Enabled Item 2 Click to toggle - {/* Disabled item */} Disabled Item Cannot select - {selectedKeys !== 'all' && selectedKeys.has('starred1') ? ( @@ -242,7 +238,6 @@ function InteractiveExamplesStory() { Starred Item 1 Click to star - {selectedKeys !== 'all' && selectedKeys.has('starred2') ? ( @@ -252,14 +247,11 @@ function InteractiveExamplesStory() { Starred Item 2 Click to star - - {/* Another disabled item */} Disabled Service Cannot select - {selectedKeys !== 'all' && selectedKeys.has('dynamic1') ? ( @@ -269,7 +261,6 @@ function InteractiveExamplesStory() { Dynamic Icon Click to activate - {selectedKeys !== 'all' && selectedKeys.has('controllable') ? ( @@ -278,8 +269,7 @@ function InteractiveExamplesStory() { )} Controllable External control available - - +
@@ -392,8 +382,8 @@ function FormAndLayoutStory() {
+ Save Preferences + + selectedKeys={selectedKeys}> Option 1 @@ -1017,191 +810,6 @@ describe('SelectBoxGroup', () => { const option2 = screen.getByRole('option', {name: 'Option 2'}); expect(option2).toHaveAttribute('aria-selected', 'true'); }); - - it('handles keyboard navigation with visibility controls', async () => { - const onSelectionChange = jest.fn(); - render( - - - Option 1 - - - Option 2 - - - ); - - const listbox = screen.getByRole('listbox'); - await act(async () => { - listbox.focus(); - }); - - // Navigate with arrow keys - await act(async () => { - await userEvent.keyboard('{ArrowDown}'); - }); - - // Select with space - the first option should be focused/selected by default - await act(async () => { - await userEvent.keyboard(' '); - }); - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - const receivedSelection = onSelectionChange.mock.calls[0][0]; - expect(Array.from(receivedSelection)).toEqual(['option1']); - }); - - it('maintains proper ARIA attributes with visibility controls', () => { - render( - {}} - selectedKeys={new Set(['option1'])} - isCheckboxHidden - isRequired - isInvalid - errorMessage="Test error" - label="ARIA with visibility test"> - - Option 1 - - - Option 2 - - - ); - - const listbox = screen.getByRole('listbox'); - expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); - - const options = screen.getAllByRole('option'); - expect(options[0]).toHaveAttribute('aria-selected', 'true'); - expect(options[1]).toHaveAttribute('aria-selected', 'false'); - - const errorMessage = screen.getByText('Test error'); - expect(listbox).toHaveAttribute('aria-describedby', errorMessage.id); - }); - - it('handles horizontal orientation with visibility controls', () => { - render( - {}} - selectedKeys={new Set(['option1'])} - orientation="horizontal" - isCheckboxHidden - label="Horizontal visibility test"> - - - Option 1 - Description - - - ); - - const option = screen.getByRole('option', {name: 'Option 1'}); - expect(option).toBeInTheDocument(); - - // Checkbox should be hidden due to group setting - const checkbox = option.querySelector('[aria-hidden="true"]'); - expect(checkbox).not.toBeInTheDocument(); - - expect(option.textContent).toContain('Option 1'); - expect(option.textContent).toContain('Description'); - }); - - it('validates children count with visibility controls', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - render( - {}} - selectedKeys={new Set()} - isCheckboxHidden - label="Too many children test"> - {Array.from({length: 12}, (_, i) => ( - - Option {i} - - ))} - - ); - - expect(consoleSpy).toHaveBeenCalledWith( - 'Invalid content. SelectBox cannot have more than 9 children.' - ); - - consoleSpy.mockRestore(); - }); - - it('handles complex slot combinations with visibility controls', () => { - render( - {}} - selectedKeys={new Set(['complex'])} - label="Complex slots test"> - - - Complex Item - With multiple slots -
Additional content
- More content -
-
- ); - - const option = screen.getByRole('option', {name: 'Complex Item'}); - expect(option).toBeInTheDocument(); - - // Should show checkbox since item is selected and not disabled - const checkbox = option.querySelector('[aria-hidden="true"]'); - expect(checkbox).toBeInTheDocument(); - - expect(option.textContent).toContain('Complex Item'); - expect(option.textContent).toContain('With multiple slots'); - expect(option.textContent).toContain('Additional content'); - expect(option.textContent).toContain('More content'); - }); - - it('handles rapid selection changes with visibility controls', async () => { - const onSelectionChange = jest.fn(); - render( - - - Option 1 - - - Option 2 - - - Option 3 - - - ); - - const options = screen.getAllByRole('option'); - - // Rapidly click multiple options - in single mode, only last selection matters - await userEvent.click(options[0]); - await userEvent.click(options[1]); - await userEvent.click(options[2]); - - expect(onSelectionChange).toHaveBeenCalledTimes(3); - - const finalSelection = onSelectionChange.mock.calls[2][0]; - expect(Array.from(finalSelection)).toEqual(['option3']); - }); }); }); From bd3d6b735b9bd6ecc48ca7b4d1f25c7e721051ad Mon Sep 17 00:00:00 2001 From: DPandyan Date: Fri, 1 Aug 2025 15:44:56 -0700 Subject: [PATCH 18/24] fixed group disabled checkbox issue --- packages/@react-spectrum/s2/src/SelectBox.tsx | 28 ++++------------- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 8 ++--- .../s2/stories/SelectBoxGroup.stories.tsx | 3 +- .../s2/test/SelectBoxGroup.test.tsx | 31 +++++++++++++++++++ 4 files changed, 42 insertions(+), 28 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 70fc8b46468..358539ff644 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -113,9 +113,9 @@ const selectBoxStyles = style({ } }, gap: { - default: 12, + default: 8, orientation: { - horizontal: 0 + horizontal: 8 } }, // Visual styling @@ -185,18 +185,8 @@ const illustrationContainer = style({ display: 'flex', alignItems: 'center', justifyContent: 'center', - size: { - S: 20, - M: 24, - L: 28, - XL: 32 - }, + minSize: 48, flexShrink: 0, - marginEnd: { - orientation: { - horizontal: 12 - } - }, color: { isDisabled: 'disabled' }, @@ -232,11 +222,6 @@ const textContainer = style({ }, width: '100%', minWidth: 0, - overflow: { - orientation: { - horizontal: 'hidden' - } - }, color: { default: 'neutral', isDisabled: 'disabled' @@ -270,12 +255,12 @@ const descriptionText = style({ horizontal: 'hidden' } }, - wordWrap: { + textOverflow: 'ellipsis', + whiteSpace: { orientation: { - horizontal: 'break-word' + horizontal: 'nowrap' } }, - font: 'ui', color: { default: 'neutral', isDisabled: 'disabled' @@ -299,7 +284,6 @@ const labelText = style({ horizontal: 'stretch' } }, - font: 'ui', fontWeight: { orientation: { horizontal: 'bold' diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index e79140f98b0..92ed7aebb87 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -155,16 +155,14 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p const childrenArray = React.Children.toArray(children).filter((x) => x); const disabledKeys = useMemo(() => { - if (isDisabled) { - return 'all'; - } - 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) { + + if (isDisabled || childElement.props?.isDisabled) { disabled.add(childValue); } } diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 6998dbdcea1..c15de1d1370 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -156,7 +156,8 @@ export const MultipleSelection: Story = { export const DisabledGroup: Story = { args: { isDisabled: true, - defaultSelectedKeys: new Set(['option1']) + defaultSelectedKeys: new Set(['option1']), + isCheckboxSelection: true }, render: (args) => ( diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 81f14bf1c06..8bfb7a0d6fc 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -111,6 +111,37 @@ describe('SelectBoxGroup', () => { const options = screen.getAllByRole('option'); expect(options.length).toBeGreaterThan(0); }); + + it('prevents interaction when group is disabled', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + const option2 = screen.getByRole('option', {name: 'Option 2'}); + + await userEvent.click(option1); + await userEvent.click(option2); + + expect(onSelectionChange).not.toHaveBeenCalled(); + + // Items should have disabled attributes + expect(option1).toHaveAttribute('aria-disabled', 'true'); + expect(option2).toHaveAttribute('aria-disabled', 'true'); + }); }); describe('Checkbox functionality', () => { From 85f7e21efd5a850011d8b65adec693a513757370 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Fri, 1 Aug 2025 16:28:49 -0700 Subject: [PATCH 19/24] pruned styles --- packages/@react-spectrum/s2/src/SelectBox.tsx | 43 ++++--------------- .../s2/stories/SelectBoxGroup.stories.tsx | 8 +--- 2 files changed, 9 insertions(+), 42 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 358539ff644..82258af866c 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -177,8 +177,7 @@ const contentContainer = style({ horizontal: '1 0 0' } }, - width: '100%', - minWidth: 0 + width: '100%' }, getAllowedOverrides()); const illustrationContainer = style({ @@ -229,38 +228,11 @@ const textContainer = style({ }, getAllowedOverrides()); const descriptionText = style({ - display: { - default: 'none', - orientation: { - horizontal: 'block' - } - }, - alignSelf: { - orientation: { - horizontal: 'stretch' - } - }, - width: { - orientation: { - horizontal: '100%' - } - }, - minWidth: { - orientation: { - horizontal: 0 - } - }, - overflow: { - orientation: { - horizontal: 'hidden' - } - }, - textOverflow: 'ellipsis', - whiteSpace: { - orientation: { - horizontal: 'nowrap' - } - }, + display: 'block', + alignSelf: 'stretch', + width: '100%', + minWidth: 0, + whiteSpace: 'normal', color: { default: 'neutral', isDisabled: 'disabled' @@ -271,6 +243,7 @@ const labelText = style({ display: 'block', width: '100%', overflow: 'hidden', + minWidth: 0, textAlign: { default: 'center', orientation: { @@ -387,7 +360,7 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele {textSlot}
- {!!descriptionSlot && ( + {!!descriptionSlot && orientation === 'horizontal' && (
{descriptionSlot} diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index c15de1d1370..577982e916b 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -608,9 +608,6 @@ export const WithCheckboxes: Story = { render: (args) => (
Checkboxes Enabled -

- When isCheckboxSelection is true, checkboxes appear on hover and when selected: -

@@ -639,7 +636,7 @@ export const WithCheckboxes: Story = { - Another very long cloud service provider name that will test ellipsis + Another extremely long text. This is a very long text that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. Another extremely long description text that will help us verify how the SelectBox handles text overflow in different scenarios and orientations, ensuring that the text behavior is consistent and user-friendly across all use cases. @@ -650,9 +647,6 @@ export const WithCheckboxes: Story = { Checkboxes Disabled (Default) -

- By default, isCheckboxSelection is false and no checkboxes appear: -

From bdee23ab0cd6deb304b96eedf4526b098ad457d2 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Sun, 3 Aug 2025 22:58:01 -0700 Subject: [PATCH 20/24] fixed label overflow, description still remaining --- packages/@react-spectrum/s2/src/SelectBox.tsx | 21 +++++++++++-------- .../s2/stories/SelectBoxGroup.stories.tsx | 5 +---- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx index 82258af866c..eee17474890 100644 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -177,7 +177,8 @@ const contentContainer = style({ horizontal: '1 0 0' } }, - width: '100%' + width: '100%', + overflow: 'hidden' }, getAllowedOverrides()); const illustrationContainer = style({ @@ -224,15 +225,19 @@ const textContainer = style({ color: { default: 'neutral', isDisabled: 'disabled' - } + }, }, getAllowedOverrides()); const descriptionText = style({ - display: 'block', - alignSelf: 'stretch', + alignSelf: 'stretch', width: '100%', minWidth: 0, - whiteSpace: 'normal', + overflow: 'hidden', + maxHeight: { + orientation: { + horizontal: 120, + } + }, color: { default: 'neutral', isDisabled: 'disabled' @@ -353,13 +358,11 @@ export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: Sele
)} - -
-
+
+
{textSlot}
- {!!descriptionSlot && orientation === 'horizontal' && (
diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 577982e916b..4ecb0bd97ba 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -388,9 +388,6 @@ function FormAndLayoutStory() {
Preferences: {submittedData.length > 0 ? submittedData.join(', ') : 'None selected'}
-
- Form data would be sent to server with name "preferences" -
)}
@@ -642,7 +639,7 @@ export const WithCheckboxes: Story = { Short - This description is intentionally very long to create a mixed layout where some boxes have short labels but long descriptions, which will help test how the grid layout handles boxes of different content lengths and whether they maintain consistent heights as expected. + This description is intentionally very long to create a mixed layout where some boxes have short labels but long descriptions, which will help test how the grid layout handles boxes of different content lengths and whether they maintain consistent heights as expected. This is a very long description that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. From 9ad81e627e96c3eeb4ce7e17fff60c6277744d30 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Wed, 6 Aug 2025 19:48:00 -0700 Subject: [PATCH 21/24] moved selectbox into same file, reworked grid, addressed various gh comments --- packages/@react-spectrum/s2/src/SelectBox.tsx | 379 ------------- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 489 +++++++++++------ packages/@react-spectrum/s2/src/index.ts | 6 +- .../s2/stories/SelectBoxGroup.stories.tsx | 486 ++++++++--------- .../s2/test/SelectBoxGroup.test.tsx | 502 ++++++++---------- 5 files changed, 802 insertions(+), 1060 deletions(-) delete mode 100644 packages/@react-spectrum/s2/src/SelectBox.tsx diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx deleted file mode 100644 index eee17474890..00000000000 --- a/packages/@react-spectrum/s2/src/SelectBox.tsx +++ /dev/null @@ -1,379 +0,0 @@ -/* - * 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 {box, iconStyles} from './Checkbox'; -import Checkmark from '../ui-icons/Checkmark'; -import {ContextValue} from 'react-aria-components'; -import {FocusableRef, FocusableRefValue} from '@react-types/shared'; -import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; -import {IllustrationContext} from '../src/Icon'; -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'; - -export interface SelectBoxProps extends StyleProps { - /** - * The value of the SelectBox. - */ - value: string, - /** - * The label for the element. - */ - children?: ReactNode, - /** - * Whether the SelectBox is disabled. - */ - isDisabled?: boolean -} - -export const SelectBoxSpectrumContext = createContext, FocusableRefValue>>(null); - -const selectBoxStyles = style({ - display: 'flex', - flexDirection: { - default: 'column', - orientation: { - horizontal: 'row' - } - }, - justifyContent: { - default: 'center', - orientation: { - horizontal: 'start' - } - }, - alignItems: 'center', - font: 'ui', - flexShrink: 0, - boxSizing: 'border-box', - overflow: 'hidden', - position: 'relative', - // Sizing - width: { - default: 170, - orientation: { - horizontal: 368 - } - }, - height: { - default: 170, - orientation: { - horizontal: '100%' - } - }, - minWidth: { - default: 144, - orientation: { - horizontal: 188 - } - }, - maxWidth: { - default: 170, - orientation: { - horizontal: 480 - } - }, - minHeight: { - default: 144, - orientation: { - horizontal: 80 - } - }, - maxHeight: { - default: 170, - orientation: { - horizontal: 240 - } - }, - // Spacing - padding: { - default: 24, - orientation: { - horizontal: 16 - } - }, - paddingStart: { - orientation: { - horizontal: 24 - } - }, - paddingEnd: { - orientation: { - horizontal: 32 - } - }, - gap: { - default: 8, - orientation: { - horizontal: 8 - } - }, - // Visual styling - borderRadius: 'lg', - backgroundColor: { - default: 'layer-2', - isDisabled: 'layer-1' - }, - color: { - isDisabled: 'disabled' - }, - boxShadow: { - default: 'emphasized', - isHovered: 'elevated', - isSelected: 'elevated', - forcedColors: 'none', - isDisabled: 'emphasized' - }, - borderWidth: 2, - borderStyle: 'solid', - borderColor: { - default: 'transparent', - isSelected: 'gray-900', - isFocusVisible: 'blue-900', - isDisabled: 'transparent' - }, - transition: 'default', - cursor: { - default: 'pointer', - isDisabled: 'default' - }, - outlineStyle: 'none' -}, getAllowedOverrides()); - -const contentContainer = style({ - display: 'flex', - flexDirection: { - default: 'column', - orientation: { - horizontal: 'row' - } - }, - justifyContent: 'center', - alignItems: 'center', - textAlign: { - default: 'center', - orientation: { - horizontal: 'start' - } - }, - gap: { - default: 8, - orientation: { - horizontal: 12 - } - }, - flex: { - orientation: { - horizontal: '1 0 0' - } - }, - width: '100%', - overflow: 'hidden' -}, getAllowedOverrides()); - -const illustrationContainer = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - minSize: 48, - flexShrink: 0, - color: { - isDisabled: 'disabled' - }, - opacity: { - isDisabled: 0.4 - } -}); - -const textContainer = style({ - display: 'flex', - flexDirection: { - orientation: { - horizontal: 'column' - } - }, - justifyContent: 'center', - alignItems: { - default: 'center', - orientation: { - horizontal: 'start' - } - }, - gap: { - default: 12, - orientation: { - horizontal: 2 - } - }, - flex: { - orientation: { - horizontal: '1 0 0' - } - }, - width: '100%', - minWidth: 0, - color: { - default: 'neutral', - isDisabled: 'disabled' - }, -}, getAllowedOverrides()); - -const descriptionText = style({ - alignSelf: 'stretch', - width: '100%', - minWidth: 0, - overflow: 'hidden', - maxHeight: { - orientation: { - horizontal: 120, - } - }, - color: { - default: 'neutral', - isDisabled: 'disabled' - } -}); - -const labelText = style({ - display: 'block', - width: '100%', - overflow: 'hidden', - minWidth: 0, - textAlign: { - default: 'center', - orientation: { - horizontal: 'start' - } - }, - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - alignSelf: { - orientation: { - horizontal: 'stretch' - } - }, - fontWeight: { - orientation: { - horizontal: 'bold' - } - }, - color: { - default: 'neutral', - isDisabled: 'disabled' - } -}); - - -const SelectBoxRenderPropsContext = createContext<{ - isHovered?: boolean, - isFocusVisible?: boolean, - isPressed?: boolean -}>({}); - -/** - * SelectBox components allow users to select options from a list. - * Works as content within a ListBoxItem. - */ -export const SelectBox = /*#__PURE__*/ forwardRef(function SelectBox(props: SelectBoxProps, ref: FocusableRef) { - [props, ref] = useSpectrumContextProps(props, ref, SelectBoxSpectrumContext); - let { - children, - value, - isDisabled: individualDisabled = false, - UNSAFE_style - } = props; - let divRef = useRef(null); - let domRef = useFocusableRef(ref, divRef); - - let contextValue = useContext(SelectBoxContext); - let { - orientation = 'vertical', - selectedKeys, - isDisabled: groupDisabled = false, - isCheckboxSelection = false - } = contextValue; - - let renderProps = useContext(SelectBoxRenderPropsContext); - - const size = 'M'; - const isDisabled = individualDisabled || groupDisabled; - const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); - - const childrenArray = React.Children.toArray(children); - const illustrationSlot = childrenArray.find((child: any) => child?.props?.slot === 'illustration'); - const textSlot = childrenArray.find((child: any) => child?.props?.slot === 'text'); - const descriptionSlot = childrenArray.find((child: any) => child?.props?.slot === 'description'); - const otherChildren = childrenArray.filter((child: any) => - !['illustration', 'text', 'description'].includes(child?.props?.slot) - ); - - return ( -
- {isCheckboxSelection && (isSelected || (!isDisabled && renderProps.isHovered)) && ( - - )} - {!!illustrationSlot && ( -
- - {illustrationSlot} - -
- )} -
-
-
- {textSlot} -
- {!!descriptionSlot && orientation === 'horizontal' && ( -
- {descriptionSlot} -
- )} -
-
- {otherChildren} -
- ); -}); - -export {SelectBoxRenderPropsContext}; diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 92ed7aebb87..517f40e814f 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -9,29 +9,29 @@ * governing permissions and limitations under the License. */ +import {AriaLabelingProps, DOMRef, DOMRefValue, Orientation, Selection} from '@react-types/shared'; +import {box, iconStyles} from './Checkbox'; +import Checkmark from '../ui-icons/Checkmark'; import { ContextValue, ListBox, - ListBoxItem + ListBoxItem, + ListBoxProps, + Provider } from 'react-aria-components'; -import {DOMRef, DOMRefValue, Orientation, Selection} 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, ReactElement, ReactNode, useEffect, useMemo} from 'react'; -import {SelectBoxRenderPropsContext} from './SelectBox'; -import {style} from '../style' with {type: 'macro'}; +import {IllustrationContext} from '../src/Icon'; +import React, {createContext, forwardRef, ReactNode, useContext, useEffect, useMemo} from 'react'; +import {TextContext} from './Content'; import {useControlledState} from '@react-stately/utils'; -import {useDOMRef} from '@react-spectrum/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface SelectBoxGroupProps extends StyleProps { +export interface SelectBoxGroupProps extends StyleProps, Omit, 'layout' | 'dragAndDropHooks'>, AriaLabelingProps{ /** * The SelectBox elements contained within the SelectBoxGroup. */ children: ReactNode, - /** - * Handler that is called when the selection changes. - */ - onSelectionChange?: (selection: Selection) => void, /** * The selection mode for the SelectBoxGroup. * @default 'single' @@ -45,11 +45,6 @@ export interface SelectBoxGroupProps extends StyleProps { * The initial selected keys in the collection (uncontrolled). */ defaultSelectedKeys?: Selection, - /** - * The axis the SelectBox elements should align with. - * @default 'vertical' - */ - orientation?: Orientation, /** * Number of columns to display the SelectBox elements in. * @default 2 @@ -68,32 +63,232 @@ export interface SelectBoxGroupProps extends StyleProps { * Whether to show selection checkboxes for all SelectBoxes. * @default false */ - isCheckboxSelection?: boolean, + showCheckbox?: boolean +} + +export interface SelectBoxProps extends StyleProps { /** - * The name of the form field for form submission. + * The value of the SelectBox. */ - name?: string, + value: string, /** - * Defines a string value that labels the SelectBoxGroup. + * The label for the element. */ - 'aria-label'?: string, + children?: ReactNode, /** - * Identifies the element (or elements) that labels the SelectBoxGroup. + * Whether the SelectBox is disabled. */ - 'aria-labelledby'?: string + isDisabled?: boolean } interface SelectBoxContextValue { allowMultiSelect?: boolean, orientation?: Orientation, isDisabled?: boolean, - isCheckboxSelection?: boolean, + showCheckbox?: boolean, selectedKeys?: Selection, onSelectionChange?: (keys: Selection) => void } export const SelectBoxContext = createContext({orientation: 'vertical'}); -export const SelectBoxGroupContext = createContext, DOMRefValue>>(null); +export const SelectBoxGroupContext = createContext>, DOMRefValue>>(null); + +const descriptionOnly = ':has([slot=description]):not(:has([slot=label]))'; +const labelOnly = ':has([slot=label]):not(:has([slot=description]))'; +const selectBoxStyles = style({ + ...focusRing(), + outlineOffset: { + isFocusVisible: -2 + }, + display: 'grid', + gridAutoRows: '1fr', + position: 'relative', + font: 'ui', + boxSizing: 'border-box', + overflow: 'hidden', + width: { + default: 170, + orientation: { + horizontal: 368 + } + }, + height: { + default: 170, + orientation: { + horizontal: '100%' + } + }, + minWidth: { + default: 144, + orientation: { + horizontal: 188 + } + }, + maxWidth: { + default: 170, + orientation: { + horizontal: 480 + } + }, + minHeight: { + default: 144, + orientation: { + horizontal: 80 + } + }, + maxHeight: { + default: 170, + orientation: { + horizontal: 240 + } + }, + padding: { + default: 24, + orientation: { + horizontal: 16 + } + }, + paddingStart: { + orientation: { + horizontal: 24 + } + }, + paddingEnd: { + orientation: { + horizontal: 32 + } + }, + gridTemplateAreas: { + orientation: { + vertical: [ + 'illustration', + '.', + 'label' + ], + horizontal: { + default: [ + 'illustration . label', + 'illustration . description' + ], + [descriptionOnly]: [ + 'illustration . description' + ], + [labelOnly]: [ + 'illustration . label' + ] + } + } + }, + gridTemplateRows: { + orientation: { + vertical: ['min-content', 8, 'min-content'], + horizontal: ['min-content', 'min-content'] + } + }, + gridTemplateColumns: { + orientation: { + horizontal: ['min-content', 12, '1fr'] + } + }, + alignContent: { + orientation: { + vertical: 'center' + } + }, + borderRadius: 'lg', + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isSelected: 'gray-900', + isDisabled: 'transparent' + }, + backgroundColor: { + default: 'layer-2', + isDisabled: 'layer-1' + }, + color: { + isDisabled: 'disabled' + }, + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isSelected: 'elevated', + forcedColors: 'none', + isDisabled: 'emphasized' + }, + borderWidth: 2, + transition: 'default', + cursor: { + default: 'pointer', + isDisabled: 'default' + } +}, getAllowedOverrides()); + +const illustrationContainer = style({ + gridArea: 'illustration', + alignSelf: 'center', + justifySelf: 'center', + minSize: 48, + color: { + isDisabled: 'disabled' + }, + opacity: { + isDisabled: 0.4 + } +}); + +const descriptionText = style({ + gridArea: 'description', + alignSelf: 'center', + display: { + default: 'block', + orientation: { + vertical: 'none' + } + }, + overflow: 'hidden', + textAlign: { + default: 'center', + orientation: { + horizontal: 'start' + } + }, + color: { + default: 'neutral', + isDisabled: 'disabled' + } +}); + +const labelText = style({ + gridArea: 'label', + alignSelf: 'center', + justifySelf: { + default: 'center', + orientation: { + horizontal: 'start' + } + }, + width: '100%', + overflow: 'hidden', + minWidth: 0, + textAlign: { + default: 'center', + orientation: { + horizontal: 'start' + } + }, + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + fontWeight: { + orientation: { + horizontal: 'bold' + } + }, + color: { + default: 'neutral', + isDisabled: 'disabled' + } +}); const gridStyles = style({ display: 'grid', @@ -107,26 +302,90 @@ const gridStyles = style({ } }, getAllowedOverrides()); -const containerStyles = style({ - display: 'flex', - flexDirection: 'column', - gap: 8 -}, getAllowedOverrides()); +/** + * SelectBox is a single selectable item in a SelectBoxGroup. + */ +export function SelectBox(props: SelectBoxProps): ReactNode { + let {children, value, isDisabled: individualDisabled = false, UNSAFE_style} = props; + + let { + orientation = 'vertical', + isDisabled: groupDisabled = false, + showCheckbox = false + } = useContext(SelectBoxContext); + + const size = 'M'; + const isDisabled = individualDisabled || groupDisabled; -interface FormIntegrationProps { - name?: string, - selectedKeys: Selection, - selectionMode: 'single' | 'multiple' + return ( + (props.UNSAFE_className || '') + selectBoxStyles({ + size, + orientation, + isDisabled: renderProps.isDisabled, + isSelected: renderProps.isSelected, + isHovered: renderProps.isHovered, + isFocusVisible: renderProps.isFocusVisible + }, props.styles)} + style={UNSAFE_style}> + {(renderProps) => ( + <> + {showCheckbox && (renderProps.isSelected || (!renderProps.isDisabled && renderProps.isHovered)) && ( + + )} + + {children} + + + )} + + ); } /** - * 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 ListBox for automatic grid-based keyboard navigation. + * SelectBoxGroup allows users to select one or more options from a list. */ -export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { +export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); - + let { children, onSelectionChange, @@ -137,15 +396,11 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p numColumns = 2, gutterWidth = 'default', isDisabled = false, - isCheckboxSelection = false, - name, - UNSAFE_style, - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby + showCheckbox = false, + UNSAFE_className, + UNSAFE_style } = props; - const domRef = useDOMRef(ref); - const [selectedKeys, setSelectedKeys] = useControlledState( controlledSelectedKeys, defaultSelectedKeys || new Set(), @@ -153,27 +408,9 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p ); const childrenArray = React.Children.toArray(children).filter((x) => x); - - const disabledKeys = useMemo(() => { - 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 (isDisabled || childElement.props?.isDisabled) { - disabled.add(childValue); - } - } - }); - - return disabled.size > 0 ? disabled : undefined; - }, [isDisabled, childrenArray]); - useEffect(() => { if (childrenArray.length > 9) { - console.error('Invalid content. SelectBoxGroup cannot have more than 9 children.'); + console.warn('Invalid content. SelectBoxGroup cannot have more than 9 children.'); } }, [childrenArray.length]); @@ -183,118 +420,32 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(p allowMultiSelect: selectionMode === 'multiple', orientation, isDisabled, - isCheckboxSelection, + showCheckbox, selectedKeys, onSelectionChange: setSelectedKeys }; return contextValue; }, - [selectionMode, orientation, isDisabled, isCheckboxSelection, selectedKeys, setSelectedKeys] + [selectionMode, orientation, isDisabled, showCheckbox, selectedKeys, setSelectedKeys] ); return ( -
- - - - - - {childrenArray.map((child, index) => { - if (!React.isValidElement(child)) {return null;} - - const childElement = child as ReactElement<{value?: string}>; - const childValue = childElement.props?.value || String(index); - - 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 || ''); - } - - const textContent = children - .filter((child: any) => typeof child === 'string') - .join(' '); - - return textContent || childValue; - }; - - const textValue = getTextValue(childElement); - - return ( - - {(renderProps) => ( - - - {child} - - - )} - - ); - })} - -
+ + + {children} + + ); }); - -function FormIntegration({name, selectedKeys, selectionMode}: FormIntegrationProps) { - if (!name) { - return null; - } - - const values = selectedKeys === 'all' ? [] : Array.from(selectedKeys).map(String); - - if (selectionMode === 'multiple') { - return ( - <> - {values.map((val, index) => ( - - ))} - {values.length === 0 && ( - - )} - - ); - } - return ( - - ); -} diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index 8efded57cf0..28924ec0645 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -72,8 +72,7 @@ export {RangeCalendar, RangeCalendarContext} from './RangeCalendar'; export {RangeSlider, RangeSliderContext} from './RangeSlider'; export {SearchField, SearchFieldContext} from './SearchField'; export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl'; -export {SelectBox} from './SelectBox'; -export {SelectBoxGroup, SelectBoxContext} from './SelectBoxGroup'; +export {SelectBox, SelectBoxContext, SelectBoxGroup} from './SelectBoxGroup'; export {Slider, SliderContext} from './Slider'; export {Skeleton, useIsSkeleton} from './Skeleton'; export {SkeletonCollection} from './SkeletonCollection'; @@ -150,8 +149,7 @@ export type {RadioProps} from './Radio'; export type {RadioGroupProps} from './RadioGroup'; export type {SearchFieldProps} from './SearchField'; export type {SegmentedControlProps, SegmentedControlItemProps} from './SegmentedControl'; -export type {SelectBoxProps} from './SelectBox'; -export type {SelectBoxGroupProps} from './SelectBoxGroup'; +export type {SelectBoxProps, SelectBoxGroupProps} 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/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 4ecb0bd97ba..e7575ab6a30 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -17,14 +17,44 @@ import {action} from '@storybook/addon-actions'; import AlertNotice from '../spectrum-illustrations/linear/AlertNotice'; -import {Button, Heading, SelectBox, SelectBoxGroup, Text} from '../src'; +import {Button, SelectBox, SelectBoxGroup, Text} from '../src'; import type {Meta, StoryObj} from '@storybook/react'; -import Paperairplane from '../spectrum-illustrations/linear/Paperairplane'; +import PaperAirplane from '../spectrum-illustrations/linear/Paperairplane'; import React, {useState} from 'react'; import type {Selection} from 'react-aria-components'; import Server from '../spectrum-illustrations/linear/Server'; import StarFilled1 from '../spectrum-illustrations/gradient/generic1/Star'; import StarFilled2 from '../spectrum-illustrations/gradient/generic2/Star'; +import {style} from '../style' with {type: 'macro'}; + +const headingStyles = style({ + font: 'heading', + margin: 0, + marginBottom: 16 +}); + +const subheadingStyles = style({ + font: 'heading', + fontSize: 'heading-lg', + margin: 0, + marginBottom: 16 +}); + +const sectionHeadingStyles = style({ + font: 'heading', + fontSize: 'heading-sm', + color: 'gray-600', + margin: 0, + marginBottom: 8 +}); + +const descriptionStyles = style({ + font: 'body', + fontSize: 'body-sm', + color: 'gray-600', + margin: 0, + marginBottom: 16 +}); const meta: Meta = { title: 'SelectBoxGroup', @@ -49,7 +79,7 @@ const meta: Meta = { control: 'select', options: ['compact', 'default', 'spacious'] }, - isCheckboxSelection: { + showCheckbox: { control: 'boolean' } }, @@ -59,7 +89,7 @@ const meta: Meta = { numColumns: 2, gutterWidth: 'default', isDisabled: false, - isCheckboxSelection: false + showCheckbox: false } }; @@ -70,20 +100,22 @@ export const Default: Story = { render: (args) => ( - - Amazon Web Services + + Amazon Web Services + Reliable cloud infrastructure - - Microsoft Azure + + Microsoft Azure - - Google Cloud Platform + + Google Cloud Platform - - IBM Cloud + + IBM Cloud + Hybrid cloud solutions ) @@ -93,7 +125,6 @@ export const MultipleSelection: Story = { args: { selectionMode: 'multiple', defaultSelectedKeys: new Set(['aws', 'gcp']), - necessityIndicator: 'icon', numColumns: 3, gutterWidth: 'default' }, @@ -104,48 +135,48 @@ export const MultipleSelection: Story = {

- - Amazon Web Services - Reliable cloud infrastructure + + Amazon Web Services + {/* Reliable cloud infrastructure */} - - Microsoft Azure + + Microsoft Azure Enterprise cloud solutions - - Google Cloud Platform + + Google Cloud Platform Modern cloud services - - Oracle Cloud + + Oracle Cloud Database-focused cloud - - IBM Cloud + + IBM Cloud Hybrid cloud solutions - - Alibaba Cloud + + Alibaba Cloud Asia-focused services - - DigitalOcean + + DigitalOcean Developer-friendly platform - - Linode + + Linode Simple cloud computing - - Vultr + + Vultr High performance cloud @@ -162,12 +193,12 @@ export const DisabledGroup: Story = { render: (args) => ( - - Selected then Disabled + + Selected then Disabled - - Disabled + + Disabled ) @@ -178,8 +209,8 @@ function InteractiveExamplesStory() { return (
- Interactive Features Combined -

+

Interactive Features Combined

+

Current selection: {selectedKeys === 'all' ? 'All' : Array.from(selectedKeys).join(', ') || 'None'}

@@ -192,70 +223,70 @@ function InteractiveExamplesStory() { setSelectedKeys(selection); action('onSelectionChange')(selection); }}> - {/* Enabled items with dynamic icons */} + {/* Enabled items with dynamic illustrations */} {selectedKeys !== 'all' && selectedKeys.has('enabled1') ? ( - + ) : ( - + )} - Enabled Item 1 + Enabled Item 1 Status updates {selectedKeys !== 'all' && selectedKeys.has('enabled2') ? ( - + ) : ( - + )} - Enabled Item 2 + Enabled Item 2 Click to toggle {/* Disabled item */} - - Disabled Item + + Disabled Item Cannot select {selectedKeys !== 'all' && selectedKeys.has('starred1') ? ( - + ) : ( - + )} - Starred Item 1 + Starred Item 1 Click to star {selectedKeys !== 'all' && selectedKeys.has('starred2') ? ( - + ) : ( - + )} - Starred Item 2 + Starred Item 2 Click to star - - Disabled Service + + Disabled Service Cannot select {selectedKeys !== 'all' && selectedKeys.has('dynamic1') ? ( - + ) : ( - + )} - Dynamic Icon + Dynamic Illustration Click to activate {selectedKeys !== 'all' && selectedKeys.has('controllable') ? ( - + ) : ( - + )} - Controllable + Controllable External control available @@ -285,180 +316,81 @@ export const InteractiveExamples: Story = { render: () => }; -function FormAndLayoutStory() { - const [selectedPreferences, setSelectedPreferences] = useState(new Set(['newsletter', 'security'])); - 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 ( -
- Form Integration with Multiple Columns -

- 8-item grid with form integration demonstrating both layout and submission capabilities. -

- -
- { - setSelectedPreferences(selection); - action('onSelectionChange')(selection); - }} - numColumns={4} - gutterWidth="spacious" - name="preferences"> - - - - Newsletter - Weekly updates and news - - - - - Marketing - Product promotions - - - - - Product News - Feature announcements - - - - - Security Alerts - Important updates - - - - - Events - Webinars & conferences - - - - - Surveys - Help us improve - - - - - Community - Forum notifications - - - - - Support - Help & assistance - - - - -
- - - - Selected: {selectedPreferences === 'all' ? 'All' : selectedPreferences.size} item{(selectedPreferences === 'all' || selectedPreferences.size !== 1) ? 's' : ''} - -
-
- - {submittedData && ( -
-
Form Submitted Successfully!
-
- Preferences: {submittedData.length > 0 ? submittedData.join(', ') : 'None selected'} -
-
- )} -
- ); -} - -export const FormAndLayout: Story = { - render: () => -}; - export const AllSlotCombinations: Story = { render: () => (
- All Slot Combinations +

All Slot Combinations

{/* Vertical Orientation */}
- Vertical Orientation +

Vertical Orientation

{/* Text Only */}
- Text Only +

Text Only

- Simple Text + Simple Text
- {/* Icon + Text */} + {/* Illustration + Text */}
- Icon + Text +

Illustration + Text

- - - With Icon + + + With Illustration
{/* Text + Description */}
- Text + Description +

Text + Description

- Main Text + Main Text Additional description
- {/* Icon + Text + Description */} + {/* Illustration + Description */} +
+

Illustration + Description

+ + + + Only description text + + +
+ + {/* Illustration + Text + Description */}
- Icon + Text + Description +

Illustration + Text + Description

- - Full Vertical + + Full Vertical Complete description @@ -469,60 +401,74 @@ export const AllSlotCombinations: Story = { {/* Horizontal Orientation */}
- Horizontal Orientation +

Horizontal Orientation

{/* Text Only */}
- Text Only (Optimized) +

Text Only (Optimized)

- Simple Horizontal Text + Simple Horizontal Text
- {/* Icon + Text */} + {/* Illustration + Text */}
- Icon + Text +

Illustration + Text

- - - Horizontal with Icon + + + Horizontal with Illustration
{/* Text + Description */}
- Text + Description +

Text + Description

- Main Horizontal Text + Main Horizontal Text Horizontal description text
- {/* Icon + Text + Description */} + {/* Illustration + Description */}
- Icon + Text + Description +

Illustration + Description

+ + + + Only horizontal description text + + +
+ + {/* Illustration + Text + Description */} +
+

Illustration + Text + Description

- - Complete Horizontal + + Complete Horizontal Full horizontal layout with all elements @@ -533,30 +479,35 @@ export const AllSlotCombinations: Story = { {/* Comparison Grid */}
- Side-by-Side Comparison +

Side-by-Side Comparison

{/* Vertical examples */} - V: Text Only + V: Text Only - - V: Icon + Text + + V: Illustration + Text - V: Text + Desc + V: Text + Desc Vertical description - - V: All Elements + + V: Illustration + Desc + + + + + V: All Elements Complete vertical @@ -565,28 +516,33 @@ export const AllSlotCombinations: Story = {
{/* Horizontal examples */} - H: Text Only + H: Text Only - - H: Icon + Text + + H: Illustration + Text - H: Text + Description + H: Text + Description Horizontal description - - H: All Elements + + H: Illustration + Desc + + + + + H: All Elements Complete horizontal layout @@ -604,58 +560,112 @@ export const WithCheckboxes: Story = { }, render: (args) => (
- Checkboxes Enabled +

Checkboxes Enabled

- - Amazon Web Services + + Amazon Web Services Reliable cloud infrastructure - - Microsoft Azure + + Microsoft Azure Enterprise cloud solutions - - Google Cloud Platform + + Google Cloud Platform Modern cloud services - - Oracle Cloud + + Oracle Cloud Database-focused cloud - - This is an extremely long service name that should definitely overflow and show ellipsis behavior + + This is an extremely long service name that should definitely overflow and show ellipsis behavior This is a very long description that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. - - Another extremely long text. This is a very long text that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. + + Another extremely long text. This is a very long text that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. Another extremely long description text that will help us verify how the SelectBox handles text overflow in different scenarios and orientations, ensuring that the text behavior is consistent and user-friendly across all use cases. - - Short + + Short This description is intentionally very long to create a mixed layout where some boxes have short labels but long descriptions, which will help test how the grid layout handles boxes of different content lengths and whether they maintain consistent heights as expected. This is a very long description that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. - Checkboxes Disabled (Default) +

Checkboxes Disabled (Default)

- - Amazon Web Services + + Amazon Web Services No checkbox visible - - Microsoft Azure + + Microsoft Azure No checkbox on hover
) }; + +export const TextSlots: Story = { + args: { + orientation: 'horizontal' + }, + render: (args) => ( +
+

Text Slots Example

+ + + + Amazon Web Services + Reliable cloud infrastructure + + + + Microsoft Azure + Enterprise cloud solutions + + + + Google Cloud Platform + Modern cloud services + + + + Oracle Cloud + Database-focused cloud + + +
+ ) +}; + +export const WithDescription: Story = { + args: { + orientation: 'horizontal' + }, + render: (args) => ( +
+

With Description

+ + + + Reliable cloud infrastructure + + + + Microsoft Azure + + +
+ ) +}; diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 8bfb7a0d6fc..947810ee18b 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -1,7 +1,7 @@ import {act, render, screen, waitFor} from '@react-spectrum/test-utils-internal'; -import {Button, SelectBox, SelectBoxGroup, Text} from '../src'; import Calendar from '../spectrum-illustrations/linear/Calendar'; import React from 'react'; +import {SelectBox, SelectBoxGroup, Text} from '../src'; import {Selection} from '@react-types/shared'; import userEvent from '@testing-library/user-event'; @@ -14,13 +14,13 @@ function SingleSelectBox() { onSelectionChange={setSelectedKeys} selectedKeys={selectedKeys}> - Option 1 + Option 1 - Option 2 + Option 2 - Option 3 + Option 3
); @@ -35,13 +35,13 @@ function MultiSelectBox() { onSelectionChange={setSelectedKeys} selectedKeys={selectedKeys}> - Option 1 + Option 1 - Option 2 + Option 2 - Option 3 + Option 3 ); @@ -56,10 +56,10 @@ function DisabledSelectBox() { selectedKeys={new Set()} isDisabled> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -122,10 +122,10 @@ describe('SelectBoxGroup', () => { selectedKeys={new Set()} isDisabled> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -152,12 +152,12 @@ describe('SelectBoxGroup', () => { selectionMode="single" onSelectionChange={() => {}} selectedKeys={new Set(['option1'])} - isCheckboxSelection> + showCheckbox> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -176,9 +176,9 @@ describe('SelectBoxGroup', () => { selectionMode="single" onSelectionChange={() => {}} selectedKeys={new Set()} - isCheckboxSelection> + showCheckbox> - Option 1 + Option 1 ); @@ -200,7 +200,7 @@ describe('SelectBoxGroup', () => { onSelectionChange={() => {}} selectedKeys={new Set(['option1'])}> - Option 1 + Option 1 ); @@ -218,9 +218,9 @@ describe('SelectBoxGroup', () => { selectionMode="single" onSelectionChange={() => {}} defaultSelectedKeys={new Set(['option1'])} - isCheckboxSelection> + showCheckbox> - Option 1 + Option 1 ); @@ -238,9 +238,9 @@ describe('SelectBoxGroup', () => { selectionMode="single" onSelectionChange={() => {}} selectedKeys={new Set()} - isCheckboxSelection> + showCheckbox> - Option 1 + Option 1 ); @@ -257,36 +257,61 @@ describe('SelectBoxGroup', () => { }); describe('Props and configuration', () => { - it('supports different sizes', () => { + it('supports different orientations', () => { render( {}} - selectedKeys={new Set()}> + selectedKeys={new Set()} + orientation="horizontal"> - Option 1 + Option 1 ); expect(screen.getByRole('listbox')).toBeInTheDocument(); }); - it('supports horizontal orientation', () => { + it('supports different gutter widths', () => { render( {}} selectedKeys={new Set()} - orientation="horizontal"> + gutterWidth="compact"> - Option 1 + Option 1 ); expect(screen.getByRole('listbox')).toBeInTheDocument(); }); + + it('supports custom number of columns', () => { + render( + {}} + selectedKeys={new Set()} + numColumns={3}> + + Option 1 + + + Option 2 + + + Option 3 + + + ); + + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveStyle('grid-template-columns: repeat(3, 1fr)'); + }); }); describe('Controlled behavior', () => { @@ -298,10 +323,10 @@ describe('SelectBoxGroup', () => { onSelectionChange={() => {}} selectedKeys={new Set(['option1'])}> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -321,13 +346,13 @@ describe('SelectBoxGroup', () => { onSelectionChange={() => {}} selectedKeys={new Set(['option1', 'option2'])}> - Option 1 + Option 1 - Option 2 + Option 2 - Option 3 + Option 3 ); @@ -350,10 +375,10 @@ describe('SelectBoxGroup', () => { onSelectionChange={onSelectionChange} selectedKeys={new Set()}> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -375,10 +400,10 @@ describe('SelectBoxGroup', () => { onSelectionChange={onSelectionChange} selectedKeys={new Set()}> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -390,182 +415,62 @@ describe('SelectBoxGroup', () => { const receivedSelection = onSelectionChange.mock.calls[0][0]; expect(Array.from(receivedSelection)).toEqual(['option1']); }); - }); - describe('Form integration', () => { - it('creates hidden inputs for form submission', () => { - const {container} = render( - {}} - selectedKeys={new Set(['option1', 'option2'])} - name="test-field"> - - 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('creates single hidden input for single selection', () => { - const {container} = render( - {}} - selectedKeys={new Set(['option1'])} - name="test-field"> - - Option 1 - - - ); - - const hiddenInput = container.querySelector('input[type="hidden"][name="test-field"]'); - 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); - }}> - {}} - selectedKeys={new Set(['option1', 'option3'])} - name="preferences"> - - Newsletter - - - Marketing - - - Updates - - - -
- ); - - const submitButton = screen.getByRole('button', {name: 'Submit Preferences'}); - expect(submitButton).toBeInTheDocument(); + it('handles controlled component updates', async () => { + function ControlledTest() { + const [selectedKeys, setSelectedKeys] = React.useState(new Set()); + + return ( +
+ + + + Option 1 + + + Option 2 + + +
+ ); + } - await userEvent.click(submitButton); + render(); - expect(onSubmit).toHaveBeenCalledWith(['option1', 'option3']); - }); - - it('creates hidden inputs for form submission in single mode', () => { - render( - - - Option 1 - - - Option 2 - - - Option 3 - - - ); - - const hiddenInput = screen.getByDisplayValue('option2'); - expect(hiddenInput).toBeInTheDocument(); - expect(hiddenInput).toHaveAttribute('type', 'hidden'); - expect(hiddenInput).toHaveAttribute('name', 'test-field'); - }); - - it('creates multiple hidden inputs for form submission in multiple mode', () => { - render( - - - Option 1 - - - Option 2 - - - Option 3 - - - ); - - const hiddenInputs = screen.getAllByDisplayValue(/option[13]/); - expect(hiddenInputs).toHaveLength(2); - expect(hiddenInputs[0]).toHaveAttribute('type', 'hidden'); - expect(hiddenInputs[0]).toHaveAttribute('name', 'test-field'); - expect(hiddenInputs[1]).toHaveAttribute('type', 'hidden'); - expect(hiddenInputs[1]).toHaveAttribute('name', 'test-field'); - }); - - it('creates empty hidden input when no options selected in multiple mode', () => { - render( - - - Option 1 - - - Option 2 - - - ); - - const hiddenInput = screen.getByDisplayValue(''); - expect(hiddenInput).toBeInTheDocument(); - expect(hiddenInput).toHaveAttribute('type', 'hidden'); - expect(hiddenInput).toHaveAttribute('name', 'test-field'); + const button = screen.getByRole('button', {name: 'Select Option 2'}); + await userEvent.click(button); + + const option2 = screen.getByRole('option', {name: 'Option 2'}); + expect(option2).toHaveAttribute('aria-selected', 'true'); }); - it('does not create hidden inputs when name prop is not provided', () => { + it('handles "all" selection', () => { render( - + {}} + selectedKeys="all"> - Option 1 + Option 1 - Option 2 + Option 2 ); - - const hiddenInputs = document.querySelectorAll('input[type="hidden"]'); - expect(hiddenInputs).toHaveLength(0); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + const option2 = screen.getByRole('option', {name: 'Option 2'}); + + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); }); }); @@ -578,16 +483,19 @@ describe('SelectBoxGroup', () => { onSelectionChange={() => {}} selectedKeys={new Set()}> - Option 1 + Option 1 - Option 2 + Option 2 ); const rows = screen.getAllByRole('option'); expect(rows.length).toBe(2); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + expect(option1).toHaveAttribute('aria-disabled', 'true'); }); it('prevents interaction with disabled items', async () => { @@ -599,7 +507,7 @@ describe('SelectBoxGroup', () => { onSelectionChange={onSelectionChange} selectedKeys={new Set()}> - Option 1 + Option 1 ); @@ -612,7 +520,7 @@ describe('SelectBoxGroup', () => { }); describe('Grid navigation', () => { - it('supports keyboard navigation', async () => { + it('supports keyboard navigation and grid layout', async () => { render( { selectedKeys={new Set()} numColumns={2}> - Option 1 + Option 1 - Option 2 + Option 2 - Option 3 + Option 3 - Option 4 + Option 4 ); @@ -658,7 +566,7 @@ describe('SelectBoxGroup', () => { onSelectionChange={onSelectionChange} selectedKeys={new Set()}> - Option 1 + Option 1 ); @@ -675,21 +583,52 @@ describe('SelectBoxGroup', () => { const receivedSelection = onSelectionChange.mock.calls[0][0]; expect(Array.from(receivedSelection)).toEqual(['option1']); }); + + it('supports arrow key navigation', async () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + Option 2 + + + ); + + const listbox = screen.getByRole('listbox'); + await act(async () => { + listbox.focus(); + }); + + // Navigate to second option + await userEvent.keyboard('{ArrowDown}'); + + // Check that navigation works by verifying an option has focus + const option1 = screen.getByRole('option', {name: 'Option 1'}); + expect(option1).toHaveFocus(); + }); }); describe('Children validation', () => { + let consoleSpy: jest.SpyInstance; + beforeEach(() => { - jest.spyOn(console, 'error').mockImplementation(() => {}); + consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { - (console.error as jest.Mock).mockRestore(); + consoleSpy.mockRestore(); }); it('validates maximum children', () => { const manyChildren = Array.from({length: 10}, (_, i) => ( - Option {i} + Option {i} )); @@ -703,10 +642,29 @@ describe('SelectBoxGroup', () => { ); - expect(console.error).toHaveBeenCalledWith( + expect(console.warn).toHaveBeenCalledWith( 'Invalid content. SelectBoxGroup cannot have more than 9 children.' ); }); + + it('does not warn with valid number of children', () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + Option 2 + + + ); + + expect(console.warn).not.toHaveBeenCalled(); + }); }); describe('Accessibility', () => { @@ -718,10 +676,10 @@ describe('SelectBoxGroup', () => { onSelectionChange={() => {}} selectedKeys={new Set()}> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -729,6 +687,28 @@ describe('SelectBoxGroup', () => { expect(screen.getByRole('listbox')).toBeInTheDocument(); expect(screen.getAllByRole('option')).toHaveLength(2); }); + + it('supports aria-label and aria-labelledby', () => { + render( +
+

My SelectBoxGroup

+ {}} + selectedKeys={new Set()}> + + Option 1 + + +
+ ); + + const listbox = screen.getByRole('listbox'); + // Just verify the listbox has an aria-labelledby attribute + expect(listbox).toHaveAttribute('aria-labelledby'); + expect(listbox.getAttribute('aria-labelledby')).toBeTruthy(); + }); }); describe('Edge cases', () => { @@ -742,7 +722,7 @@ describe('SelectBoxGroup', () => { orientation="horizontal"> - Complex Option + Complex Option With description @@ -752,51 +732,62 @@ describe('SelectBoxGroup', () => { expect(screen.getByText('With description')).toBeInTheDocument(); }); - it('handles empty string values', () => { + it('handles different value types', () => { render( {}} selectedKeys={new Set()}> - - Empty Value + + Option 1 + + + Option 2 ); - const row = screen.getByRole('option', {name: 'Empty Value'}); - expect(row).toBeInTheDocument(); + const option1 = screen.getByRole('option', {name: 'Option 1'}); + const option2 = screen.getByRole('option', {name: 'Option 2'}); + expect(option1).toBeInTheDocument(); + expect(option2).toBeInTheDocument(); }); - it('handles different gutter widths', () => { + it('handles empty children gracefully', () => { render( {}} - selectedKeys={new Set()} - gutterWidth="compact"> + selectedKeys={new Set()}> + {null} + {undefined} - Option 1 + Valid Option + {false} ); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(screen.getByText('Valid Option')).toBeInTheDocument(); }); - it('handles "all" selection', () => { + it('handles uncontrolled selection with defaultSelectedKeys', async () => { + const onSelectionChange = jest.fn(); render( {}} - selectedKeys="all"> + aria-label="Uncontrolled test" + selectionMode="single" + onSelectionChange={onSelectionChange} + defaultSelectedKeys={new Set(['option1'])}> - Option 1 + Option 1 - Option 2 + Option 2 ); @@ -805,41 +796,12 @@ describe('SelectBoxGroup', () => { const option2 = screen.getByRole('option', {name: 'Option 2'}); expect(option1).toHaveAttribute('aria-selected', 'true'); - expect(option2).toHaveAttribute('aria-selected', 'true'); - }); - - it('handles controlled component updates', async () => { - function ControlledTest() { - const [selectedKeys, setSelectedKeys] = React.useState(new Set()); - - return ( -
- - - - Option 1 - - - Option 2 - - -
- ); - } - - render(); - - const button = screen.getByRole('button', {name: 'Select Option 2'}); - await userEvent.click(button); + expect(option2).toHaveAttribute('aria-selected', 'false'); - const option2 = screen.getByRole('option', {name: 'Option 2'}); - expect(option2).toHaveAttribute('aria-selected', 'true'); + await userEvent.click(option2); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const receivedSelection = onSelectionChange.mock.calls[0][0]; + expect(Array.from(receivedSelection)).toEqual(['option2']); }); }); }); From 267cf7bf3c01e6e014d3b3e752631aead1a5acdc Mon Sep 17 00:00:00 2001 From: DPandyan Date: Tue, 12 Aug 2025 09:59:39 -0700 Subject: [PATCH 22/24] s2(SelectBoxGroup): center label in horizontal layout when only label is present --- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 35 ++++++----- .../s2/stories/SelectBoxGroup.stories.tsx | 62 ------------------- 2 files changed, 20 insertions(+), 77 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 517f40e814f..7b5e3d40167 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -9,7 +9,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, DOMRef, DOMRefValue, Orientation, Selection} from '@react-types/shared'; +import {DOMRef, DOMRefValue, Orientation, Selection, GlobalDOMAttributes} from '@react-types/shared'; import {box, iconStyles} from './Checkbox'; import Checkmark from '../ui-icons/Checkmark'; import { @@ -27,7 +27,7 @@ import {TextContext} from './Content'; import {useControlledState} from '@react-stately/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; -export interface SelectBoxGroupProps extends StyleProps, Omit, 'layout' | 'dragAndDropHooks'>, AriaLabelingProps{ +export interface SelectBoxGroupProps extends StyleProps, Omit, keyof GlobalDOMAttributes | 'layout' | 'dragAndDropHooks' | 'renderEmptyState' | 'dependencies' | 'items' | 'children' | 'selectionMode'>{ /** * The SelectBox elements contained within the SelectBoxGroup. */ @@ -95,6 +95,7 @@ export const SelectBoxGroupContext = createContext (props.UNSAFE_className || '') + selectBoxStyles({ size, orientation, - isDisabled: renderProps.isDisabled, - isSelected: renderProps.isSelected, - isHovered: renderProps.isHovered, - isFocusVisible: renderProps.isFocusVisible + ...renderProps }, props.styles)} style={UNSAFE_style}> {(renderProps) => ( @@ -407,13 +419,6 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup x); - useEffect(() => { - if (childrenArray.length > 9) { - console.warn('Invalid content. SelectBoxGroup cannot have more than 9 children.'); - } - }, [childrenArray.length]); - const selectBoxContextValue = useMemo( () => { const contextValue = { @@ -436,7 +441,7 @@ export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup ( -
-

Checkboxes Enabled

- - - - Amazon Web Services - Reliable cloud infrastructure - - - - Microsoft Azure - Enterprise cloud solutions - - - - Google Cloud Platform - Modern cloud services - - - - Oracle Cloud - Database-focused cloud - - - - This is an extremely long service name that should definitely overflow and show ellipsis behavior - This is a very long description that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. - - - - Another extremely long text. This is a very long text that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. - Another extremely long description text that will help us verify how the SelectBox handles text overflow in different scenarios and orientations, ensuring that the text behavior is consistent and user-friendly across all use cases. - - - - Short - This description is intentionally very long to create a mixed layout where some boxes have short labels but long descriptions, which will help test how the grid layout handles boxes of different content lengths and whether they maintain consistent heights as expected. This is a very long description that should test the text wrapping and overflow behavior in horizontal orientation. It should wrap naturally until it hits the container boundaries and then show appropriate overflow handling. - - - -

Checkboxes Disabled (Default)

- - - - Amazon Web Services - No checkbox visible - - - - Microsoft Azure - No checkbox on hover - - -
- ) -}; - export const TextSlots: Story = { args: { orientation: 'horizontal' From 4a6f6813dde2533ff1b73e88ace615cbd6f52aa1 Mon Sep 17 00:00:00 2001 From: DPandyan Date: Tue, 12 Aug 2025 11:02:28 -0700 Subject: [PATCH 23/24] s2(SelectBoxGroup): support [DEFAULT_SLOT] as label styles for text-only children --- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 20 +++++-------------- .../s2/stories/SelectBoxGroup.stories.tsx | 2 +- 2 files changed, 6 insertions(+), 16 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 7b5e3d40167..3b4d8780585 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -17,7 +17,8 @@ import { ListBox, ListBoxItem, ListBoxProps, - Provider + Provider, + DEFAULT_SLOT } from 'react-aria-components'; import {focusRing, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; @@ -93,7 +94,6 @@ interface SelectBoxContextValue { export const SelectBoxContext = createContext({orientation: 'vertical'}); export const SelectBoxGroupContext = createContext>, DOMRefValue>>(null); -const descriptionOnly = ':has([slot=description]):not(:has([slot=label]))'; const labelOnly = ':has([slot=label]):not(:has([slot=description]))'; const noIllustration = ':not(:has([slot=illustration]))'; const selectBoxStyles = style({ @@ -171,9 +171,6 @@ const selectBoxStyles = style({ 'illustration . label', 'illustration . description' ], - [descriptionOnly]: [ - 'illustration . description' - ], [labelOnly]: [ 'illustration . label' ] @@ -270,11 +267,6 @@ const labelText = style({ default: 'center', orientation: { horizontal: 'start' - }, - [labelOnly]: { - orientation: { - horizontal: 'center' - } } }, width: '100%', @@ -284,11 +276,6 @@ const labelText = style({ default: 'center', orientation: { horizontal: 'start' - }, - [labelOnly]: { - orientation: { - horizontal: 'center' - } } }, whiteSpace: 'nowrap', @@ -375,6 +362,9 @@ export function SelectBox(props: SelectBoxProps): ReactNode { }], [TextContext, { slots: { + [DEFAULT_SLOT]: { + styles: labelText({orientation, isDisabled: renderProps.isDisabled}) + }, label: { styles: labelText({orientation, isDisabled: renderProps.isDisabled}) }, diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index b88314656ab..9ba6bf88719 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -406,7 +406,7 @@ export const AllSlotCombinations: Story = { {/* Text Only */}
-

Text Only (Optimized)

+

Text Only

Date: Tue, 12 Aug 2025 13:15:35 -0700 Subject: [PATCH 24/24] removed extra story and count check --- .../@react-spectrum/s2/src/SelectBoxGroup.tsx | 10 ++++----- .../s2/stories/SelectBoxGroup.stories.tsx | 14 ------------ .../s2/test/SelectBoxGroup.test.tsx | 22 ------------------- 3 files changed, 5 insertions(+), 41 deletions(-) diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx index 3b4d8780585..d545624255b 100644 --- a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -9,21 +9,21 @@ * governing permissions and limitations under the License. */ -import {DOMRef, DOMRefValue, Orientation, Selection, GlobalDOMAttributes} from '@react-types/shared'; import {box, iconStyles} from './Checkbox'; import Checkmark from '../ui-icons/Checkmark'; import { ContextValue, + DEFAULT_SLOT, ListBox, ListBoxItem, ListBoxProps, - Provider, - DEFAULT_SLOT + Provider } from 'react-aria-components'; +import {DOMRef, DOMRefValue, GlobalDOMAttributes, Orientation, Selection} from '@react-types/shared'; import {focusRing, style} from '../style' with {type: 'macro'}; import {getAllowedOverrides, StyleProps} from './style-utils' with {type: 'macro'}; import {IllustrationContext} from '../src/Icon'; -import React, {createContext, forwardRef, ReactNode, useContext, useEffect, useMemo} from 'react'; +import React, {createContext, forwardRef, ReactNode, useContext, useMemo} from 'react'; import {TextContext} from './Content'; import {useControlledState} from '@react-stately/utils'; import {useSpectrumContextProps} from './useSpectrumContextProps'; @@ -182,7 +182,7 @@ const selectBoxStyles = style({ vertical: ['min-content', 8, 'min-content'], horizontal: { default: ['min-content', 'min-content'], - [noIllustration]: ['min-content'], + [noIllustration]: ['min-content'] } } }, diff --git a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx index 9ba6bf88719..8babd315516 100644 --- a/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -445,20 +445,6 @@ export const AllSlotCombinations: Story = {
- {/* Illustration + Description */} -
-

Illustration + Description

- - - - Only horizontal description text - - -
- {/* Illustration + Text + Description */}

Illustration + Text + Description

diff --git a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx index 947810ee18b..8100384d039 100644 --- a/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -625,28 +625,6 @@ describe('SelectBoxGroup', () => { consoleSpy.mockRestore(); }); - it('validates maximum children', () => { - const manyChildren = Array.from({length: 10}, (_, i) => ( - - Option {i} - - )); - - render( - {}} - selectedKeys={new Set()}> - {manyChildren} - - ); - - expect(console.warn).toHaveBeenCalledWith( - 'Invalid content. SelectBoxGroup cannot have more than 9 children.' - ); - }); - it('does not warn with valid number of children', () => { render(