diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx new file mode 100644 index 00000000000..d545624255b --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -0,0 +1,446 @@ +/* + * 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, + DEFAULT_SLOT, + ListBox, + ListBoxItem, + ListBoxProps, + 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, useMemo} from 'react'; +import {TextContext} from './Content'; +import {useControlledState} from '@react-stately/utils'; +import {useSpectrumContextProps} from './useSpectrumContextProps'; + +export interface SelectBoxGroupProps extends StyleProps, Omit, keyof GlobalDOMAttributes | 'layout' | 'dragAndDropHooks' | 'renderEmptyState' | 'dependencies' | 'items' | 'children' | 'selectionMode'>{ + /** + * The SelectBox elements contained within the SelectBoxGroup. + */ + children: ReactNode, + /** + * The selection mode for the SelectBoxGroup. + * @default 'single' + */ + selectionMode?: 'single' | 'multiple', + /** + * The currently selected keys in the collection (controlled). + */ + selectedKeys?: Selection, + /** + * The initial selected keys in the collection (uncontrolled). + */ + defaultSelectedKeys?: Selection, + /** + * 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 disabled. + */ + isDisabled?: boolean, + /** + * Whether to show selection checkboxes for all SelectBoxes. + * @default false + */ + showCheckbox?: boolean +} + +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 +} + +interface SelectBoxContextValue { + allowMultiSelect?: boolean, + orientation?: Orientation, + isDisabled?: boolean, + showCheckbox?: boolean, + selectedKeys?: Selection, + onSelectionChange?: (keys: Selection) => void +} + +export const SelectBoxContext = createContext({orientation: 'vertical'}); +export const SelectBoxGroupContext = createContext>, DOMRefValue>>(null); + +const labelOnly = ':has([slot=label]):not(:has([slot=description]))'; +const noIllustration = ':not(:has([slot=illustration]))'; +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' + ], + [labelOnly]: [ + 'illustration . label' + ] + } + } + }, + gridTemplateRows: { + orientation: { + vertical: ['min-content', 8, 'min-content'], + horizontal: { + default: ['min-content', 'min-content'], + [noIllustration]: ['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', + outline: 'none', + gridAutoRows: '1fr', + gap: { + gutterWidth: { + default: 16, + compact: 8, + spacious: 24 + } + } +}, 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; + + return ( + (props.UNSAFE_className || '') + selectBoxStyles({ + size, + orientation, + ...renderProps + }, props.styles)} + style={UNSAFE_style}> + {(renderProps) => ( + <> + {showCheckbox && (renderProps.isSelected || (!renderProps.isDisabled && renderProps.isHovered)) && ( + + )} + + {children} + + + )} + + ); +} + +/** + * SelectBoxGroup allows users to select one or more options from a list. + */ +export const SelectBoxGroup = /*#__PURE__*/ forwardRef(function SelectBoxGroup(props: SelectBoxGroupProps, ref: DOMRef) { + [props, ref] = useSpectrumContextProps(props, ref, SelectBoxGroupContext); + + let { + children, + onSelectionChange, + selectedKeys: controlledSelectedKeys, + defaultSelectedKeys, + selectionMode = 'single', + orientation = 'vertical', + numColumns = 2, + gutterWidth = 'default', + isDisabled = false, + showCheckbox = false, + UNSAFE_className, + UNSAFE_style + } = props; + + const [selectedKeys, setSelectedKeys] = useControlledState( + controlledSelectedKeys, + defaultSelectedKeys || new Set(), + onSelectionChange + ); + + const selectBoxContextValue = useMemo( + () => { + const contextValue = { + allowMultiSelect: selectionMode === 'multiple', + orientation, + isDisabled, + showCheckbox, + selectedKeys, + onSelectionChange: setSelectedKeys + }; + + return contextValue; + }, + [selectionMode, orientation, isDisabled, showCheckbox, selectedKeys, setSelectedKeys] + ); + + return ( + + + {children} + + + ); +}); diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index ab01656ff77..28924ec0645 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -72,6 +72,7 @@ export {RangeCalendar, RangeCalendarContext} from './RangeCalendar'; export {RangeSlider, RangeSliderContext} from './RangeSlider'; export {SearchField, SearchFieldContext} from './SearchField'; export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl'; +export {SelectBox, SelectBoxContext, SelectBoxGroup} from './SelectBoxGroup'; export {Slider, SliderContext} from './Slider'; export {Skeleton, useIsSkeleton} from './Skeleton'; export {SkeletonCollection} from './SkeletonCollection'; @@ -148,6 +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, 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 new file mode 100644 index 00000000000..8babd315516 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -0,0 +1,595 @@ +/************************************************************************* + * 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, 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 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', + component: SelectBoxGroup, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + selectionMode: { + control: 'select', + options: ['single', 'multiple'] + }, + orientation: { + control: 'select', + options: ['vertical', 'horizontal'] + }, + numColumns: { + control: {type: 'number', min: 1, max: 4} + }, + gutterWidth: { + control: 'select', + options: ['compact', 'default', 'spacious'] + }, + showCheckbox: { + control: 'boolean' + } + }, + args: { + selectionMode: 'single', + orientation: 'vertical', + numColumns: 2, + gutterWidth: 'default', + isDisabled: false, + showCheckbox: false + } +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + Amazon Web Services + Reliable cloud infrastructure + + + + Microsoft Azure + + + + Google Cloud Platform + + + + IBM Cloud + Hybrid cloud solutions + + + ) +}; + +export const MultipleSelection: Story = { + args: { + selectionMode: 'multiple', + defaultSelectedKeys: new Set(['aws', 'gcp']), + numColumns: 3, + gutterWidth: 'default' + }, + render: (args) => ( +
+

+ Focus any item and use arrow keys for grid navigation: +

+ + + + Amazon Web Services + {/* Reliable cloud infrastructure */} + + + + Microsoft Azure + Enterprise cloud solutions + + + + Google Cloud Platform + Modern cloud services + + + + Oracle Cloud + Database-focused cloud + + + + IBM Cloud + Hybrid cloud solutions + + + + Alibaba Cloud + Asia-focused services + + + + DigitalOcean + Developer-friendly platform + + + + Linode + Simple cloud computing + + + + Vultr + High performance cloud + + +
+ ) +}; + +export const DisabledGroup: Story = { + args: { + isDisabled: true, + defaultSelectedKeys: new Set(['option1']), + isCheckboxSelection: true + }, + render: (args) => ( + + + + Selected then Disabled + + + + Disabled + + + ) +}; + +function InteractiveExamplesStory() { + const [selectedKeys, setSelectedKeys] = useState(new Set(['enabled1', 'starred2'])); + + return ( +
+

Interactive Features Combined

+

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

+ + { + setSelectedKeys(selection); + action('onSelectionChange')(selection); + }}> + {/* Enabled items with dynamic illustrations */} + + {selectedKeys !== 'all' && selectedKeys.has('enabled1') ? ( + + ) : ( + + )} + Enabled Item 1 + Status updates + + + {selectedKeys !== 'all' && selectedKeys.has('enabled2') ? ( + + ) : ( + + )} + Enabled Item 2 + Click to toggle + + {/* Disabled item */} + + + Disabled Item + Cannot select + + + {selectedKeys !== 'all' && selectedKeys.has('starred1') ? ( + + ) : ( + + )} + Starred Item 1 + Click to star + + + {selectedKeys !== 'all' && selectedKeys.has('starred2') ? ( + + ) : ( + + )} + Starred Item 2 + Click to star + + + + Disabled Service + Cannot select + + + {selectedKeys !== 'all' && selectedKeys.has('dynamic1') ? ( + + ) : ( + + )} + Dynamic Illustration + Click to activate + + + {selectedKeys !== 'all' && selectedKeys.has('controllable') ? ( + + ) : ( + + )} + Controllable + External control available + + + +
+ + + +
+
+ ); +} + +export const InteractiveExamples: Story = { + render: () => +}; + +export const AllSlotCombinations: Story = { + render: () => ( +
+

All Slot Combinations

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

Vertical Orientation

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

Text Only

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

Illustration + Text

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

Text + Description

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

Illustration + Description

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

Illustration + Text + Description

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

Horizontal Orientation

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

Text Only

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

Illustration + Text

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

Text + Description

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

Illustration + Text + Description

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

Side-by-Side Comparison

+ + + {/* Vertical examples */} + + V: Text Only + + + + + V: Illustration + Text + + + + V: Text + Desc + Vertical description + + + + + V: Illustration + Desc + + + + + V: All Elements + Complete vertical + + + + +
+ + + {/* Horizontal examples */} + + H: Text Only + + + + + H: Illustration + Text + + + + H: Text + Description + Horizontal description + + + + + H: Illustration + Desc + + + + + H: All Elements + Complete horizontal layout + + + +
+
+ +
+ ) +}; + +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 new file mode 100644 index 00000000000..8100384d039 --- /dev/null +++ b/packages/@react-spectrum/s2/test/SelectBoxGroup.test.tsx @@ -0,0 +1,787 @@ +import {act, render, screen, waitFor} from '@react-spectrum/test-utils-internal'; +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'; + +function SingleSelectBox() { + const [selectedKeys, setSelectedKeys] = React.useState(new Set()); + return ( + + + Option 1 + + + Option 2 + + + Option 3 + + + ); +} + +function MultiSelectBox() { + const [selectedKeys, setSelectedKeys] = React.useState(new Set()); + return ( + + + Option 1 + + + Option 2 + + + Option 3 + + + ); +} + +function DisabledSelectBox() { + return ( + {}} + selectedKeys={new Set()} + isDisabled> + + Option 1 + + + Option 2 + + + ); +} + +describe('SelectBoxGroup', () => { + describe('Basic functionality', () => { + it('renders as a listbox with options', () => { + render(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(3); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('renders multiple selection mode', () => { + render(); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(3); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + }); + + it('handles selection in single mode', async () => { + render(); + const options = screen.getAllByRole('option'); + const option1 = options.find(option => option.textContent?.includes('Option 1'))!; + + await userEvent.click(option1); + expect(option1).toHaveAttribute('aria-selected', 'true'); + }); + + it('handles multiple selection', async () => { + render(); + const options = screen.getAllByRole('option'); + const option1 = options.find(option => option.textContent?.includes('Option 1'))!; + const option2 = options.find(option => option.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 listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + 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', () => { + it('shows checkbox when showCheckbox=true and item is selected', async () => { + render( + {}} + selectedKeys={new Set(['option1'])} + showCheckbox> + + Option 1 + + + Option 2 + + + ); + + const selectedRow = screen.getByRole('option', {name: 'Option 1'}); + expect(selectedRow).toHaveAttribute('aria-selected', 'true'); + + const checkboxDiv = selectedRow.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).toBeInTheDocument(); + }); + + it('shows checkbox on hover when showCheckbox=true for non-disabled items', async () => { + render( + {}} + selectedKeys={new Set()} + showCheckbox> + + Option 1 + + + ); + + const row = screen.getByRole('option', {name: 'Option 1'}); + + await userEvent.hover(row); + await waitFor(() => { + const checkboxDiv = row.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).toBeInTheDocument(); + }); + }); + + it('does not show checkbox when showCheckbox=false', async () => { + render( + {}} + selectedKeys={new Set(['option1'])}> + + Option 1 + + + ); + + const row = screen.getByRole('option', {name: 'Option 1'}); + + const checkboxDiv = row.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).not.toBeInTheDocument(); + }); + + it('shows checkbox for disabled but selected items when showCheckbox=true', () => { + render( + {}} + defaultSelectedKeys={new Set(['option1'])} + showCheckbox> + + Option 1 + + + ); + + const row = screen.getByRole('option', {name: 'Option 1'}); + + const checkboxDiv = row.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).toBeInTheDocument(); + }); + + it('does not show checkbox on hover for disabled items', async () => { + render( + {}} + selectedKeys={new Set()} + showCheckbox> + + Option 1 + + + ); + + const row = screen.getByRole('option', {name: 'Option 1'}); + + await userEvent.hover(row); + + await waitFor(() => { + const checkboxDiv = row.querySelector('[aria-hidden="true"]'); + expect(checkboxDiv).not.toBeInTheDocument(); + }, {timeout: 1000}); + }); + }); + + describe('Props and configuration', () => { + it('supports different orientations', () => { + render( + {}} + selectedKeys={new Set()} + orientation="horizontal"> + + Option 1 + + + ); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('supports different gutter widths', () => { + render( + {}} + selectedKeys={new Set()} + gutterWidth="compact"> + + 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', () => { + it('handles initial value selection', () => { + render( + {}} + selectedKeys={new Set(['option1'])}> + + Option 1 + + + Option 2 + + + ); + + 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', 'false'); + }); + + it('handles multiple selection with initial values', () => { + render( + {}} + selectedKeys={new Set(['option1', 'option2'])}> + + Option 1 + + + Option 2 + + + Option 3 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + const option2 = screen.getByRole('option', {name: 'Option 2'}); + const option3 = screen.getByRole('option', {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('option', {name: 'Option 1'}); + await userEvent.click(option1); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const receivedSelection = onSelectionChange.mock.calls[0][0]; + expect(Array.from(receivedSelection)).toEqual(['option1']); + }); + + it('calls onSelectionChange with Set for multiple selection', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await userEvent.click(option1); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const receivedSelection = onSelectionChange.mock.calls[0][0]; + expect(Array.from(receivedSelection)).toEqual(['option1']); + }); + + 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); + + const option2 = screen.getByRole('option', {name: 'Option 2'}); + expect(option2).toHaveAttribute('aria-selected', 'true'); + }); + + it('handles "all" selection', () => { + render( + {}} + selectedKeys="all"> + + Option 1 + + + Option 2 + + + ); + + 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'); + }); + }); + + describe('Individual SelectBox behavior', () => { + it('handles disabled individual items', () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + 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 () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + await userEvent.click(option1); + + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + }); + + describe('Grid navigation', () => { + it('supports keyboard navigation and grid layout', async () => { + render( + {}} + selectedKeys={new Set()} + numColumns={2}> + + Option 1 + + + Option 2 + + + Option 3 + + + Option 4 + + + ); + + const listbox = screen.getByRole('listbox'); + const options = screen.getAllByRole('option'); + + expect(listbox).toBeInTheDocument(); + expect(options).toHaveLength(4); + + expect(listbox).toHaveStyle('grid-template-columns: repeat(2, 1fr)'); + + expect(screen.getByRole('option', {name: 'Option 1'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'Option 2'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'Option 3'})).toBeInTheDocument(); + expect(screen.getByRole('option', {name: 'Option 4'})).toBeInTheDocument(); + }); + + it('supports space key selection', async () => { + const onSelectionChange = jest.fn(); + render( + + + Option 1 + + + ); + + const listbox = screen.getByRole('listbox'); + await act(async () => { + listbox.focus(); + }); + + await act(async () => { + await userEvent.keyboard(' '); + }); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + 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(() => { + consoleSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('does not warn with valid number of children', () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + Option 2 + + + ); + + expect(console.warn).not.toHaveBeenCalled(); + }); + }); + + describe('Accessibility', () => { + it('has proper listbox structure', () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + Option 2 + + + ); + + 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', () => { + it('handles complex children with slots', () => { + render( + {}} + selectedKeys={new Set()} + orientation="horizontal"> + + + Complex Option + With description + + + ); + + expect(screen.getByText('Complex Option')).toBeInTheDocument(); + expect(screen.getByText('With description')).toBeInTheDocument(); + }); + + it('handles different value types', () => { + render( + {}} + selectedKeys={new Set()}> + + Option 1 + + + Option 2 + + + ); + + const option1 = screen.getByRole('option', {name: 'Option 1'}); + const option2 = screen.getByRole('option', {name: 'Option 2'}); + expect(option1).toBeInTheDocument(); + expect(option2).toBeInTheDocument(); + }); + + it('handles empty children gracefully', () => { + render( + {}} + selectedKeys={new Set()}> + {null} + {undefined} + + Valid Option + + {false} + + ); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + expect(screen.getAllByRole('option')).toHaveLength(1); + expect(screen.getByText('Valid Option')).toBeInTheDocument(); + }); + + it('handles uncontrolled selection with defaultSelectedKeys', 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'}); + + expect(option1).toHaveAttribute('aria-selected', 'true'); + expect(option2).toHaveAttribute('aria-selected', 'false'); + + await userEvent.click(option2); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const receivedSelection = onSelectionChange.mock.calls[0][0]; + expect(Array.from(receivedSelection)).toEqual(['option2']); + }); + }); +}); + +