diff --git a/packages/@react-spectrum/s2/src/SelectBox.tsx b/packages/@react-spectrum/s2/src/SelectBox.tsx new file mode 100644 index 00000000000..4ac28f6fcf2 --- /dev/null +++ b/packages/@react-spectrum/s2/src/SelectBox.tsx @@ -0,0 +1,341 @@ +/* + * 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 {IconContext} from './Icon'; +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: { + 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: { + S: 0, + M: size(2) + }, + vertical: size(8) + } + } + }, + gridTemplateAreas: { + orientation: { + horizontal: ['icon label', 'icon description'], + vertical: ['. icon .', '. 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({ + fill: { + default: 'currentColor', + isDisabled: 'gray-400' + }, + gridArea: 'icon', + marginBottom: { + orientation: { + horizontal: 0, + vertical: 8 + } + }, + flexShrink: 0 +}); + +const selectBoxLabelStyle = style({ + gridArea: 'label', + color: { + default: 'neutral', + isDisabled: 'disabled' + }, + fontSize: { + size: { + XS: 'body-sm', + M: 'body-lg', + L: 'body-xl', + XL: 'body-2xl' + } + } +}); + +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); + const {UNSAFE_className, UNSAFE_style} = props; + + return ( + UNSAFE_className + selectBoxStyle({...renderProps, orientation, size})} + inputRef={inputRef} + ref={domRef} + style={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/stories/SelectBoxGroup.stories.tsx b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx new file mode 100644 index 00000000000..9f18ff7e7d7 --- /dev/null +++ b/packages/@react-spectrum/s2/stories/SelectBoxGroup.stories.tsx @@ -0,0 +1,67 @@ +/************************************************************************* + * ADOBE CONFIDENTIAL + * ___________________ + * + * Copyright 2024 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 Bell from '../s2wf-icons/S2_Icon_Bell_20_N.svg'; +import Heart from '../s2wf-icons/S2_Icon_Heart_20_N.svg'; +import type {Meta, StoryObj} from '@storybook/react'; +import {SelectBox} from '../src/SelectBox'; +import {SelectBoxGroup} from '../src/SelectBoxGroup'; +import {Text} from '../src'; + +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 + + + + Heart + + + ); + }, + args: { + label: 'Select an icon' + } +};