diff --git a/.changeset/calm-keys-wonder.md b/.changeset/calm-keys-wonder.md new file mode 100644 index 0000000000..8053e94ed1 --- /dev/null +++ b/.changeset/calm-keys-wonder.md @@ -0,0 +1,9 @@ +--- +"@marigold/docs": minor +"@marigold/components": minor +"@marigold/theme-b2b": minor +"@marigold/theme-core": minor +"@marigold/theme-unicorn": minor +--- + +feat: Accessible and diff --git a/docs/content/components/checkbox.mdx b/docs/content/components/checkbox.mdx index f0fbca7fab..ce0903f85a 100644 --- a/docs/content/components/checkbox.mdx +++ b/docs/content/components/checkbox.mdx @@ -8,7 +8,7 @@ import { DoAndDont } from '../../src/components/DoAndDont'; # Checkbox -The `Checkbox` components allows users to select one or more options from a list of options. In order to group multiple checkboxes, use the `CheckboxGroup` component. +The `Checkbox` component allows users to select one or more options from a set of options. In order to group multiple checkboxes, use the `CheckboxGroup` component. I will not talk about Fight Club diff --git a/docs/content/components/radio.mdx b/docs/content/components/radio.mdx index 66880f7544..c2a789487c 100644 --- a/docs/content/components/radio.mdx +++ b/docs/content/components/radio.mdx @@ -1,13 +1,24 @@ --- title: Radio +figma: https://www.figma.com/file/DFKyTGHAoDxOsUBPszLLxP/%F0%9F%8F%B5%EF%B8%8FMarigold?node-id=467%3A332 --- import { FigmaLink } from '../../src/components/FigmaLink'; # Radio -With the Radio Button component you can add a HTML `` element with `type="radio"` to your form. -The variant can be added with the variant prop. The default variant is `__default`. +The `Radio` and `Radio.Group` components allow users to select one option from a set of options. They are useful for presenting a list of options to users and collecting their responses. + +```tsx + + 🍨 Ice Cream + 🧁 Cupcake + + 🍪 Cookie (sold out) + + 🍎 Apple + +``` ```tsx onlyCode import { Radio } from '@marigold/components'; @@ -21,90 +32,50 @@ import { Radio } from '@marigold/components'; ## Props -| Property | Type | Default | -| :------------------------ | :-------- | :---------- | -| `id` | `string` | | -| `variant` (optional) | `string` | `__default` | -| `labelVariant` (optional) | `string` | `inline` | -| `required` (optional) | `boolean` | `false` | -| `disabled` (optional) | `boolean` | `false` | -| `error` (optional) | `boolean` | `false` | -| `errorMessage` (optional) | `string` | | +| Name | Type | Default | Description | +| :----------------- | :-------- | :---------- | :----------------------------------------------------------------------------------------------------------------- | +| `variant` | `string` | | Use a different _variant_ from theme | +| `size` | `string` | `'level-1'` | Use a different _size_ from theme | +| `error` (optional) | `boolean` | `false` | If `true`, the checkbox is considered invalid and if set the `errorMessage` is shown instead of the `description`. | +| ... | | | Yes you can use all regular attributes of `input`! | ## Examples -### Radio standard labeled +### Simple Radio ```tsx -() => { - const [state, setState] = React.useState('Mastercard'); - const onChange = changeEvent => { - setState(changeEvent.currentTarget.value); - }; - return ( - <> - - Mastercard - -
- - Visa - -
- - AmericanExpress - - - ); -}; + + Option 1 + Option 2 + + Option 3 + + Option 4 + ``` -### Radio disabled +### Disabed Radio ```tsx -<> - - Disabled + + Option 1 + Option 2 + + Option 3 -
- - Checked and disabled - - + Option 4 +
``` ### Radio with required label ```tsx -<> - - This label is required + + Option 1 + Option 2 + + Option 3 - -``` - -### Radio with error and errorMessage - -```tsx - - This radio has an error - + Option 4 + ``` diff --git a/packages/components/package.json b/packages/components/package.json index c51e804adc..b1c474d776 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -36,6 +36,7 @@ "@react-aria/listbox": "^3.3.1", "@react-aria/menu": "^3.4.3", "@react-aria/overlays": "^3.7.2", + "@react-aria/radio": "^3.1.10", "@react-aria/select": "^3.4.1", "@react-aria/separator": "^3.1.3", "@react-aria/ssr": "^3.1.0", @@ -50,6 +51,7 @@ "@react-stately/list": "^3.3.0", "@react-stately/menu": "^3.2.6", "@react-stately/overlays": "^3.1.3", + "@react-stately/radio": "^3.3.5", "@react-stately/select": "^3.1.3", "@react-stately/table": "^3.1.3", "@react-stately/toggle": "^3.2.5", @@ -57,6 +59,7 @@ "@react-stately/tree": "^3.2.3", "@react-types/checkbox": "^3.2.6", "@react-types/dialog": "^3.3.1", + "@react-types/radio": "^3.1.5", "@react-types/shared": "^3.8.0", "@react-types/tooltip": "^3.1.5", "react-is": "^17.0.2" diff --git a/packages/components/src/Checkbox/Checkbox.test.tsx b/packages/components/src/Checkbox/Checkbox.test.tsx index 5e6f507aa6..4e20039107 100644 --- a/packages/components/src/Checkbox/Checkbox.test.tsx +++ b/packages/components/src/Checkbox/Checkbox.test.tsx @@ -58,7 +58,7 @@ const theme = { }, }, }, - sies: { + size: { large: { label: { fontSize: 'large-1', diff --git a/packages/components/src/Checkbox/Checkbox.tsx b/packages/components/src/Checkbox/Checkbox.tsx index e341c137ee..fc6404e0b0 100644 --- a/packages/components/src/Checkbox/Checkbox.tsx +++ b/packages/components/src/Checkbox/Checkbox.tsx @@ -28,12 +28,6 @@ export interface CheckboxThemeExtension // SVG Icon // --------------- -interface IconProps extends StateAttrProps { - css?: CSSObject; - checked?: boolean; - indeterminate?: boolean; -} - const CheckMark = () => ( ( ); +interface IconProps extends StateAttrProps { + css?: CSSObject; + checked?: boolean; + indeterminate?: boolean; +} + const Icon = ({ css, checked, indeterminate, ...props }: IconProps) => { const icon = indeterminate ? : ; return ( @@ -68,7 +68,7 @@ const Icon = ({ css, checked, indeterminate, ...props }: IconProps) => { display: 'flex', alignItems: 'center', justifyContent: 'center', - p: '1px', + p: 1, }} css={css} {...props} @@ -181,7 +181,6 @@ export const Checkbox = ({ return ( ( - /** - * Needs to be falsy so we can check if a checkbox is used as standalone - * or in a group. - */ null as any ); - export const useCheckboxGroupContext = () => useContext(CheckboxGroupContext); // Theme Extension @@ -43,7 +42,7 @@ export interface CheckboxGroupThemeExtension interface CheckboxGroupProps extends Omit, 'onChange'>, AriaCheckboxGroupProps { - children?: ReactNode; + children: ReactNode; variant?: string; size?: string; label?: ReactNode; @@ -68,7 +67,6 @@ export const CheckboxGroup = ({ error, ...rest }: CheckboxGroupProps) => { - // Adjust props to the react-aria API const props = { isRequired: required, isDisabled: disabled, @@ -76,6 +74,7 @@ export const CheckboxGroup = ({ validationState: error ? 'invalid' : 'valid', ...rest, } as const; + const state = useCheckboxGroupState(props); const { groupProps, labelProps } = useCheckboxGroup(props, state); @@ -97,7 +96,7 @@ export const CheckboxGroup = ({ __baseCSS={{ display: 'flex', flexDirection: 'column', - alignItems: 'left', + alignItems: 'start', }} css={styles.group} > diff --git a/packages/components/src/Radio/Radio.stories.tsx b/packages/components/src/Radio/Radio.stories.tsx index 36c67fb428..d0a5a874ac 100644 --- a/packages/components/src/Radio/Radio.stories.tsx +++ b/packages/components/src/Radio/Radio.stories.tsx @@ -1,36 +1,24 @@ -import React, { useState } from 'react'; +import React from 'react'; import type { Meta, ComponentStory } from '@storybook/react'; -import { Radio } from './Radio'; +import { Radio } from '@marigold/components'; export default { title: 'Components/Radio', - parameters: { - actions: { - handles: ['click'], - }, - }, argTypes: { - variant: { + label: { control: { type: 'text', }, - description: 'Radio variant', - defaultValue: '__default', + description: 'Label', + defaultValue: 'The Label', }, - labelVariant: { + orientation: { control: { - type: 'text', + type: 'select', + options: ['horizontal', 'vertical'], }, - description: 'Radio label variant', - defaultValue: 'inline', - }, - children: { - control: { - type: 'text', - }, - description: 'Label', - defaultValue: 'Radio Label', + description: 'Orientation', }, required: { control: { @@ -53,26 +41,16 @@ export default { description: 'Error', defaultValue: false, }, - errorMessage: { - control: { - type: 'text', - }, - description: 'Error Message', - }, }, } as Meta; -export const Basic: ComponentStory = ({ - onChange, - checked, - ...args -}) => { - const [isChecked, setChecked] = useState(false); - return ( - setChecked(!isChecked)} - checked={isChecked} - {...args} - /> - ); -}; +export const Basic: ComponentStory = args => ( + + Option 1 + Option 2 + + Option 3 + + Option 4 + +); diff --git a/packages/components/src/Radio/Radio.test.tsx b/packages/components/src/Radio/Radio.test.tsx index d080dc50e6..d18a1d9748 100644 --- a/packages/components/src/Radio/Radio.test.tsx +++ b/packages/components/src/Radio/Radio.test.tsx @@ -1,172 +1,292 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; -import { Radio } from './Radio'; +import { Radio } from '.'; import { ThemeProvider } from '@marigold/system'; const theme = { - space: { - none: 0, - small: 2, - }, colors: { - disabled: 'gray', + gray: '#868e96', + blue: '#a5d8ff', + teal: '#099268', + green: '#2b8a3e', + red: '#c92a2a', }, - radio: { - __default: { - m: 'small', - }, + fontSizes: { + 'small-1': 12, + 'large-1': 24, + }, + radii: { + none: 0, + 'large-1': '9999px', }, - label: { - above: { - fontSize: '8px', + components: { + Radio: { + base: { + label: { + fontSize: 'small-1', + }, + radio: { + borderRadius: 'large-1', + '&:focus': { + outline: '1px solid', + outlineColor: 'blue', + }, + '&:checked': { + color: 'teal', + }, + '&:disabled': { + bg: 'gray', + }, + '&:read-only': { + opacity: 0.5, + }, + '&:error': { + bg: 'red', + }, + }, + }, + variant: { + green: { + label: { + color: 'green', + }, + radio: { + '&:checked': { + color: 'green', + }, + }, + }, + }, + size: { + large: { + label: { + fontSize: 'large-1', + }, + radio: { + width: 32, + height: 32, + }, + }, + }, }, - inline: { - fontSize: '14px', + RadioGroup: { + base: { + container: { + bg: 'gray', + }, + group: { + fontStyle: 'italic', + }, + }, + variant: { + green: { + container: { + bg: 'green', + }, + }, + }, + size: { + large: { + group: { + fontSize: 'large-1', + }, + }, + }, }, }, }; -test('supports default labelVariant', () => { +// There is no real accesible way to get to the element that acts as radio +const getVisibleRadios = () => { + const label = screen.getByText('With Label'); + // eslint-disable-next-line testing-library/no-node-access + return label.parentElement?.querySelectorAll('[aria-hidden="true"]'); +}; + +// Tests +// --------------- +test('allows styling via theme', () => { render( - - label - + + + Option 1 + + + Option 2 + + + Option 3 + + ); - const label = screen.getByText(/label/); - expect(label).toHaveStyle(`font-size: 14px`); + const radioLabel = screen.getByText('Option 1'); + expect(radioLabel).toHaveStyle(`font-size: ${theme.fontSizes['small-1']}px`); + + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`border-radius: ${theme.radii['large-1']}`); }); -test('supports other labelVariant than default', () => { +test('supports styling via variant and size', () => { render( - - label - + + + Option 1 + + + Option 2 + + + Option 3 + + ); - const label = screen.getByText(/label/); - expect(label).toHaveStyle(`font-size: 8px`); -}); + const radioLabel = screen.getByText('Option 1'); + expect(radioLabel).toHaveStyle(`color: ${theme.colors.green}`); + expect(radioLabel).toHaveStyle(`font-size: ${theme.fontSizes['large-1']}px`); -test('supports label prop', () => { - render( - - Test - - ); + fireEvent.click(screen.getByTestId('radio-1')); - const radioLabel = screen.getByText(/Test/); - expect(radioLabel).toBeDefined(); + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`color: ${theme.colors.green}`); + expect(radio).toHaveStyle(`width: 32px`); + expect(radio).toHaveStyle(`height: 32px`); }); -test('supports required prop and renders required icon', () => { +test('variant and size styling on radio option', () => { render( - - Test - + + + + Option 1 + + + Option 2 + + + Option 3 + + + ); - const label = screen.getByText(/Test/); - // eslint-disable-next-line testing-library/no-node-access - expect(label.nextSibling).toContainHTML('path d="M10.8'); -}); - -test('supports default type', () => { - render( - - test - + // 1st option has no variant / size + const radioLabelOne = screen.getByText('Option 1'); + expect(radioLabelOne).not.toHaveStyle(`color: ${theme.colors.green}`); + expect(radioLabelOne).not.toHaveStyle( + `font-size: ${theme.fontSizes['large-1']}px` ); - const radio = screen.getByTitle(/radio/); - expect(radio.getAttribute('type')).toEqual('radio'); -}); - -test('renders element', () => { - render( - - test - - ); + fireEvent.click(screen.getByTestId('radio-1')); - const radio = screen.getByTitle(/radio/); - expect(radio instanceof HTMLInputElement).toBeTruthy(); -}); + const radioOne = getVisibleRadios()?.[0]; + expect(radioOne).not.toHaveStyle(`color: ${theme.colors.green}`); + expect(radioOne).not.toHaveStyle(`width: 32px`); + expect(radioOne).not.toHaveStyle(`height: 32px`); -test('supports disabled prop', () => { - render( - - - label - - + // 2nd option has variant / size + const radioLabelTwo = screen.getByText('Option 2'); + expect(radioLabelTwo).toHaveStyle(`color: ${theme.colors.green}`); + expect(radioLabelTwo).toHaveStyle( + `font-size: ${theme.fontSizes['large-1']}px` ); - const radio = screen.getByTitle(/radio/); - expect(radio).toHaveAttribute('disabled'); - const label = screen.getByText(/label/); - expect(label).toHaveStyle(`color: gray`); + fireEvent.click(screen.getByTestId('radio-2')); + + const radio = getVisibleRadios()?.[1]; + expect(radio).toHaveStyle(`color: ${theme.colors.green}`); + expect(radio).toHaveStyle(`width: 32px`); + expect(radio).toHaveStyle(`height: 32px`); }); -test('supports error and errorMessage prop', () => { +test('allows styling "checked" state via theme', () => { render( - - test - + + + Option 1 + + + Option 2 + + + Option 3 + + ); - const errorMessage = screen.getByText(/error/); - expect(errorMessage).toBeDefined(); + fireEvent.click(screen.getByTestId('radio-1')); + + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`color: ${theme.colors.teal}`); }); -test('supports checked radio', () => { +test('allows styling "focus" state via theme', () => { render( - {}} checked> - test - {' '} + + + Option 1 + + + Option 2 + + + Option 3 + + ); + const input = screen.getByTestId('radio-1'); + input.focus(); - const radio = screen.getByTitle(/radio/); - expect(radio).toHaveAttribute('checked'); + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`outline: 1px solid`); + expect(radio).toHaveStyle(`outline-color: ${theme.colors.blue}`); }); -test('supports checked and disabled radio', () => { +test('allows styling "disabled" state via theme', () => { render( - {}} checked disabled> - test - + + + Option 1 + + + Option 2 + + + Option 3 + + ); - - const radio = screen.getByTitle(/radio/); - expect(radio).toHaveAttribute('checked'); - expect(radio).toHaveAttribute('disabled'); + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`background: ${theme.colors.gray}`); }); -test('correctly handles interaction', () => { - const click = jest.fn(); - const change = jest.fn(); - +test('allows styling "read-only" state via theme', () => { render( - - Test - + + + Option 1 + + + Option 2 + + + Option 3 + + ); - - const radio = screen.getByTitle(/radio/); - fireEvent.click(radio); - - expect(click).toHaveBeenCalledTimes(1); - expect(change).toHaveBeenCalledTimes(1); + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`opacity: 0.5`); }); diff --git a/packages/components/src/Radio/Radio.tsx b/packages/components/src/Radio/Radio.tsx old mode 100755 new mode 100644 index 4b1460f7bd..c06eef8a16 --- a/packages/components/src/Radio/Radio.tsx +++ b/packages/components/src/Radio/Radio.tsx @@ -1,85 +1,144 @@ -import React from 'react'; +import React, { useRef } from 'react'; +import { useRadio } from '@react-aria/radio'; +import type { AriaRadioProps } from '@react-types/radio'; + +import { + Box, + CSSObject, + StateAttrProps, + ThemeComponentProps, + ThemeExtensionsWithParts, + useComponentStyles, + useStateProps, +} from '@marigold/system'; import { ComponentProps } from '@marigold/types'; -import { Exclamation } from '@marigold/icons'; -import { useFocusRing } from '@react-aria/focus'; -import { VisuallyHidden } from '@react-aria/visually-hidden'; -import { RadioIcon, RadioIconProps } from './RadioIcon'; -import { Box } from '../Box'; -import { Label } from '../Label'; -import { ValidationMessage } from '../ValidationMessage'; +import { RadioGroup, useRadioGroupContext } from './RadioGroup'; +import { useHover } from '@react-aria/interactions'; +import { useFocusRing } from '@react-aria/focus'; // Theme Extension // --------------- -export interface RadioThemeExtension { - radio?: { - [key: string]: Value; - }; -} +export interface RadioThemeExtension + extends ThemeExtensionsWithParts<'Radio', ['container', 'label', 'radio']> {} -// Radio Input +// SVG Icon // --------------- -interface RadioInputProps extends RadioIconProps, ComponentProps<'input'> {} +const Dot = () => ( + + + +); -const RadioInput: React.FC = ({ error, ...props }) => { - const { focusProps } = useFocusRing(); - const { children, ...restProps } = props; +interface IconProps extends StateAttrProps { + css?: CSSObject; + checked?: boolean; +} - return ( - - - - - - - ); -}; +const Icon = ({ checked, css, ...props }: IconProps) => ( + +); -// Radio +// Props // --------------- -export interface RadioProps extends RadioInputProps { - id: string; - required?: boolean; - labelVariant?: string; - errorMessage?: string; +export type CustomRadioProps = + | 'value' + | 'onFocus' + | 'onBlur' + | 'onKeyUp' + | 'onKeyDown'; + +export interface RadioProps + extends ThemeComponentProps, + Omit< + ComponentProps<'input'>, + 'size' | 'type' | 'defaultChecked' | CustomRadioProps + >, + AriaRadioProps { + disabled?: boolean; } -export const Radio: React.FC = ({ - required, - labelVariant = 'inline', - errorMessage, - ...props -}) => ( - <> +// Component +// --------------- +export const Radio = ({ disabled, ...props }: RadioProps) => { + const { variant, size, error, ...state } = useRadioGroupContext(); + + const ref = useRef(null); + const { inputProps } = useRadio( + { isDisabled: disabled, ...props }, + state, + ref + ); + + const styles = useComponentStyles( + 'Radio', + { variant: variant || props.variant, size: size || props.size }, + { parts: ['container', 'label', 'radio'] } + ); + + const { hoverProps, isHovered } = useHover({}); + const { isFocusVisible, focusProps } = useFocusRing(); + const stateProps = useStateProps({ + hover: isHovered, + focus: isFocusVisible, + checked: inputProps.checked, + disabled: inputProps.disabled, + readOnly: props.readOnly, + error, + }); + + return ( - - {props.children} + + + + {props.children} + - {props.error && errorMessage && ( - - - {errorMessage} - - )} - -); + ); +}; + +Radio.Group = RadioGroup; diff --git a/packages/components/src/Radio/RadioGroup.test.tsx b/packages/components/src/Radio/RadioGroup.test.tsx new file mode 100644 index 0000000000..5f462ef62c --- /dev/null +++ b/packages/components/src/Radio/RadioGroup.test.tsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Radio } from '.'; +import { ThemeProvider } from '@marigold/system'; + +const theme = { + colors: { + gray: '#868e96', + blue: '#a5d8ff', + teal: '#099268', + green: '#2b8a3e', + red: '#c92a2a', + }, + fontSizes: { + 'small-1': 12, + 'large-1': 24, + }, + radii: { + none: 0, + 'large-1': '9999px', + }, + components: { + Radio: { + base: { + label: { + fontSize: 'small-1', + }, + radio: { + borderRadius: 'large-1', + '&:focus': { + outline: '1px solid', + outlineColor: 'blue', + }, + '&:checked': { + color: 'teal', + }, + '&:disabled': { + bg: 'gray', + }, + '&:read-only': { + opacity: 0.5, + }, + '&:error': { + bg: 'red', + }, + }, + }, + variant: { + green: { + label: { + color: 'green', + }, + radio: { + '&:checked': { + color: 'green', + }, + }, + }, + }, + size: { + large: { + label: { + fontSize: 'large-1', + }, + radio: { + width: 32, + height: 32, + }, + }, + }, + }, + RadioGroup: { + base: { + container: { + bg: 'gray', + }, + group: { + fontStyle: 'italic', + + '&[data-orientation="horizontal"]': { + gap: '3ch', + }, + }, + }, + variant: { + green: { + container: { + bg: 'green', + }, + }, + }, + size: { + large: { + group: { + fontSize: 'large-1', + }, + }, + }, + }, + }, +}; + +// There is no real accesible way to get to the element that acts as radio +const getVisibleRadios = () => { + const label = screen.getByText('With Label'); + // eslint-disable-next-line testing-library/no-node-access + return label.parentElement?.querySelectorAll('[aria-hidden="true"]'); +}; + +// Tests +// --------------- +test('renders label(s) and (hidden) radio', () => { + render( + + + Option 1 + + + Option 2 + + + Option 3 + + + ); + + const label = screen.queryByText('With Label'); + expect(label).toBeInTheDocument(); + + const radios = screen.queryAllByTestId(/radio/); + expect(radios).toHaveLength(3); + + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + expect(screen.getByText('Option 3')).toBeInTheDocument(); +}); + +test('label is optional (can use aria-label instead)', () => { + render( + + + Option 1 + + + Option 2 + + + ); + + expect(screen.queryByText('With Label')).not.toBeInTheDocument(); +}); + +test('allows styling via theme', () => { + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + const container = screen.getByRole('radiogroup'); + expect(container).toHaveStyle(`background: ${theme.colors.gray}`); + + const group = screen.getByRole('presentation'); + expect(group).toHaveStyle(`font-style: italic`); +}); + +test('supports styling via variant and size', () => { + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + const container = screen.getByRole('radiogroup'); + expect(container).toHaveStyle(`background: ${theme.colors.green}`); + + const group = screen.getByRole('presentation'); + expect(group).toHaveStyle(`font-size: ${theme.fontSizes['large-1']}px`); +}); + +test('support vertical orientation by default', () => { + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + const group = screen.getByRole('presentation'); + expect(group).toHaveAttribute('data-orientation', 'vertical'); +}); + +test('support horizontal orientation', () => { + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + const group = screen.getByRole('presentation'); + expect(group).toHaveAttribute('data-orientation', 'horizontal'); + + expect(group).toHaveStyle(`gap: 3ch;`); +}); + +test('supports error styling via theme & passes down error', () => { + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + // Note that there is no error styling for the container and group yet! + const radio = getVisibleRadios()?.[0]; + expect(radio).toHaveStyle(`background: ${theme.colors.red}`); +}); + +test('supports default value (uncontrolled)', () => { + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + const checkedRadio = getVisibleRadios()?.[2]; + expect(checkedRadio).toHaveStyle(`color: ${theme.colors.teal}`); +}); + +test('controlled', () => { + const onChange = jest.fn(); + + render( + + + + Option 1 + + + Option 2 + + + Option 3 + + + + ); + + fireEvent.click(getVisibleRadios()?.[0]!); + + const checkedRadio = getVisibleRadios()?.[0]; + expect(checkedRadio).toHaveStyle(`color: ${theme.colors.teal}`); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith('1'); +}); + +// orientation? diff --git a/packages/components/src/Radio/RadioGroup.tsx b/packages/components/src/Radio/RadioGroup.tsx new file mode 100644 index 0000000000..e7dbe59240 --- /dev/null +++ b/packages/components/src/Radio/RadioGroup.tsx @@ -0,0 +1,102 @@ +import React, { ReactNode, useContext } from 'react'; +import { useRadioGroup } from '@react-aria/radio'; +import { RadioGroupState, useRadioGroupState } from '@react-stately/radio'; +import { AriaRadioGroupProps } from '@react-types/radio'; + +import { + Box, + ThemeExtensionsWithParts, + useComponentStyles, +} from '@marigold/system'; + +import { Label } from '../Field/Label'; + +// Context +// --------------- +export interface RadioGroupContextProps extends RadioGroupState { + variant?: string; + size?: string; + error?: boolean; +} + +const RadioGroupContext = React.createContext( + null as any +); +export const useRadioGroupContext = () => useContext(RadioGroupContext); + +// Theme Extension +// --------------- +export interface RadioGroupThemeExtension + extends ThemeExtensionsWithParts<'RadioGroup', ['container', 'group']> {} + +// Props +// --------------- +export interface RadioGroupProps + extends Omit< + AriaRadioGroupProps, + 'isDisabled' | 'isRquired' | 'isReadOnly ' | 'validationState' + > { + children: ReactNode[]; + variant?: string; + size?: string; + required?: boolean; + disabled?: boolean; + readOnly?: boolean; + error?: boolean; +} + +// Component +// --------------- +export const RadioGroup = ({ + children, + orientation = 'vertical', + size, + variant, + required, + disabled, + readOnly, + error, + ...rest +}: RadioGroupProps) => { + const props: AriaRadioGroupProps = { + isRequired: required, + isDisabled: disabled, + isReadOnly: readOnly, + validationState: error ? 'invalid' : 'valid', + ...rest, + }; + + const state = useRadioGroupState(props); + const { radioGroupProps, labelProps } = useRadioGroup(props, state); + + const styles = useComponentStyles( + 'RadioGroup', + { variant, size }, + { parts: ['container', 'group'] } + ); + + return ( + + {props.label && ( + + )} + + + {children} + + + + ); +}; diff --git a/packages/components/src/Radio/RadioIcon.tsx b/packages/components/src/Radio/RadioIcon.tsx deleted file mode 100644 index 5a968da713..0000000000 --- a/packages/components/src/Radio/RadioIcon.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { conditional, State, SVG } from '@marigold/system'; - -import { Box } from '../Box'; - -// Radio Icon -// --------------- -export interface RadioIconProps { - variant?: string; - checked?: boolean; - disabled?: boolean; - error?: boolean; -} - -export const RadioIcon: React.FC = ({ - variant = '', - checked = false, - disabled = false, - error = false, -}) => { - const conditionalStates: State = disabled - ? { - disabled: disabled, - } - : { - checked: checked, - error: error, - }; - - return ( - - ); -}; diff --git a/packages/components/src/Radio/index.ts b/packages/components/src/Radio/index.ts index bfbe6d09bf..60f8fa6a65 100644 --- a/packages/components/src/Radio/index.ts +++ b/packages/components/src/Radio/index.ts @@ -1 +1,2 @@ export * from './Radio'; +export type { RadioGroupProps, RadioGroupThemeExtension } from './RadioGroup'; diff --git a/packages/components/src/theme.ts b/packages/components/src/theme.ts index 98679c8b08..3ccb0ad863 100644 --- a/packages/components/src/theme.ts +++ b/packages/components/src/theme.ts @@ -24,17 +24,17 @@ import type { InputThemeExtension } from './Input'; import type { LabelThemeExtension } from './Field/Label'; import type { LinkThemeExtension } from './Link'; import type { MenuThemeExtension } from './Menu'; +import type { RadioThemeExtension, RadioGroupThemeExtension } from './Radio'; +import type { TableThemeExtension } from './Table'; import type { TextThemeExtension } from './Text'; import type { TextAreaThemeExtension } from './TextArea'; // Old Styling Solution import { type LabelThemeExtension as LegacyLabelThemeExtension } from './Label'; import { type MessageThemeExtension } from './Message'; -import { type RadioThemeExtension } from './Radio'; import { type SelectThemeExtension } from './Select'; import { type SliderThemeExtension } from './Slider'; import { type SwitchThemeExtension } from './Switch'; -import { type TableThemeExtension } from './Table'; import { type TooltipThemeExtension } from './Tooltip'; import { type ValidationMessageThemeExtension } from './ValidationMessage'; @@ -56,6 +56,8 @@ interface ComponentStyles LabelThemeExtension, LinkThemeExtension, MenuThemeExtension, + RadioThemeExtension, + RadioGroupThemeExtension, TableThemeExtension, TextThemeExtension, TextAreaThemeExtension {} @@ -65,7 +67,6 @@ export interface Theme RootThemeExtension, LegacyLabelThemeExtension, MessageThemeExtension, - RadioThemeExtension, SelectThemeExtension, SliderThemeExtension, SwitchThemeExtension, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1caeeff035..5186092daa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,7 @@ importers: '@react-aria/listbox': ^3.3.1 '@react-aria/menu': ^3.4.3 '@react-aria/overlays': ^3.7.2 + '@react-aria/radio': ^3.1.10 '@react-aria/select': ^3.4.1 '@react-aria/separator': ^3.1.3 '@react-aria/ssr': ^3.1.0 @@ -265,6 +266,7 @@ importers: '@react-stately/list': ^3.3.0 '@react-stately/menu': ^3.2.6 '@react-stately/overlays': ^3.1.3 + '@react-stately/radio': ^3.3.5 '@react-stately/select': ^3.1.3 '@react-stately/table': ^3.1.3 '@react-stately/toggle': ^3.2.5 @@ -272,6 +274,7 @@ importers: '@react-stately/tree': ^3.2.3 '@react-types/checkbox': ^3.2.6 '@react-types/dialog': ^3.3.1 + '@react-types/radio': ^3.1.5 '@react-types/shared': ^3.8.0 '@react-types/tooltip': ^3.1.5 react-is: ^17.0.2 @@ -290,6 +293,7 @@ importers: '@react-aria/listbox': 3.4.4_react@17.0.2 '@react-aria/menu': 3.4.3_react-dom@17.0.2+react@17.0.2 '@react-aria/overlays': 3.8.1_react-dom@17.0.2+react@17.0.2 + '@react-aria/radio': 3.1.10_react@17.0.2 '@react-aria/select': 3.6.4_react-dom@17.0.2+react@17.0.2 '@react-aria/separator': 3.1.6_react@17.0.2 '@react-aria/ssr': 3.1.2_react@17.0.2 @@ -304,6 +308,7 @@ importers: '@react-stately/list': 3.4.4_react@17.0.2 '@react-stately/menu': 3.2.6_react@17.0.2 '@react-stately/overlays': 3.1.6_react@17.0.2 + '@react-stately/radio': 3.3.5_react@17.0.2 '@react-stately/select': 3.1.6_react@17.0.2 '@react-stately/table': 3.1.3_react@17.0.2 '@react-stately/toggle': 3.2.6_react@17.0.2 @@ -311,6 +316,7 @@ importers: '@react-stately/tree': 3.2.3_react@17.0.2 '@react-types/checkbox': 3.2.6_react@17.0.2 '@react-types/dialog': 3.3.4_react@17.0.2 + '@react-types/radio': 3.1.5_react@17.0.2 '@react-types/shared': 3.11.2_react@17.0.2 '@react-types/tooltip': 3.1.5_react@17.0.2 react-is: 17.0.2 @@ -5407,6 +5413,22 @@ packages: react-dom: 17.0.2_react@17.0.2 dev: false + /@react-aria/radio/3.1.10_react@17.0.2: + resolution: {integrity: sha512-AZfzKhqzUUaiVLJ9APqQMrNgsoOqnV7jJeHqwEfSl+epBI+WtG6cbJtXroHlYr6hfwEjRzv0wg5CMzip0nJEaA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.17.9 + '@react-aria/focus': 3.5.4_react@17.0.2 + '@react-aria/i18n': 3.3.8_react@17.0.2 + '@react-aria/interactions': 3.8.3_react@17.0.2 + '@react-aria/label': 3.2.4_react@17.0.2 + '@react-aria/utils': 3.11.3_react@17.0.2 + '@react-stately/radio': 3.3.5_react@17.0.2 + '@react-types/radio': 3.1.5_react@17.0.2 + react: 17.0.2 + dev: false + /@react-aria/select/3.6.4_react-dom@17.0.2+react@17.0.2: resolution: {integrity: sha512-JDNqE7qS4n+ruT3SbLWhp1toB4Op4UBVCAohB3lWTo4mdqxz6v3H/ArGmXDZZ/8l7bPPj3LmTw2O1b1/nULfVQ==} peerDependencies: @@ -5643,6 +5665,17 @@ packages: react: 17.0.2 dev: false + /@react-stately/radio/3.3.5_react@17.0.2: + resolution: {integrity: sha512-/2m4beHHbKdz91PFFaPd5xWysENnWGBNUa1Un9VSwvmtoYbUJDl33lZOx/GqHg8Vu2sxCyVMv19w75bun2jm+Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@babel/runtime': 7.17.9 + '@react-stately/utils': 3.4.1_react@17.0.2 + '@react-types/radio': 3.1.5_react@17.0.2 + react: 17.0.2 + dev: false + /@react-stately/select/3.1.6_react@17.0.2: resolution: {integrity: sha512-z7RzxLQpTbPqMjI1nrDP3xusXjtdeo3cdKMyyJCXFlifjT0wWm3HhCnAIhpB5LxQUzClckkPulZ35QmZ8NZObA==} peerDependencies: @@ -5827,6 +5860,15 @@ packages: react: 17.0.2 dev: false + /@react-types/radio/3.1.5_react@17.0.2: + resolution: {integrity: sha512-KO6qERL4MQT7wMud4afikpWSdMNFc7+AMBYYxC+9usEEIxgYn98ksvvPc/rekRbnq0WL1T8WAZtYBM/+41qMbA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 + dependencies: + '@react-types/shared': 3.11.2_react@17.0.2 + react: 17.0.2 + dev: false + /@react-types/select/3.5.4_react@17.0.2: resolution: {integrity: sha512-O3kCE/43bfNH3iXLJuMagFnT1qRgBLgK/9Rz00FHLF6WsvE/RYJxlVBsGsu0u9+S1zR+ye6iKdXHdlnrnbQOgw==} peerDependencies: diff --git a/themes/theme-b2b/src/component.ts b/themes/theme-b2b/src/component.ts index 176a3f9127..d1b8b95209 100644 --- a/themes/theme-b2b/src/component.ts +++ b/themes/theme-b2b/src/component.ts @@ -324,22 +324,6 @@ export const component = { mb: 'xsmall', }, }, - radio: { - __default: { - fill: 'gray00', - stroke: 'gray40', - }, - ':disabled': { - fill: 'gray30', - }, - ':error': { - stroke: 'error', - }, - ':checked': { - fill: 'primary', - stroke: 'orange80', - }, - }, select, slider, switch: { diff --git a/themes/theme-b2b/src/components/Radio.style.ts b/themes/theme-b2b/src/components/Radio.style.ts new file mode 100644 index 0000000000..0d6900352b --- /dev/null +++ b/themes/theme-b2b/src/components/Radio.style.ts @@ -0,0 +1,37 @@ +import type { Theme } from '@marigold/components'; + +export const Radio: Theme['components']['Radio'] = { + base: { + label: { + fontSize: 'xsmall', + + '&:disabled': { + color: 'gray40', + }, + }, + radio: { + borderColor: 'gray40', + + '&:hover': { + borderColor: 'gray50', + }, + + '&:focus': { + outline: '2px solid', + outlineColor: 'blue60', + outlineOffset: 3, + }, + + '&:checked': { + color: 'white', + borderColor: 'orange80', + bg: 'primary', + }, + + '&:disabled': { + bg: 'gray30', + borderColor: 'gray40', + }, + }, + }, +}; diff --git a/themes/theme-b2b/src/components/index.ts b/themes/theme-b2b/src/components/index.ts index e8f84985fd..e8e05b4e0b 100644 --- a/themes/theme-b2b/src/components/index.ts +++ b/themes/theme-b2b/src/components/index.ts @@ -11,6 +11,7 @@ export * from './Input.style'; export * from './Label.style'; export * from './Link.style'; export * from './Menu.style'; +export * from './Radio.style'; export * from './Table.style'; export * from './Text.style'; export * from './TextArea.style'; diff --git a/themes/theme-core/src/component.ts b/themes/theme-core/src/component.ts index 264b00a84e..21b8bb66e4 100644 --- a/themes/theme-core/src/component.ts +++ b/themes/theme-core/src/component.ts @@ -333,22 +333,6 @@ export const component = { mb: '8px', }, }, - radio: { - __default: { - fill: 'gray00', - stroke: 'gray40', - }, - ':disabled': { - fill: 'gray30', - }, - ':error': { - stroke: 'error', - }, - ':checked': { - fill: 'blue60', - stroke: 'blue70', - }, - }, select, slider, switch: { diff --git a/themes/theme-core/src/components/Radio.style.ts b/themes/theme-core/src/components/Radio.style.ts new file mode 100644 index 0000000000..6fc16c4cc9 --- /dev/null +++ b/themes/theme-core/src/components/Radio.style.ts @@ -0,0 +1,37 @@ +import type { Theme } from '@marigold/components'; + +export const Radio: Theme['components']['Radio'] = { + base: { + label: { + fontSize: 'xsmall', + + '&:disabled': { + color: 'gray40', + }, + }, + radio: { + borderColor: 'gray40', + + '&:hover': { + borderColor: 'gray50', + }, + + '&:focus': { + outline: '2px solid', + outlineColor: 'blue60', + outlineOffset: 3, + }, + + '&:checked': { + color: 'white', + borderColor: 'blue70', + bg: 'blue60', + }, + + '&:disabled': { + bg: 'gray30', + borderColor: 'gray40', + }, + }, + }, +}; diff --git a/themes/theme-core/src/components/index.ts b/themes/theme-core/src/components/index.ts index 5141ddfefd..7e347e95c0 100644 --- a/themes/theme-core/src/components/index.ts +++ b/themes/theme-core/src/components/index.ts @@ -8,6 +8,7 @@ export * from './Image.style'; export * from './Input.style'; export * from './Label.style'; export * from './Link.style'; +export * from './Radio.style'; export * from './Table.style'; export * from './Text.style'; export * from './TextArea.style'; diff --git a/themes/theme-unicorn/src/component.ts b/themes/theme-unicorn/src/component.ts index e91f0cb207..13b73a1ee9 100644 --- a/themes/theme-unicorn/src/component.ts +++ b/themes/theme-unicorn/src/component.ts @@ -343,22 +343,6 @@ export const component = { mb: 'small', }, }, - radio: { - __default: { - fill: 'gray00', - stroke: 'gray40', - }, - ':disabled': { - fill: 'gray30', - }, - ':error': { - stroke: 'error', - }, - ':checked': { - fill: 'primary', - stroke: '#311b92', - }, - }, select, slider, switch: { diff --git a/themes/theme-unicorn/src/components/Radio.style.ts b/themes/theme-unicorn/src/components/Radio.style.ts new file mode 100644 index 0000000000..59efa9dbf1 --- /dev/null +++ b/themes/theme-unicorn/src/components/Radio.style.ts @@ -0,0 +1,37 @@ +import type { Theme } from '@marigold/components'; + +export const Radio: Theme['components']['Radio'] = { + base: { + label: { + fontSize: 'xsmall', + + '&:disabled': { + color: 'gray40', + }, + }, + radio: { + borderColor: 'gray40', + + '&:hover': { + borderColor: 'gray50', + }, + + '&:focus': { + outline: '2px solid', + outlineColor: 'blue60', + outlineOffset: 3, + }, + + '&:checked': { + color: 'white', + borderColor: 'purple80', + bg: 'primary', + }, + + '&:disabled': { + bg: 'gray30', + borderColor: 'gray40', + }, + }, + }, +}; diff --git a/themes/theme-unicorn/src/components/index.ts b/themes/theme-unicorn/src/components/index.ts index e8f84985fd..e8e05b4e0b 100644 --- a/themes/theme-unicorn/src/components/index.ts +++ b/themes/theme-unicorn/src/components/index.ts @@ -11,6 +11,7 @@ export * from './Input.style'; export * from './Label.style'; export * from './Link.style'; export * from './Menu.style'; +export * from './Radio.style'; export * from './Table.style'; export * from './Text.style'; export * from './TextArea.style';