diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx new file mode 100644 index 00000000000..7ac39e3f0ce --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -0,0 +1,355 @@ +/* + * Copyright 2024 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + + +import { + Checkbox as AriaCheckbox, + Radio as AriaRadio, + GridListItemProps, + Provider, + TextContext +} from 'react-aria-components'; +import {Checkbox} from './Checkbox'; +import {FocusableRef} from '@react-types/shared'; +import {focusRing, size, style} from '../style' with {type: 'macro'}; +import {IllustrationContext} from './Icon'; +import {pressScale} from './pressScale'; +import {Radio} from './Radio'; +import React, {forwardRef, ReactNode, useRef} from 'react'; +import {StyleProps} from './style-utils' with {type: 'macro'}; +import {useFocusableRef} from '@react-spectrum/utils'; +import {useSelectBoxGroupProvider} from './SelectBoxGroup'; + +export interface SelectBoxProps extends Omit, StyleProps { + children: ReactNode | ((renderProps: SelectBoxProps) => ReactNode), + value: string +} + +const selectBoxStyle = style({ + ...focusRing(), + + aspectRatio: { + orientation: { + vertical: 'square' + } + }, + backgroundColor: { + default: 'white', + isDisabled: 'disabled' + }, + borderWidth: 2, + borderStyle: { + default: 'solid', + isDisabled: 'none' + }, + borderColor: { + default: 'gray-25', + isSelected: 'black' + }, + borderRadius: 'lg', + boxShadow: 'elevated', + boxSizing: 'border-box', + color: { + default: 'neutral', + isDisabled: 'disabled' + }, + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + maxHeight: { + orientation: { + horizontal: { + size: { + XS: size(74), + S: size(90), + M: size(106), + L: size(122), + XL: size(138) + } + }, + vertical: { + size: { + XS: size(168), + S: size(184), + M: size(200), + L: size(216), + XL: size(232) + } + } + } + }, + minHeight: { + orientation: { + horizontal: { + size: { + XS: size(74), + S: size(90), + M: size(106), + L: size(122), + XL: size(138) + } + }, + vertical: { + size: { + XS: size(100), + S: size(128), + M: size(144), + L: size(160), + XL: size(192) + } + } + } + }, + maxWidth: { + orientation: { + horizontal: { + size: { + XS: 192, + S: size(312), + M: size(368), + L: size(424), + XL: size(480) + } + }, + vertical: { + size: { + XS: size(168), + S: size(184), + M: size(200), + L: size(216), + XL: size(232) + } + } + } + }, + minWidth: { + orientation: { + horizontal: { + size: { + XS: 256, + S: size(312), + M: size(368), + L: size(424), + XL: size(480) + } + }, + vertical: { + size: { + XS: size(100), + S: size(128), + M: size(144), + L: size(160), + XL: size(192) + } + } + } + }, + outlineColor: { + default: 'focus-ring', + forcedColors: 'Highlight' + }, + padding: { + orientation: { + vertical: { + size: size(24) + }, + horizontal: { + size: { + XS: 8, + S: 12, + M: 16, + L: 20, + XL: 24 + } + } + } + }, + position: 'relative' +}); + +const selectBoxContentStyle = style({ + alignContent: 'center', + alignItems: 'center', + display: 'grid', + gap: { + orientation: { + horizontal: { + size: size(10), + vertical: size(8) + } + } + }, + gridTemplateAreas: { + orientation: { + horizontal: ['illustration label', 'illustration description'], + vertical: ['illustration', 'label'] + } + }, + gridTemplateColumns: { + orientation: { + horizontal: { + size: { + XS: ['36px', '1fr'], + S: ['42px', '1fr'], + M: ['48px', '1fr'], + L: ['54px', '1fr'], + XL: ['60px', '1fr'] + } + } + } + }, + height: 'full', + justifyItems: { + orientation: { + horizontal: 'flex-start', + vertical: 'center' + } + }, + paddingStart: { + orientation: { + horizontal: { + size: { + XS: 4, + S: 8, + M: 12, + L: 16, + XL: 20 + } + }, + vertical: 0 + } + }, + width: 'auto' +}); + +const selectBoxIconStyle = style({ + gridArea: 'illustration', + marginBottom: { + orientation: { + horizontal: 0, + vertical: 8 + } + }, + flexShrink: 0, + '--iconPrimary': { + type: 'color', + value: { + default: 'gray-800', + isDisabled: 'gray-400' + } + } +}); + +const selectBoxLabelStyle = style({ + gridArea: 'label', + font: 'control', + fontWeight: { + default: 'normal', + orientation: { + horizontal: 'bold' + } + }, + color: { + default: 'neutral', + isDisabled: 'disabled' + } +}); + +const selectBoxDescriptionStyle = style({ + display: { + default: 'none', + orientation: { + horizontal: 'block' + } + }, + color: 'gray-600', + font: 'control', + gridArea: 'description' +}); + +const selectorStyle = style({ + display: 'block', + position: 'absolute', + top: { + orientation: { + vertical: 16, + horizontal: '50%' + } + }, + right: 16, + visibility: { + default: 'hidden', + isHovered: 'visible', + isSelected: 'visible' + } +}); + +const SelectBox = (props: SelectBoxProps, ref: FocusableRef) => { + const {orientation, selectionMode, size} = useSelectBoxGroupProvider(); + const AriaSelector = selectionMode === 'single' ? AriaRadio : AriaCheckbox; + const Selector = selectionMode === 'single' ? Radio : Checkbox; + const domRef = useFocusableRef(ref); + const inputRef = useRef(null); + // TODO: readd UNSAFE_style + const {UNSAFE_className} = props; + + return ( + UNSAFE_className + selectBoxStyle({...renderProps, orientation, size})} + inputRef={inputRef} + ref={domRef} + style={renderProps => pressScale(domRef)(renderProps)} // TODO: this removed UNSAFE_style + value={props.value} + isDisabled={props.isDisabled}> + {renderProps => ( + + + + {typeof props.children === 'function' ? props.children(renderProps) : props.children} + + + )} + + ); +}; + +const _SelectBox = forwardRef(SelectBox); +_SelectBox.displayName = 'SelectBox'; +export {_SelectBox as SelectBox}; diff --git a/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx new file mode 100644 index 00000000000..7793bf8e119 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBoxGroup.tsx @@ -0,0 +1,173 @@ +/* + * Copyright 2024 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {CheckboxGroup, CheckboxGroupProps, GridListProps, Label, Provider, RadioGroup, RadioGroupProps, SelectionMode} from 'react-aria-components'; +import {IconContext} from './Icon'; +import {Orientation} from 'react-aria'; +import React, { + ForwardedRef, + forwardRef, + ReactNode, + useContext, + useEffect, + useMemo, + useState +} from 'react'; +import {style} from '../style' with {type: 'macro'}; +import {TextContext} from './Content'; +import {UnsafeStyles} from './style-utils' with {type: 'macro'}; +import {ValueBase} from '@react-types/shared'; + +/** + * Ensures the return value is a string. + * @param { string[]} value Possible options for selection. + * @returns { string } + */ +function unwrapValue(value: string[]): string { + if (Array.isArray(value)) { + return value[0]; + } + return value; +} + +export interface SelectBoxGroupProps extends Omit, 'dragAndDropHooks' | 'layout' | 'keyboardNavigationBehavior' | 'selectionBehavior' | 'onSelectionChange' | 'className' | 'style'>, UnsafeStyles, ValueBase { + children?: ReactNode, + label?: ReactNode, + isDisabled?: boolean, + isRequired?: boolean, + onChange?: (value: string[]) => void, + orientation?: Orientation, + size?: 'XS' | 'S' | 'M' | 'L' | 'XL' +} + +export type SelectorGroupProps = (CheckboxGroupProps | Omit) & { defaultValue?: string[], selectionMode: SelectionMode, value?: string[] }; + +export const SelectBoxContext = React.createContext>({ + size: 'M', + orientation: 'vertical' +}); + +export function useSelectBoxGroupProvider() { + return useContext(SelectBoxContext); +} + +const SelectorGroup = forwardRef(function SelectorGroupComponent( + { + children, + className, + defaultValue, + isDisabled, + isRequired, + onChange, + selectionMode, + value + }: SelectorGroupProps, + ref: ForwardedRef +) { + const props = { + isRequired, + isDisabled, + className, + children, + onChange, + ref + }; + + return selectionMode === 'single' ? ( + + ) : ( + + ); +}); + +function SelectBoxGroup( + props: SelectBoxGroupProps, + ref: ForwardedRef +): React.ReactElement { + const { + children, + defaultValue, + isDisabled = false, + isRequired = false, + label, + onChange, + orientation = 'vertical', + selectionMode = 'multiple', + size = 'M', + value: valueProp + } = props; + + const [value, setValue] = useState(defaultValue || valueProp); + + useEffect(() => { + if (value !== undefined && onChange) { + onChange(value); + } + }, [onChange, value]); + + const selectBoxContextValue = useMemo( + () => ({ + orientation, + selectionMode, + selectedValue: value, + size: size, + value + }), + [orientation, selectionMode, size, value] + ); + + // When one box grows, the rest should grow, too. + // setting autoRows to 1fr so that different rows split the space evenly + return ( + + + + + +
+ + {children} + +
+
+
+ ); +} + +const _SelectBoxGroup = forwardRef(SelectBoxGroup); +export {_SelectBoxGroup as SelectBoxGroup}; diff --git a/packages/@react-spectrum/s2/src/index.ts b/packages/@react-spectrum/s2/src/index.ts index c13c5a51afe..e3f33577aaf 100644 --- a/packages/@react-spectrum/s2/src/index.ts +++ b/packages/@react-spectrum/s2/src/index.ts @@ -57,6 +57,8 @@ export {Radio} from './Radio'; export {RadioGroup, RadioGroupContext} from './RadioGroup'; export {RangeSlider, RangeSliderContext} from './RangeSlider'; export {SearchField, SearchFieldContext} from './SearchField'; +export {SelectBox} from './SelectBox'; +export {SelectBoxGroup} from './SelectBoxGroup'; export {SegmentedControl, SegmentedControlItem, SegmentedControlContext} from './SegmentedControl'; export {Slider, SliderContext} from './Slider'; export {Skeleton, useIsSkeleton} from './Skeleton'; 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..a63ba2bca19 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -0,0 +1,110 @@ +/* + * Copyright 2024 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 REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import type {Meta, StoryObj} from '@storybook/react'; +import {SelectBox, SelectBoxGroup, Text} from '../src'; +import Server from '../spectrum-illustrations/linear/Server'; + +const meta: Meta = { + component: SelectBoxGroup, + parameters: { + layout: 'centered' + }, + tags: ['autodocs'], + argTypes: { + onChange: {table: {category: 'Events'}}, + orientation: { + control: 'radio', + options: ['horizontal', 'vertical'] + }, + selectionMode: { + control: 'radio', + options: ['single', 'multiple'] + } + }, + title: 'SelectBoxGroup' +}; + +export default meta; + +type Story = StoryObj; + +export const Example: Story = { + render(args) { + return ( + + + + Bells + + + + Select box label + A description that explains more context and details, to supplement the message of the label. + + + ); + }, + args: { + label: 'Select an icon' + } +}; + +export const LongTitleManyItems: Story = { + render(args) { + return ( + + + + Bells + + + + This is a long title that will wrap and shouldn't cause any issues + A description that explains more context and details, to supplement the message of the label. This shouldn't be very long like this or cause any wrapping or height issues. + + + + Bells + + + + Bells + + + + Bells + + + + Bells + + + + Bells + + + + Bells + + + + Select box label + A description that explains more context and details, to supplement the message of the label. + + + ); + }, + args: { + label: 'Select an icon' + } +};