Skip to content

Commit

Permalink
feat(components): add radio component
Browse files Browse the repository at this point in the history
  • Loading branch information
danielsimao committed Nov 29, 2023
1 parent 4f932e7 commit 6480e9e
Show file tree
Hide file tree
Showing 11 changed files with 3,559 additions and 3,066 deletions.
1 change: 1 addition & 0 deletions .eslintcache

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"@react-aria/meter": "^3.4.4",
"@react-aria/overlays": "^3.16.0",
"@react-aria/progress": "^3.4.4",
"@react-aria/radio": "^3.9.0",
"@react-aria/select": "^3.12.0",
"@react-aria/selection": "^3.16.2",
"@react-aria/separator": "^3.3.4",
Expand All @@ -70,6 +71,7 @@
"@react-stately/collections": "^3.10.0",
"@react-stately/list": "^3.9.1",
"@react-stately/overlays": "^3.6.1",
"@react-stately/radio": "^3.10.0",
"@react-stately/select": "^3.5.3",
"@react-stately/slider": "^3.4.1",
"@react-stately/table": "^3.11.0",
Expand Down
4 changes: 2 additions & 2 deletions packages/components/src/Input/Input.style.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ const StyledBaseInput = styled.input<BaseInputProps>`
props.$isDisabled
? theme.input.disabled.border
: props.$hasError
? theme.input.error.border
: theme.border.default};
? theme.input.error.border
: theme.border.default};
border-radius: ${theme.rounded.lg};
transition:
border-color ${theme.transition.duration.duration150}ms ease-in-out,
Expand Down
79 changes: 79 additions & 0 deletions packages/components/src/Radio/Radio.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Meta, StoryFn, StoryObj } from '@storybook/react';
import { useState } from 'react';

import { Card } from '../Card';

import { Radio, RadioGroupProps, RadioGroup } from '.';

const Render = (args: RadioGroupProps) => (
<RadioGroup {...args} label='Coin'>
<Card>
<Radio value='BTC'>BTC</Radio>
</Card>
<Radio value='ETH'>ETH</Radio>
</RadioGroup>
);

export default {
title: 'Forms/Radio',
component: RadioGroup,
parameters: {
layout: 'centered'
},
render: Render
} as Meta<typeof RadioGroup>;

export const Default: StoryObj<RadioGroupProps> = {};

export const Controlled: StoryFn<RadioGroupProps> = (args) => {
const [state, setState] = useState<string>('ETH');

return <Render {...args} value={state} onValueChange={(value) => setState(value)} />;
};

export const DefaultValue: StoryObj<RadioGroupProps> = {
args: {
defaultValue: 'ETH'
}
};

export const Horizontal: StoryObj<RadioGroupProps> = {
args: {
orientation: 'horizontal'
}
};

export const SideLabel: StoryObj<RadioGroupProps> = {
args: {
labelPosition: 'side'
}
};

export const Disabled: StoryObj<RadioGroupProps> = {
args: {
isDisabled: true
}
};

export const SingleDisable: StoryObj<RadioGroupProps> = {
render: (args: RadioGroupProps) => (
<RadioGroup {...args} label='Coin'>
<Radio value='BTC'>BTC</Radio>
<Radio isDisabled value='ETH'>
ETH
</Radio>
</RadioGroup>
)
};

export const WithErrorMessage: StoryObj<RadioGroupProps> = {
args: {
errorMessage: 'Please select a valid coin'
}
};

export const WithDescription: StoryObj<RadioGroupProps> = {
args: {
description: 'Please select a valid coin'
}
};
71 changes: 71 additions & 0 deletions packages/components/src/Radio/Radio.style.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import styled from 'styled-components';
import { Orientation, theme } from '@interlay/theme';

import { Flex } from '../Flex';
import { visuallyHidden } from '../utils/visually-hidden';
import { Label } from '../Label';

type StyledRadioGroupProps = {
$orientation: Orientation;
};

type StyledLabelProps = {
$isDisabled?: boolean;
};

type StyledButtonProps = {
$isSelected: boolean;
$isHovered: boolean;
};

const StyledRadioGroup = styled(Flex)<StyledRadioGroupProps>`
label {
margin-right: ${({ $orientation }) => $orientation && theme.spacing.spacing4};
}
`;

const StyledLabel = styled(Label)<StyledLabelProps>`
padding: 0;
display: flex;
gap: ${theme.spacing.spacing2};
align-items: center;
opacity: ${({ $isDisabled }) => $isDisabled && 0.5};
`;

const StyledInput = styled.input`
${visuallyHidden()}
`;

const StyledButton = styled.span<StyledButtonProps>`
position: relative;
flex-grow: 0;
flex-shrink: 0;
width: 24px;
height: 24px;
margin: ${theme.spacing.spacing2} 0;
outline: none;
border-width: 2px;
border-style: solid;
border-color: ${({ $isSelected }) => ($isSelected ? theme.colors.textSecondary : theme.colors.textPrimary)};
border-radius: 50%;
opacity: ${({ $isHovered }) => $isHovered && 0.9};
transition:
border-color ${theme.transition.duration.duration100}ms ease-in-out,
opacity ${theme.transition.duration.duration100}ms ease-in-out;
&::after {
content: '';
border-radius: 50%;
position: absolute;
transition: border-width ${theme.transition.duration.duration100}ms ease-in-out;
border-color: ${theme.colors.textSecondary};
border-style: solid;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-width: ${({ $isSelected }) => ($isSelected ? '7px' : 0)};
opacity: inherit;
}
`;

export { StyledLabel, StyledRadioGroup, StyledButton, StyledInput };
56 changes: 56 additions & 0 deletions packages/components/src/Radio/Radio.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { useDOMRef } from '@interlay/hooks';
import { Placement } from '@interlay/theme';
import { useHover } from '@react-aria/interactions';
import { AriaRadioProps, useRadio } from '@react-aria/radio';
import { HTMLAttributes, forwardRef, useRef } from 'react';

import { Span, TextProps } from '../Text';

import { useRadioProvider } from './RadioContext';
import { StyledButton, StyledInput, StyledLabel } from './Radio.style';

type Props = {
labelProps?: TextProps;
labelPlacement?: Extract<Placement, 'left' | 'right'>;
};

type NativeAttrs = Omit<HTMLAttributes<unknown>, keyof Props>;

type InheritAttrs = Omit<AriaRadioProps, keyof Props>;

type RadioProps = Props & NativeAttrs & InheritAttrs;

// TODO: determine if isInvalid is necessary
const Radio = forwardRef<HTMLLabelElement, RadioProps>(
({ labelProps, isDisabled: isDisabledProp, children, ...props }, ref): JSX.Element => {
let { hoverProps, isHovered } = useHover({ isDisabled: isDisabledProp });

const labelRef = useDOMRef(ref);
const inputRef = useRef<HTMLInputElement>(null);

const state = useRadioProvider();

const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
children,
isDisabled: isDisabledProp
},
state,
inputRef
);

return (
<StyledLabel {...labelProps} {...hoverProps} ref={labelRef} $isDisabled={isDisabled}>
<StyledInput {...inputProps} ref={inputRef} />
<StyledButton $isHovered={isHovered} $isSelected={isSelected} />
{children && <Span size='xs'>{children}</Span>}
</StyledLabel>
);
}
);

Radio.displayName = 'Radio';

export { Radio };
export type { RadioProps };
10 changes: 10 additions & 0 deletions packages/components/src/Radio/RadioContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { RadioGroupState } from '@react-stately/radio';
import React, { useContext } from 'react';

type RadioGroupContext = RadioGroupState;

export const RadioContext = React.createContext<RadioGroupContext>({} as RadioGroupContext);

export function useRadioProvider(): RadioGroupContext {
return useContext(RadioContext);
}
57 changes: 57 additions & 0 deletions packages/components/src/Radio/RadioGroup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useDOMRef } from '@interlay/hooks';
import { Orientation } from '@interlay/theme';
import { AriaRadioGroupProps, useRadioGroup } from '@react-aria/radio';
import { ChangeEvent, forwardRef } from 'react';
import { useRadioGroupState } from '@react-stately/radio';

import { Field, FieldProps, useFieldProps } from '../Field';

import { RadioContext } from './RadioContext';
import { StyledRadioGroup } from './Radio.style';

type Props = {
orientation?: Orientation;
onChange?: (e: ChangeEvent<HTMLDivElement>) => void;
onValueChange?: (value: string) => void;
};

type InheritAttrs = Omit<AriaRadioGroupProps, keyof Props | 'errorMessage' | 'description'>;

type FieldAttrs = Omit<FieldProps, keyof (Props & InheritAttrs)>;

type RadioGroupProps = Props & FieldAttrs & InheritAttrs;

const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
({ orientation = 'vertical', children, onValueChange, onChange, ...props }, ref): JSX.Element => {
const { fieldProps } = useFieldProps(props);

let domRef = useDOMRef(ref);
let state = useRadioGroupState({ onChange: onValueChange, ...props });
let { radioGroupProps, labelProps, descriptionProps, errorMessageProps } = useRadioGroup(props, state);

return (
<Field
{...fieldProps}
ref={domRef}
descriptionProps={descriptionProps}
elementType='span'
errorMessageProps={errorMessageProps}
labelProps={labelProps}
>
<StyledRadioGroup
{...radioGroupProps}
$orientation={orientation}
direction={orientation === 'vertical' ? 'column' : 'row'}
onChange={onChange}
>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</StyledRadioGroup>
</Field>
);
}
);

RadioGroup.displayName = 'RadioGroup';

export { RadioGroup };
export type { RadioGroupProps };
51 changes: 51 additions & 0 deletions packages/components/src/Radio/__tests__/Radio.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { render } from '@testing-library/react';
import { createRef } from 'react';
import { testA11y } from '@interlay/test-utils';

import { RadioGroup } from '../RadioGroup';
import { Radio } from '../Radio';

describe('Radio', () => {
it('should render correctly', () => {
const wrapper = render(
<RadioGroup aria-label='Coin'>
<Radio value='BTC'>BTC</Radio>
<Radio isDisabled value='ETH'>
ETH
</Radio>
</RadioGroup>
);

expect(() => wrapper.unmount()).not.toThrow();
});

it('ref should be forwarded', () => {
const ref = createRef<HTMLDivElement>();
const radioRef = createRef<HTMLLabelElement>();

render(
<RadioGroup ref={ref} aria-label='Coin'>
<Radio ref={radioRef} value='BTC'>
BTC
</Radio>
<Radio isDisabled value='ETH'>
ETH
</Radio>
</RadioGroup>
);

expect(ref.current).not.toBeNull();
expect(radioRef.current).not.toBeNull();
});

it('should pass a11y', async () => {
await testA11y(
<RadioGroup aria-label='Coin'>
<Radio value='BTC'>BTC</Radio>
<Radio isDisabled value='ETH'>
ETH
</Radio>
</RadioGroup>
);
});
});
4 changes: 4 additions & 0 deletions packages/components/src/Radio/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type { RadioProps } from './Radio';
export { Radio } from './Radio';
export type { RadioGroupProps } from './RadioGroup';
export { RadioGroup } from './RadioGroup';
Loading

0 comments on commit 6480e9e

Please sign in to comment.