diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx new file mode 100644 index 00000000000..de1b3f1a496 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -0,0 +1,306 @@ +/* + * 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 {Checkbox} from './Checkbox'; +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'; + +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 SelectBoxItemContext = createContext(null); + +const selectBoxStyles = style({ + display: 'flex', + flexDirection: { + default: 'column', + orientation: { + horizontal: 'row' + } + }, + lineHeight: 'title', + justifyContent: 'center', + flexShrink: 0, + alignItems: 'center', + font: 'ui', + width: { + default: { + size: { + S: 128, + M: 136, + L: 160, + XL: 192 + } + }, + orientation: { + horizontal: 'auto' + } + }, + height: { + default: { + size: { + S: 128, + M: 136, + L: 160, + XL: 192 + } + }, + orientation: { + horizontal: 'auto' + } + }, + minWidth: { + orientation: { + horizontal: 160 + } + }, + maxWidth: { + orientation: { + horizontal: 272 + } + }, + padding: { + size: { + S: 16, + M: 20, + L: 24, + XL: 28 + } + }, + borderRadius: 'lg', + backgroundColor: { + default: 'layer-2', + isSelected: 'layer-2', + isDisabled: 'layer-1' + }, + color: { + isEmphasized: 'gray-900', + isDisabled: 'disabled' + }, + boxShadow: { + default: 'emphasized', + isHovered: 'elevated', + isSelected: 'elevated', + forcedColors: 'none', + isDisabled: 'emphasized' + }, + outlineStyle: 'none', + position: 'relative', + borderWidth: 2, + borderStyle: 'solid', + borderColor: { + default: 'transparent', + isSelected: 'gray-900', + isFocusVisible: 'blue-900', + isDisabled: 'transparent' + }, + transition: 'default', + gap: { + orientation: { + horizontal: 'text-to-visual' + } + }, + cursor: { + default: 'pointer', + isDisabled: 'default' + } +}, getAllowedOverrides()); + +const contentContainer = style({ + display: 'flex', + flexDirection: 'column', + alignItems: { + default: 'center', + orientation: { + horizontal: 'start' + } + }, + justifyContent: 'center', + textAlign: { + default: 'center', + orientation: { + horizontal: 'start' + } + }, + flex: { + orientation: { + horizontal: 1 + } + } +}, getAllowedOverrides()); + +const iconContainer = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + size: { + XS: 16, + S: 20, + M: 24, + L: 28, + XL: 32 + }, + flexShrink: 0, + color: { + isDisabled: 'disabled' + }, + opacity: { + isDisabled: 0.4 + } +}); + +const textContainer = style({ + display: 'flex', + flexDirection: 'column', + alignItems: { + default: 'center', + orientation: { + horizontal: 'start' + } + }, + gap: 'text-to-visual', + color: { + isDisabled: 'disabled' + } +}, getAllowedOverrides()); + +const descriptionText = style({ + display: { + default: 'none', + orientation: { + horizontal: 'block' + } + }, + font: 'ui-sm', + color: { + default: 'gray-600', + isDisabled: 'disabled' + }, + lineHeight: 'body' +}); + + +const SelectBoxRenderPropsContext = createContext<{ + isHovered?: boolean, + isFocusVisible?: boolean, + isPressed?: boolean +}>({}); + +/** + * SelectBox components allow users to select options from a list. + * 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); + 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 + } = contextValue; + + let renderProps = useContext(SelectBoxRenderPropsContext); + + const isDisabled = individualDisabled || groupDisabled; + const isSelected = selectedKeys === 'all' || (selectedKeys && selectedKeys.has(value)); + const showCheckbox = isSelected || (!isDisabled && renderProps.isHovered); + + return ( +
+ + {showCheckbox && ( +
+ +
+ )} + {orientation === 'horizontal' ? ( + <> + {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')} + + {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description') && ( +
+ {React.Children.toArray(children).find((child: any) => child?.props?.slot === 'description')} +
+ )} +
+
+ + ) : ( + <> + {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')} +
+ + )} + + {React.Children.toArray(children).filter((child: any) => + !['icon', 'text', 'description'].includes(child?.props?.slot) + )} +
+ ); +}); + +export {SelectBoxRenderPropsContext}; diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx new file mode 100644 index 00000000000..efc5346ac45 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -0,0 +1,405 @@ +/* + * 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 { + ContextValue, + GridList, + GridListItem, + 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'; +import {style} from '../style' with {type: 'macro'}; +import {useControlledState} from '@react-stately/utils'; +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, + /** + * 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, + /** + * 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, + /** + * Contextual help text for the SelectBoxGroup. + */ + contextualHelp?: ReactNode +} + +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' +}); + +export const SelectBoxGroupContext = createContext, DOMRefValue>>(null); + +const gridStyles = style({ + display: 'grid', + gridAutoRows: '1fr', + gap: { + gutterWidth: { + default: 16, + compact: 8, + spacious: 24 + } + }, + '&[role="grid"]': { + display: 'grid' + } +}, getAllowedOverrides()); + +const containerStyles = style({ + display: 'flex', + flexDirection: 'column', + gap: 8 +}, getAllowedOverrides()); + +const errorMessageStyles = style({ + color: 'negative', + font: 'ui-sm' +}, getAllowedOverrides()); + +interface FormIntegrationProps { + name?: string, + value: SelectBoxValueType, + 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. + */ +export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); + + let { + label, + contextualHelp, + children, + onSelectionChange, + defaultValue, + selectionMode = 'single', + size = 'M', + orientation = 'vertical', + numColumns = 2, + gutterWidth = 'default', + isRequired = false, + isDisabled = false, + name, + errorMessage, + isInvalid = false, + UNSAFE_style + } = props; + + const domRef = useDOMRef(ref); + const gridId = useId(); + 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); + } + ); + + + 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; + + 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) { + disabled.add(childValue); + } + } + }); + + return disabled.size > 0 ? disabled : undefined; + }, [isDisabled, childrenArray]); + + 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.'); + } + }, [childrenArray.length]); + + const selectBoxContextValue = useMemo( + () => { + const contextValue = { + allowMultiSelect: selectionMode === 'multiple', + size, + orientation, + isDisabled, + selectedKeys, + onSelectionChange: setSelectedKeys + }; + return contextValue; + }, + [selectionMode, size, orientation, isDisabled, selectedKeys, setSelectedKeys] + ); + + const currentValue = convertSelectionToValue(selectedKeys, selectionMode); + + return ( +
+ + + + {label && ( + + {label} + + )} + + {} : 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)` + }}> + + {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} + + + )} + + ); + })} + + + {hasValidationErrors && errorMessage && ( + + {errorMessage} + + )} +
+ ); +}); diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index ab01656ff77..efa16c2fe0a 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -72,6 +72,8 @@ 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 {Slider, SliderContext} from './Slider'; export {Skeleton, useIsSkeleton} from './Skeleton'; export {SkeletonCollection} from './SkeletonCollection'; @@ -148,6 +150,8 @@ 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, 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/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx new file mode 100644 index 00000000000..af6603067ec --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -0,0 +1,415 @@ +/************************************************************************* + * 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 AlertNotice from '../spectrum-illustrations/linear/AlertNotice'; +import {Button, createIcon, SelectBox, SelectBoxGroup, Text} from '../src'; +import type {Meta, StoryObj} from '@storybook/react'; +import Paperairplane from '../spectrum-illustrations/linear/Paperairplane'; +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'; + +const StarIcon = createIcon(StarSVG); +const StarFilledIcon = createIcon(StarFilledSVG); + +const meta: Meta = { + title: 'SelectBoxGroup', + component: SelectBoxGroup, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + selectionMode: { + control: 'select', + options: ['single', 'multiple'] + }, + size: { + control: 'select', + options: ['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 + } +}; + +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', + 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: +

+ + + Item 1 + + + Item 2 + + + Item 3 + + + Item 4 + + + Item 5 + + + Item 6 + + +
+ ) +}; + +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: { + label: 'Disabled Group', + isDisabled: true, + defaultValue: 'option1' + }, + render: (args) => ( + + + + Selected then Disabled + + + + Disabled + + + ) +}; + +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'); + + return ( +
+

Current value: {value}

+ setValue(val as string)}> + + Option 1 + + + Option 2 + + + Option 3 + + + + +
+ ); +} + +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) ? ( + + ) : ( + + )} + Item {value.slice(-1)} + + ))} + + ); +} + +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} + + ))} + +
+ ) +}; + +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 new file mode 100644 index 00000000000..66451d74b8c --- /dev/null +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -0,0 +1,744 @@ +import {act, render, screen, waitFor} from '@testing-library/react'; +import {Button, Text} from '../src'; +import React from 'react'; +import {SelectBox} from '../src/SelectBox'; +import {SelectBoxGroup} from '../src/SelectBoxGroup'; +import userEvent from '@testing-library/user-event'; + +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 ( + {}} + value="" + isDisabled + label="Disabled select test"> + + Option 1 + + + Option 2 + + + ); +} + +describe('SelectBoxGroup', () => { + describe('Basic functionality', () => { + it('renders as a grid with rows', () => { + render(); + expect(screen.getByRole('grid', {name: 'Single select test'})).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(3); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('renders multiple selection mode', () => { + render(); + 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 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).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + }); + + it('handles disabled state', () => { + render(); + const grid = screen.getByRole('grid'); + expect(grid).toBeInTheDocument(); + + const rows = screen.getAllByRole('row'); + expect(rows.length).toBeGreaterThan(0); + }); + }); + + describe('Visual checkbox indicators', () => { + it('shows checkbox when item is selected', async () => { + render( + {}} + value="option1" + label="Checkbox test"> + + Option 1 + + + Option 2 + + + ); + + 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('shows checkbox on hover for non-disabled items', async () => { + render( + {}} + value="" + label="Hover test"> + + Option 1 + + + ); + + 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('does not show checkbox on hover for disabled items', async () => { + render( + {}} + value="" + label="Disabled hover test"> + + Option 1 + + + ); + + 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('shows checkbox for disabled but selected items', () => { + render( + {}} + defaultValue="option1" + label="Disabled selected test"> + + Option 1 + + + ); + + const row = screen.getByRole('row', {name: 'Option 1'}); + + 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 + + + ); + const grid = screen.getByRole('grid', {name: 'Required test'}); + expect(grid).toBeInTheDocument(); + expect(screen.getByText('Required test')).toBeInTheDocument(); + }); + + it('supports error message and validation', () => { + render( + {}} + value="" + isInvalid + errorMessage="Please select an option" + label="Validation test"> + + Option 1 + + + ); + 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 + + + ); + + 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', () => { + render( + {}} + value={['option1', 'option2']} + label="Multiple initial test"> + + Option 1 + + + Option 2 + + + Option 3 + + + ); + + 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).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'true'); + expect(option3).toHaveAttribute('aria-selected', 'false'); + }); + + it('calls onSelectionChange when selection changes', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('row', {name: 'Option 1'}); + await userEvent.click(option1); + + expect(onSelectionChange).toHaveBeenCalledWith('option1'); + }); + + it('calls onSelectionChange with array for multiple selection', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('row', {name: 'Option 1'}); + await userEvent.click(option1); + + 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('creates single hidden input for single selection', () => { + const {container} = render( + {}} + value="option1" + name="test-field" + label="Single form test"> + + 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); + }}> + {}} + 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', () => { + it('handles disabled individual items', () => { + render( + {}} + 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); + + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + }); + + describe('Grid navigation', () => { + it('supports keyboard navigation', async () => { + render( + {}} + 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 grid = screen.getByRole('grid'); + await act(async () => { + grid.focus(); + }); + + await act(async () => { + await userEvent.keyboard(' '); + }); + expect(onSelectionChange).toHaveBeenCalledWith('option1'); + }); + }); + + describe('Children validation', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + }); + + it('validates minimum children', () => { + render( + {}} value="" label="Min children test"> + {[]} + + ); + + expect(console.error).toHaveBeenCalledWith( + 'Invalid content. SelectBox must have at least one item.' + ); + }); + + it('validates maximum children', () => { + const manyChildren = Array.from({length: 10}, (_, i) => ( + + Option {i} + + )); + + render( + {}} value="" label="Max children test"> + {manyChildren} + + ); + + expect(console.error).toHaveBeenCalledWith( + 'Invalid content. SelectBox cannot have more than 9 children.' + ); + }); + }); + + describe('Accessibility', () => { + it('has proper grid structure', () => { + render( + {}} + value="" + label="ARIA test"> + + Option 1 + + + Option 2 + + + ); + + expect(screen.getByRole('grid', {name: 'ARIA test'})).toBeInTheDocument(); + expect(screen.getAllByRole('row')).toHaveLength(2); + expect(screen.getAllByRole('gridcell')).toHaveLength(2); + }); + + it('associates labels correctly', () => { + render( + {}} + value="" + label="Choose option"> + + Option 1 + + + ); + + const grid = screen.getByRole('grid', {name: 'Choose option'}); + expect(grid).toBeInTheDocument(); + }); + + it('supports aria-describedby for error messages', () => { + render( + {}} + value="" + isInvalid + errorMessage="Error occurred" + label="Error test"> + + Option 1 + + + ); + + const grid = screen.getByRole('grid'); + const errorMessage = screen.getByText('Error occurred'); + + expect(grid).toHaveAttribute('aria-describedby'); + expect(errorMessage).toBeInTheDocument(); + }); + }); + + describe('Edge cases', () => { + it('handles complex children with slots', () => { + render( + {}} + value="" + orientation="horizontal" + label="Complex children test"> + +
Icon
+ Complex Option + With description +
+
+ ); + + expect(screen.getByText('Complex Option')).toBeInTheDocument(); + expect(screen.getByText('With description')).toBeInTheDocument(); + }); + + it('handles empty string values', () => { + render( + {}} + value="" + label="Empty value test"> + + Empty Value + + + ); + + 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 + + + ); + expect(screen.getByRole('grid', {name: 'Gutter test'})).toBeInTheDocument(); + }); + + it('handles emphasized style', () => { + render( + {}} + value="" + label="Emphasized test"> + + Option 1 + + + ); + expect(screen.getByRole('grid', {name: 'Emphasized test'})).toBeInTheDocument(); + }); + }); +}); + +