diff --git a/.changeset/green-weeks-act.md b/.changeset/green-weeks-act.md new file mode 100644 index 0000000000..eec153ca26 --- /dev/null +++ b/.changeset/green-weeks-act.md @@ -0,0 +1,27 @@ +--- +"@channel.io/bezier-react": minor +--- + +Re-implement `Radio` component. + +BREAKING CHANGES + +- Legacy `Radio` component is now exported with namespace `LEGACY__Radio`. It will be deprecated. +- New `Radio` component must be used with the new `RadioGroup` component. See below. + +```tsx +// AS-IS + setWatchingValue(value)} +/> + +// TO-BE + + + +``` diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 36958cfacc..f875e33223 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -17,6 +17,7 @@ /packages/bezier-react/src/components/Forms/ @sungik-choi /packages/bezier-react/src/components/Forms/Slider/ /packages/bezier-react/src/components/Forms/Slider/ @Dogdriip +/packages/bezier-react/src/components/Forms/RadioGroup/ @sungik-choi /packages/bezier-react/src/components/Help/ @dinohan /packages/bezier-react/src/components/Icon/ @sungik-choi /packages/bezier-react/src/components/KeyValueListItem/ @sungik-choi diff --git a/packages/bezier-react/package.json b/packages/bezier-react/package.json index 8b99ad036c..787205f495 100644 --- a/packages/bezier-react/package.json +++ b/packages/bezier-react/package.json @@ -57,6 +57,7 @@ "@channel.io/react-docgen-typescript-plugin": "^1.0.0", "@mdx-js/react": "^1.6.22", "@radix-ui/react-dialog": "^1.0.2", + "@radix-ui/react-radio-group": "^1.1.0", "@radix-ui/react-visually-hidden": "^1.0.1", "@rollup/plugin-babel": "^5.3.0", "@rollup/plugin-commonjs": "^19.0.0", diff --git a/packages/bezier-react/src/components/Forms/FormControl/FormControl.stories.tsx b/packages/bezier-react/src/components/Forms/FormControl/FormControl.stories.tsx index a02f6c0382..8284734fa0 100644 --- a/packages/bezier-react/src/components/Forms/FormControl/FormControl.stories.tsx +++ b/packages/bezier-react/src/components/Forms/FormControl/FormControl.stories.tsx @@ -6,7 +6,7 @@ import type { Story, Meta } from '@storybook/react' /* Internal dependencies */ import { getTitle } from 'Utils/storyUtils' import { SegmentedControl } from 'Components/Forms/SegmentedControl' -import { Radio } from 'Components/Forms/Radio' +import { RadioGroup, Radio } from 'Components/Forms/RadioGroup' import { Checkbox } from 'Components/Forms/Checkbox' import { Text } from 'Components/Text' import { FormGroup } from 'Components/Forms/FormGroup' @@ -41,6 +41,7 @@ Primary.args = { hasError: false, disabled: false, readOnly: false, + required: false, } const WithMultiFormTemplate: Story = (args) => ( @@ -94,31 +95,12 @@ const WithMultiFormTemplate: Story = (args) => ( - Label - -
- Immediately - Year(s) - Seasons -
-
- Hour(s) - Day(s) - Minute(s) -
-
+ Theme Setting + + System Preference + Light Theme + Dark Theme + Description Error!
@@ -139,4 +121,5 @@ WithMultiForm.args = { hasError: false, disabled: false, readOnly: false, + required: false, } diff --git a/packages/bezier-react/src/components/Forms/FormGroup/FormGroup.types.ts b/packages/bezier-react/src/components/Forms/FormGroup/FormGroup.types.ts index a398460ba2..3e8f67247b 100644 --- a/packages/bezier-react/src/components/Forms/FormGroup/FormGroup.types.ts +++ b/packages/bezier-react/src/components/Forms/FormGroup/FormGroup.types.ts @@ -2,14 +2,10 @@ import type { BezierComponentProps, ChildrenProps } from 'Types/ComponentProps' import type { StackProps } from 'Components/Stack' -interface FormGroupOptions { - role?: string -} - interface FormGroupProps extends BezierComponentProps, ChildrenProps, Partial>, - FormGroupOptions {} + React.HTMLAttributes {} export default FormGroupProps diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/Radio.tsx b/packages/bezier-react/src/components/Forms/RadioGroup/Radio.tsx new file mode 100644 index 0000000000..7bf678c3fc --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/Radio.tsx @@ -0,0 +1,32 @@ +/* External dependencies */ +import React, { forwardRef } from 'react' + +/* Internal dependencies */ +import useId from 'Hooks/useId' +import { RadioProps } from './RadioGroup.types' +import * as Styled from './RadioGroup.styled' + +/** + * `Radio` is a checkable button, known as a radio button. + * It is must be a child of `RadioGroup`. + */ +export const Radio = forwardRef(function Radio({ + children, + id: idProp, + ...rest +}: RadioProps, forwardedRef: React.Ref) { + const id = useId(idProp, 'bezier-radio') + + return ( + + { /* @ts-ignore FIXME(@ed): Delete after applying polymorphic props */ } + + { children } + + + ) +}) diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.stories.tsx b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.stories.tsx new file mode 100644 index 0000000000..bc01de8ccb --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.stories.tsx @@ -0,0 +1,56 @@ +/* External dependencies */ +import React, { useState, useEffect } from 'react' +import { base } from 'paths.macro' +import { Meta, Story } from '@storybook/react' + +/* Internal dependencies */ +import { getTitle } from 'Utils/storyUtils' +import { RadioGroup } from './RadioGroup' +import { Radio } from './Radio' +import { RadioGroupProps } from './RadioGroup.types' + +export default { + title: getTitle(base), + component: RadioGroup, + subcomponents: { Radio }, + argTypes: { + direction: { + control: { + type: 'radio', + options: ['vertical', 'horizontal'], + }, + }, + }, +} as Meta + +const Template: Story = ({ + value: valueProp, + ...props +}) => { + const [value, setValue] = useState(() => valueProp) + + useEffect(function watchValueToChange() { + setValue(valueProp) + }, [valueProp]) + + return ( + + System + Light + Dark + + ) +} + +export const Primary = Template.bind({}) +Primary.args = { + value: 'System', + disabled: false, + required: false, + direction: 'vertical', + spacing: 0, +} diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.styled.ts b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.styled.ts new file mode 100644 index 0000000000..e940397ece --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.styled.ts @@ -0,0 +1,98 @@ +/* External dependencies */ +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' + +/* Internal dependencies */ +import { styled, css, Typography } from 'Foundation' +import DisabledOpacity from 'Constants/DisabledOpacity' +import { touchableHover } from 'Utils/styleUtils' +import { Text } from 'Components/Text' +import { FormFieldSize } from 'Components/Forms' +import { focusedInputWrapperStyle } from 'Components/Forms/Inputs/mixins' +import { RadioProps } from './RadioGroup.types' + +const OUTER_INDICATOR_SIZE = 18 +const OUTER_INDICATOR_MARGIN = 1 +const INNER_INDICATOR_SIZE = 8 + +export const RadioGroupPrimitiveItem = styled(RadioGroupPrimitive.Item)` + all: unset; + position: relative; + display: flex; + align-items: center; + width: 100%; + height: ${FormFieldSize.M}px; + cursor: pointer; + + /* Outer Indicator */ + &::before { + position: relative; + box-sizing: border-box; + width: ${OUTER_INDICATOR_SIZE}px; + height: ${OUTER_INDICATOR_SIZE}px; + margin: ${OUTER_INDICATOR_MARGIN}px; + content: ''; + background-color: var(--bg-white-normal); + border-radius: 50%; + box-shadow: inset 0 0 0 2px var(--bdr-black-dark); + } + + /* Inner Indicator */ + &::after { + position: absolute; + top: 50%; + left: ${(OUTER_INDICATOR_SIZE / 2) + OUTER_INDICATOR_MARGIN}px; + width: ${INNER_INDICATOR_SIZE}px; + height: ${INNER_INDICATOR_SIZE}px; + content: ''; + border-radius: 50%; + transform: translate(-50%, -50%); + } + + &[data-disabled] { + cursor: not-allowed; + opacity: ${DisabledOpacity}; + + &::before { + background-color: var(--bg-black-dark); + box-shadow: none; + } + } + + &[data-state='checked'] { + &::before { + background-color: var(--bgtxt-green-normal); + box-shadow: none; + } + + &::after { + background-color: var(--bgtxt-absolute-white-dark); + } + } + + ${touchableHover(css` + &:not([data-disabled])[data-state='checked']::before { + background-color: var(--bgtxt-green-dark); + } + + &:not([data-disabled]):not([data-state='checked'])::after { + background-color: var(--bg-black-dark); + } + `)} + + &:focus-visible { + &::before { + ${focusedInputWrapperStyle} + } + } + + ${({ interpolation }) => interpolation} +` + +export const Label = styled(Text).attrs({ + forwardedAs: 'label', + typo: Typography.Size14, + color: 'txt-black-darkest', +})` + padding-left: 12px; + pointer-events: none; +` diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.test.tsx b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.test.tsx new file mode 100644 index 0000000000..eacef30b6a --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.test.tsx @@ -0,0 +1,167 @@ +/* External dependencies */ +import React from 'react' +import { isInaccessible } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +/* Internal dependencies */ +import { render } from 'Utils/testUtils' +import { RadioGroup } from './RadioGroup' +import { Radio } from './Radio' +import { RadioGroupProps, RadioProps } from './RadioGroup.types' + +const VALUES = ['0', '1', '2'] + +describe('RadioGroup', () => { + const renderRadioGroup = ({ + radioGroupProps, + radioProps, + }: { + radioGroupProps?: RadioGroupProps + radioProps?: Partial + } = {}) => render( + + { VALUES.map(value => ( + + { value } + + )) } + , + ) + + let user: ReturnType + + beforeEach(() => { + user = userEvent.setup() + }) + + describe('ARIA', () => { + it('should be accessible', () => { + const { container } = renderRadioGroup() + expect(isInaccessible(container)).toBe(false) + }) + + it('should have \'role="radiogroup"\' attribute', () => { + const { getByRole } = renderRadioGroup() + expect(getByRole('radiogroup')).toBeInTheDocument() + }) + + it('should be required when required prop is true', () => { + const { getByRole } = renderRadioGroup({ radioGroupProps: { required: true } }) + expect(getByRole('radiogroup')).toBeRequired() + }) + + it('should be disabled when disabled prop is true', () => { + const { getByRole } = renderRadioGroup({ radioGroupProps: { disabled: true } }) + expect(getByRole('radiogroup')).toHaveAttribute('aria-disabled', 'true') + }) + + it('children(Radio) should be disabled when disabled prop is true', () => { + const { getAllByRole } = renderRadioGroup({ radioGroupProps: { disabled: true } }) + const radios = getAllByRole('radio') + radios.forEach(radio => expect(radio).toBeDisabled()) + }) + }) + + describe('User Interactions', () => { + it('should focus and check radio when user clicks on a radio', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + await user.click(radio) + expect(radio).toHaveFocus() + expect(radio).toBeChecked() + }) + + it('should call the change event handler when user clicks on a radio in a controlled radio group', async () => { + const onValueChange = jest.fn() + const { getByRole } = renderRadioGroup({ radioGroupProps: { value: VALUES[0], onValueChange } }) + const radio = getByRole('radio', { name: VALUES[1] }) + await user.click(radio) + expect(onValueChange).toBeCalledTimes(1) + }) + + it('should focus on the first radio item when user presses tab key', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + await user.tab() + expect(radio).toHaveFocus() + }) + + it('should check radio when user presses space key on a focused radio', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + await user.tab() + expect(radio).toHaveFocus() + await user.keyboard('{ }') + expect(radio).toHaveFocus() + expect(radio).toBeChecked() + }) + + it('should focus and check the next radio item when user presses arrow down key on a radio', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + const nextRadio = getByRole('radio', { name: VALUES[1] }) + await user.tab() + expect(radio).toHaveFocus() + await user.keyboard('{ArrowDown}') + expect(nextRadio).toHaveFocus() + // FIXME: Checking behavior does not work in test environment when arrow key is pressed + // expect(nextRadio).toBeChecked() + }) + + it('should focus and check the next radio item when user presses arrow right key on a radio', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + const nextRadio = getByRole('radio', { name: VALUES[1] }) + await user.tab() + expect(radio).toHaveFocus() + await user.keyboard('{ArrowRight}') + expect(nextRadio).toHaveFocus() + // FIXME: Checking behavior does not work in test environment when arrow key is pressed + // expect(nextRadio).toBeChecked() + }) + + it('should focus and check the previous radio item when user presses arrow up key on a radio', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + const nextRadio = getByRole('radio', { name: VALUES[2] }) + await user.tab() + expect(radio).toHaveFocus() + await user.keyboard('{ArrowUp}') + expect(nextRadio).toHaveFocus() + // FIXME: Checking behavior does not work in test environment when arrow key is pressed + // expect(nextRadio).toBeChecked() + }) + + it('should focus and check the previous radio item when user presses arrow left key on a radio', async () => { + const { getByRole } = renderRadioGroup() + const radio = getByRole('radio', { name: VALUES[0] }) + const nextRadio = getByRole('radio', { name: VALUES[2] }) + await user.tab() + expect(radio).toHaveFocus() + await user.keyboard('{ArrowLeft}') + expect(nextRadio).toHaveFocus() + // FIXME: Checking behavior does not work in test environment when arrow key is pressed + // expect(nextRadio).toBeChecked() + }) + }) + + describe('Radio', () => { + describe('ARIA', () => { + it('should have \'role="radio"\' attribute', () => { + const { getAllByRole } = renderRadioGroup() + const radios = getAllByRole('radio') + radios.forEach(radio => expect(radio).toBeInTheDocument()) + }) + + it('should be disabled when disabled prop is true', () => { + const { getAllByRole } = renderRadioGroup({ radioProps: { disabled: true } }) + const radios = getAllByRole('radio') + radios.forEach(radio => expect(radio).toBeDisabled()) + }) + }) + }) +}) diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.tsx b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.tsx new file mode 100644 index 0000000000..277f54f212 --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.tsx @@ -0,0 +1,53 @@ +/* External dependencies */ +import React, { forwardRef } from 'react' +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' + +/* Internal dependencies */ +import { Stack, StackItem } from 'Components/Stack' +import useFormFieldProps from 'Components/Forms/useFormFieldProps' +import { RadioGroupProps } from './RadioGroup.types' + +/** + * `RadioGroup` is a set of checkable buttons, known as radio buttons. + * + * `RadioGroup` is a context of the `Radio` components. also, it renders an element which has the 'radiogroup' role. + * It controls all the parts of a radio group. + * + * @example + * + * ```tsx + * // Anatomy of the RadioGroup + * + * + * + * ``` + */ +export const RadioGroup = forwardRef(function RadioGroup({ + children, + spacing = 0, + direction = 'vertical', + ...rest +}: RadioGroupProps, forwardedRef: React.Ref) { + const formFieldProps = useFormFieldProps(rest) + + return ( + + + { React.Children.map(children, (child, index) => ( + + { child } + + )) } + + + ) +}) diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.types.ts b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.types.ts new file mode 100644 index 0000000000..d1fc0cfd77 --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/RadioGroup.types.ts @@ -0,0 +1,67 @@ +/* External dependencies */ +import * as RadioGroupPrimitive from '@radix-ui/react-radio-group' + +/* Internal dependencies */ +import { BezierComponentProps, ChildrenProps } from 'Types/ComponentProps' +import { FormComponentProps } from 'Components/Forms' +import { StackProps } from 'Components/Stack' + +interface RadioGroupOptions { + /** + * The controlled value of the radio item to check. + * Should be used in conjunction with `onValueChange`. + */ + value?: string + /** + * The value of the radio item that should be checked when initially rendered. + * Use when you do not need to control the state of the radio items. + */ + defaultValue?: string + /** + * The name of the group. + * Submitted with its owning form as part of a name/value pair. + */ + name?: string + /** + * Default spacing between Radio, in pixels. + * @default 0 + */ + spacing?: StackProps['spacing'] + /** + * Direction of this RadioGroup. + * @default vertical + */ + direction?: StackProps['direction'] + /** + * Event handler called when the value changes. + */ + onValueChange?: (value: string) => void +} + +interface RadioOptions { + /** + * The value given as data when submitted with a `RadioGroupProps.name`. + */ + value: string + /** + * The unique id of the radio item. It is created automatically by default. + * It used by the label element in the radio item. + */ + id?: string +} + +type RadioFormComponentProps = Pick + +export interface RadioGroupProps extends + BezierComponentProps, + ChildrenProps, + RadioFormComponentProps, + Omit, keyof RadioGroupOptions | keyof RadioGroupPrimitive.RadioGroupProps>, + RadioGroupOptions {} + +export interface RadioProps extends + BezierComponentProps, + ChildrenProps, + RadioFormComponentProps, + Omit, keyof RadioOptions>, + RadioOptions {} diff --git a/packages/bezier-react/src/components/Forms/RadioGroup/index.ts b/packages/bezier-react/src/components/Forms/RadioGroup/index.ts new file mode 100644 index 0000000000..04c17cb8d6 --- /dev/null +++ b/packages/bezier-react/src/components/Forms/RadioGroup/index.ts @@ -0,0 +1,3 @@ +export { RadioGroup } from './RadioGroup' +export { Radio } from './Radio' +export type { RadioGroupProps, RadioProps } from './RadioGroup.types' diff --git a/packages/bezier-react/src/index.ts b/packages/bezier-react/src/index.ts index f08cb05b19..b035928b91 100644 --- a/packages/bezier-react/src/index.ts +++ b/packages/bezier-react/src/index.ts @@ -23,7 +23,8 @@ export * from 'Components/Forms/Inputs/mixins' export * from 'Components/Forms/Inputs/Select' export * from 'Components/Forms/Inputs/TextArea' export * from 'Components/Forms/Inputs/TextField' -export * from 'Components/Forms/Radio' +export * as LEGACY__Radio from 'Components/Forms/Radio' +export * from 'Components/Forms/RadioGroup' export * from 'Components/Forms/SegmentedControl' export * from 'Components/Forms/Slider' export * from 'Components/Forms/Switch' diff --git a/packages/bezier-react/src/utils/styleUtils.ts b/packages/bezier-react/src/utils/styleUtils.ts index 23fa3713bd..b9b3ffbe96 100644 --- a/packages/bezier-react/src/utils/styleUtils.ts +++ b/packages/bezier-react/src/utils/styleUtils.ts @@ -99,3 +99,19 @@ export function gap(spacing: number): InjectedInterpolation { } ` } + +export function touchableHover(interpolation: InjectedInterpolation): InjectedInterpolation { + return css` + @media (hover: hover) { + &:hover { + ${interpolation} + } + } + + @media (hover: none) { + &:active { + ${interpolation} + } + } + ` +} diff --git a/yarn.lock b/yarn.lock index ebc343848e..7f245c6ade 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1889,6 +1889,7 @@ __metadata: "@channel.io/react-docgen-typescript-plugin": ^1.0.0 "@mdx-js/react": ^1.6.22 "@radix-ui/react-dialog": ^1.0.2 + "@radix-ui/react-radio-group": ^1.1.0 "@radix-ui/react-separator": ^1.0.0 "@radix-ui/react-slider": ^1.0.0 "@radix-ui/react-switch": ^1.0.1 @@ -3351,6 +3352,28 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-radio-group@npm:^1.1.0": + version: 1.1.0 + resolution: "@radix-ui/react-radio-group@npm:1.1.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-context": 1.0.0 + "@radix-ui/react-direction": 1.0.0 + "@radix-ui/react-presence": 1.0.0 + "@radix-ui/react-primitive": 1.0.1 + "@radix-ui/react-roving-focus": 1.0.1 + "@radix-ui/react-use-controllable-state": 1.0.0 + "@radix-ui/react-use-previous": 1.0.0 + "@radix-ui/react-use-size": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: d8a0e418d37c5279701a2eee2a96fe398577511ff342f80a46be2adaa1590228152f255e6d00fd40d87d7c151884e48dfe5230236c10d4e6ce6e8f3baa5f40ed + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-roving-focus@npm:1.0.1" @@ -3372,7 +3395,7 @@ __metadata: languageName: node linkType: hard -"@radix-ui/react-separator@npm:1.0.1, @radix-ui/react-separator@npm:^1.0.0": +"@radix-ui/react-separator@npm:1.0.1": version: 1.0.1 resolution: "@radix-ui/react-separator@npm:1.0.1" dependencies: @@ -3385,6 +3408,27 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-separator@npm:^1.0.0": + version: 1.0.0 + resolution: "@radix-ui/react-separator@npm:1.0.0" + dependencies: + "@babel/runtime": ^7.13.10 + "@radix-ui/primitive": 1.0.0 + "@radix-ui/react-collection": 1.0.1 + "@radix-ui/react-compose-refs": 1.0.0 + "@radix-ui/react-context": 1.0.0 + "@radix-ui/react-direction": 1.0.0 + "@radix-ui/react-id": 1.0.0 + "@radix-ui/react-primitive": 1.0.1 + "@radix-ui/react-use-callback-ref": 1.0.0 + "@radix-ui/react-use-controllable-state": 1.0.0 + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + checksum: 19056da055ec89ce0613a3d60608f13e27b778228fbb0df7ada3ebda89c292e71a514ab6e0061a74d0a0d4963354fde1ffd8b8848628cb3275f216d05423a7a5 + languageName: node + linkType: hard + "@radix-ui/react-slider@npm:^1.0.0": version: 1.0.0 resolution: "@radix-ui/react-slider@npm:1.0.0"