refactor(component): new Radio component (#6910)

# New `RadioGroup` component to replace `RadioButton`

![CleanShot 2024-06-25 at 17.18.02.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/77241b07-a2dd-4d27-a322-3056f483f75a.gif)

### what's new
- [x] Change the API
- [x] More customizable options
- [x] Indicator animation
- [x] Dynamic width support(responsive)
- [x] Storybook
- [x] JSDoc
This commit is contained in:
CatsJuice 2024-06-27 06:04:19 +00:00
parent f15d1911ee
commit 827c952e9f
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
12 changed files with 551 additions and 15 deletions

View File

@ -102,7 +102,7 @@
"@vanilla-extract/css": "^1.14.2",
"fake-indexeddb": "^6.0.0",
"storybook": "^7.6.17",
"storybook-dark-mode": "^4.0.0",
"storybook-dark-mode": "4.0.1",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vitest": "1.6.0",

View File

@ -1,4 +1,3 @@
// TODO(@catsjuice): Check `input` , `loading`, not migrated from `design`
export * from './lit-react';
export * from './styles';
export * from './ui/avatar';
@ -18,6 +17,7 @@ export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';
export * from './ui/popover';
export * from './ui/radio';
export * from './ui/scrollbar';
export * from './ui/skeleton';
export * from './ui/switch';

View File

@ -2,19 +2,27 @@ import type {
RadioGroupItemProps,
RadioGroupProps,
} from '@radix-ui/react-radio-group';
import * as RadioGroup from '@radix-ui/react-radio-group';
import * as RadixRadioGroup from '@radix-ui/react-radio-group';
import clsx from 'clsx';
import type { CSSProperties } from 'react';
import { forwardRef } from 'react';
import { RadioGroup } from '../radio';
import * as styles from './styles.css';
// for reference
RadioGroup;
/**
* @deprecated
* use {@link RadioGroup } instead
*/
export const RadioButton = forwardRef<
HTMLButtonElement,
RadioGroupItemProps & { spanStyle?: string }
>(({ children, className, spanStyle, ...props }, ref) => {
return (
<RadioGroup.Item
<RadixRadioGroup.Item
ref={ref}
{...props}
className={clsx(styles.radioButton, className)}
@ -22,27 +30,31 @@ export const RadioButton = forwardRef<
<span className={clsx(styles.radioUncheckedButton, spanStyle)}>
{children}
</span>
<RadioGroup.Indicator
<RadixRadioGroup.Indicator
className={clsx(styles.radioButtonContent, spanStyle)}
>
{children}
</RadioGroup.Indicator>
</RadioGroup.Item>
</RadixRadioGroup.Indicator>
</RadixRadioGroup.Item>
);
});
RadioButton.displayName = 'RadioButton';
/**
* @deprecated
* use {@link RadioGroup} instead
*/
export const RadioButtonGroup = forwardRef<
HTMLDivElement,
RadioGroupProps & { width?: CSSProperties['width'] }
>(({ className, style, width, ...props }, ref) => {
return (
<RadioGroup.Root
<RadixRadioGroup.Root
ref={ref}
className={clsx(styles.radioButtonGroup, className)}
style={{ width, ...style }}
{...props}
></RadioGroup.Root>
></RadixRadioGroup.Root>
);
});
RadioButtonGroup.displayName = 'RadioButtonGroup';

View File

@ -0,0 +1,2 @@
export * from './radio';
export type { RadioItem, RadioProps } from './types';

View File

@ -0,0 +1,149 @@
import { AiIcon, FrameIcon, TocIcon, TodayIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { useState } from 'react';
import { ResizePanel } from '../resize-panel/resize-panel';
import { RadioGroup } from './radio';
import type { RadioItem } from './types';
export default {
title: 'UI/RadioGroup',
};
export const FixedWidth = () => {
const [value, setValue] = useState('Radio 1');
return (
<>
<p style={{ marginBottom: 10, fontSize: cssVar('fontXs') }}>
width:&nbsp;
<code
style={{
padding: '2px 4px',
borderRadius: 3,
background: cssVar('hoverColorFilled'),
}}
>
300px
</code>
</p>
<RadioGroup
width={300}
value={value}
onChange={setValue}
items={['Radio 1', 'Radio 2, Longer', 'S3']}
/>
</>
);
};
export const AutoWidth = () => {
const [value, setValue] = useState('Radio 1');
return (
<RadioGroup
value={value}
onChange={setValue}
items={['Radio 1', 'Radio 2, Longer', 'S3']}
/>
);
};
export const DynamicWidth = () => {
const [value, setValue] = useState('Radio 1');
return (
<ResizePanel
horizontal
vertical={false}
maxWidth={1080}
minWidth={235}
width={250}
>
<RadioGroup
width="100%"
value={value}
onChange={setValue}
items={['Radio 1', 'Radio 2, Longer', 'S3']}
/>
</ResizePanel>
);
};
export const IconTabs = () => {
const [value, setValue] = useState('ai');
const items: RadioItem[] = [
{
value: 'ai',
label: <AiIcon width={20} height={20} />,
style: { width: 28 },
testId: 'ai-radio',
},
{
value: 'calendar',
label: <TodayIcon width={20} height={20} />,
style: { width: 28 },
testId: 'calendar-radio',
},
{
value: 'outline',
label: <TocIcon width={20} height={20} />,
style: { width: 28 },
testId: 'outline-radio',
},
{
value: 'frame',
label: <FrameIcon width={20} height={20} />,
style: { width: 28 },
testId: 'frame-radio',
},
];
return (
<RadioGroup
value={value}
onChange={setValue}
items={items}
padding={4}
borderRadius={12}
gap={8}
/>
);
};
export const CustomizeActiveStyle = () => {
const [value, setValue] = useState('ai');
const items: RadioItem[] = [
{
value: 'ai',
label: <AiIcon width={20} height={20} />,
style: { width: 28 },
testId: 'ai-radio',
},
{
value: 'calendar',
label: <TodayIcon width={20} height={20} />,
style: { width: 28 },
testId: 'calendar-radio',
},
{
value: 'outline',
label: <TocIcon width={20} height={20} />,
style: { width: 28 },
testId: 'outline-radio',
},
{
value: 'frame',
label: <FrameIcon width={20} height={20} />,
style: { width: 28 },
testId: 'frame-radio',
},
];
return (
<RadioGroup
activeItemStyle={{ color: cssVar('primaryColor') }}
value={value}
onChange={setValue}
items={items}
padding={4}
borderRadius={12}
gap={8}
/>
);
};

View File

@ -0,0 +1,185 @@
import * as RadixRadioGroup from '@radix-ui/react-radio-group';
import { assignInlineVars } from '@vanilla-extract/dynamic';
import clsx from 'clsx';
import { createRef, memo, useCallback, useMemo, useRef } from 'react';
import { withUnit } from '../../utils/with-unit';
import * as styles from './styles.css';
import type { RadioItem, RadioProps } from './types';
/**
* ### Radio button group (Tabs)
* A tab-like radio button group
*
* #### 1. Basic usage with fixed width
* ```tsx
* <RadioGroup
* width={300}
* value={value}
* onChange={setValue}
* items={['Radio 1', 'Radio 2, Longer', 'S3']}
* />
* ```
*
* #### 2. Dynamic width
* ```tsx
* <RadioGroup
* width="100%"
* value={value}
* onChange={setValue}
* items={['Radio 1', 'Radio 2, Longer', 'S3']}
* />
* ```
*
* #### 3. `ReactNode` as label
* ```tsx
* const [value, setValue] = useState('ai');
* const items: RadioItem[] = [
* {
* value: 'ai',
* label: <AiIcon width={20} height={20} />,
* style: { width: 28 },
* },
* {
* value: 'calendar',
* label: <TodayIcon width={20} height={20} />,
* style: { width: 28 },
* },
* ];
* return (
* <RadioGroup
* value={value}
* onChange={setValue}
* items={items}
* padding={4}
* borderRadius={12}
* gap={8}
* />
* );
* ```
*/
export const RadioGroup = memo(function RadioGroup({
items,
value,
width,
style,
padding = 2,
gap = 4,
borderRadius = 10,
itemHeight = 28,
animationDuration = 250,
animationEasing = 'cubic-bezier(.18,.22,0,1)',
activeItemClassName,
activeItemStyle,
onChange,
}: RadioProps) {
const animationTImerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const finalItems = useMemo(() => {
return items
.map(value =>
typeof value === 'string' ? ({ value } as RadioItem) : value
)
.map(item => ({
...item,
ref: createRef<HTMLButtonElement>(),
indicatorRef: createRef<HTMLDivElement>(),
}));
}, [items]);
const finalStyle = useMemo(
() => ({
width,
...style,
...assignInlineVars({
[styles.outerPadding]: withUnit(padding, 'px'),
[styles.outerRadius]: withUnit(borderRadius, 'px'),
[styles.itemGap]: withUnit(gap, 'px'),
[styles.itemHeight]: withUnit(itemHeight, 'px'),
}),
}),
[width, style, padding, borderRadius, gap, itemHeight]
);
const animate = useCallback(
(oldValue?: string, newValue?: string) => {
if (!oldValue || !newValue) return;
const oldItem = finalItems.find(item => item.value === oldValue);
const newItem = finalItems.find(item => item.value === newValue);
if (!oldItem || !newItem) return;
const oldRect = oldItem.ref.current?.getBoundingClientRect();
const newRect = newItem.ref.current?.getBoundingClientRect();
if (!oldRect || !newRect) return;
const activeIndicator = newItem.indicatorRef.current;
if (!activeIndicator) return;
activeIndicator.style.transform = `translate3d(${oldRect.left - newRect.left}px,0,0)`;
activeIndicator.style.transition = 'none';
activeIndicator.style.width = `${oldRect.width}px`;
const animation = `${withUnit(animationDuration, 'ms')} ${animationEasing}`;
if (animationTImerRef.current) clearTimeout(animationTImerRef.current);
animationTImerRef.current = setTimeout(() => {
animationTImerRef.current = null;
activeIndicator.style.transition = `transform ${animation}, width ${animation}`;
activeIndicator.style.transform = 'none';
activeIndicator.style.width = '';
}, 50);
},
[animationDuration, animationEasing, finalItems]
);
const onValueChange = useCallback(
(newValue: string) => {
const oldValue = value;
if (oldValue !== newValue) {
onChange(newValue);
animate(oldValue, newValue);
}
},
[animate, onChange, value]
);
return (
<RadixRadioGroup.Root
value={value}
onValueChange={onValueChange}
className={styles.radioButtonGroup}
style={finalStyle}
>
{finalItems.map(({ customRender, ...item }, index) => {
const testId = item.testId ? { 'data-testid': item.testId } : {};
const active = item.value === value;
const classMap = { [styles.radioButton]: true };
if (activeItemClassName) classMap[activeItemClassName] = active;
if (item.className) classMap[item.className] = true;
const style = { ...item.style };
if (activeItemStyle && active) Object.assign(style, activeItemStyle);
return (
<RadixRadioGroup.Item
ref={item.ref}
key={item.value}
value={item.value}
className={clsx(classMap)}
style={style}
{...testId}
{...item.attrs}
>
<RadixRadioGroup.Indicator
forceMount
className={styles.indicator}
ref={item.indicatorRef}
/>
<span className={styles.radioButtonContent}>
{customRender
? customRender(item, index)
: item.label ?? item.value}
</span>
</RadixRadioGroup.Item>
);
})}
</RadixRadioGroup.Root>
);
});

View File

@ -0,0 +1,65 @@
import { cssVar } from '@toeverything/theme';
import { createVar, globalStyle, style } from '@vanilla-extract/css';
export const outerPadding = createVar('radio-outer-padding');
export const outerRadius = createVar('radio-outer-radius');
export const itemGap = createVar('radio-item-gap');
export const itemHeight = createVar('radio-item-height');
export const radioButton = style({
flex: 1,
position: 'relative',
borderRadius: `calc(${outerRadius} - ${outerPadding})`,
height: itemHeight,
padding: '4px 8px',
fontSize: cssVar('fontXs'),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: cssVar('textSecondaryColor'),
whiteSpace: 'nowrap',
userSelect: 'none',
fontWeight: 600,
selectors: {
'&[data-state="checked"]': {
color: cssVar('textPrimaryColor'),
},
'&[data-state="unchecked"]:hover': {
background: cssVar('hoverColor'),
},
},
});
export const radioButtonContent = style({
zIndex: 1,
display: 'block',
});
globalStyle(`${radioButtonContent} > svg`, { display: 'block' });
export const radioButtonGroup = style({
display: 'inline-flex',
alignItems: 'center',
background: cssVar('hoverColorFilled'),
borderRadius: outerRadius,
padding: outerPadding,
gap: itemGap,
// @ts-expect-error - fix electron drag
WebkitAppRegion: 'no-drag',
});
export const indicator = style({
position: 'absolute',
borderRadius: 'inherit',
width: '100%',
height: '100%',
left: 0,
top: 0,
background: cssVar('white'),
filter: 'drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.1))',
opacity: 0,
transformOrigin: 'left',
selectors: {
'[data-state="checked"] > &': {
opacity: 1,
},
},
});

View File

@ -0,0 +1,68 @@
import type { RadioGroupItemProps } from '@radix-ui/react-radio-group';
import type { CSSProperties, ReactNode } from 'react';
type SimpleRadioItem = string;
export interface RadioProps extends RadioGroupItemProps {
items: RadioItem[] | SimpleRadioItem[];
value: any;
onChange: (value: any) => void;
/**
* Total width of the radio group, items will be evenly distributed
*/
width?: CSSProperties['width'];
/**
* Distance between outer wrapper and items (in pixels)
* @default 2
*/
padding?: number;
/**
* Distance between items (in pixels)
* @default 4
*/
gap?: number;
/**
* Outer border radius (in pixels), the inner item's border radius will be calculated based on `padding` and `borderRadius`
* @default 10
*/
borderRadius?: number;
/**
* Height of the inner item (in pixels)
* @default 28
*/
itemHeight?: number;
/**
* Custom duration for the indicator animation
* @default 250
*/
animationDuration?: number | string;
/**
* Custom easing function for the indicator animation
* @default 'cubic-bezier(.18,.22,0,1)'
*/
animationEasing?: string;
/** Customize active item's className */
activeItemClassName?: string;
/** Customize active item's style */
activeItemStyle?: CSSProperties;
}
export interface RadioItem {
value: string;
label?: ReactNode;
style?: CSSProperties;
className?: string;
/** bind `data-testid` */
testId?: string;
/** Customize button-element's html attributes */
attrs?: Partial<
Omit<React.HTMLAttributes<HTMLButtonElement>, 'className' | 'style'>
> &
Record<`data-${string}`, string>;
customRender?: (
item: Omit<RadioItem, 'customRender'>,
index: number
) => ReactNode;
}

View File

@ -37,6 +37,7 @@ export const ResizePanel = ({
}: ResizePanelProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const cornerHandleRef = useRef<HTMLDivElement | null>(null);
const displayRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!containerRef.current || !cornerHandleRef.current) return;
@ -48,6 +49,7 @@ export const ResizePanel = ({
let startSize: [number, number] = [0, 0];
const onDragStart = (e: MouseEvent) => {
containerEl.dataset.resizing = 'true';
startPos = [e.clientX, e.clientY];
startSize = [containerEl.offsetWidth, containerEl.offsetHeight];
document.addEventListener('mousemove', onDrag);
@ -62,6 +64,7 @@ export const ResizePanel = ({
};
const onDragEnd = () => {
containerEl.dataset.resizing = 'false';
document.removeEventListener('mousemove', onDrag);
document.removeEventListener('mouseup', onDragEnd);
};
@ -84,6 +87,10 @@ export const ResizePanel = ({
);
containerEl.style.height = `${height}px`;
}
if (displayRef.current) {
displayRef.current.textContent = `${containerEl.offsetWidth}px * ${containerEl.offsetHeight}px`;
}
};
updateSize([width ?? 400, height ?? 200]);
@ -112,7 +119,9 @@ export const ResizePanel = ({
{...attrs}
>
{children}
<div ref={cornerHandleRef} className={styles.cornerHandle}></div>
<div ref={cornerHandleRef} className={styles.cornerHandle}>
<div ref={displayRef} className={styles.display}></div>
</div>
</div>
);
};

View File

@ -1,3 +1,4 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
const HANDLE_SIZE = 24;
export const container = style({
@ -19,3 +20,29 @@ export const cornerHandle = style({
transform: 'rotate(45deg)',
cursor: 'nwse-resize',
});
export const display = style({
position: 'absolute',
left: 32,
top: 12,
transform: 'rotate(-45deg)',
transformOrigin: '0 0',
whiteSpace: 'nowrap',
borderRadius: 6,
background: cssVar('black'),
color: cssVar('white'),
borderTopLeftRadius: 0,
maxWidth: 0,
maxHeight: 0,
padding: 0,
transition: 'all 0.23s ease',
overflow: 'hidden',
selectors: {
'[data-resizing="true"] &': {
padding: '4px 8px',
maxWidth: 200,
maxHeight: 40,
},
},
});

View File

@ -0,0 +1,19 @@
type AllowedUnits = 'px' | 'ms';
/**
* get value with unit
*/
export const withUnit = (
value: string | number,
unit: AllowedUnits
): string => {
if (typeof value === 'number') {
return `${value}${unit}`;
}
if (/^\d+(\.\d+)?$/.test(value)) {
return `${value}${unit}`;
}
return value;
};

View File

@ -354,7 +354,7 @@ __metadata:
rxjs: "npm:^7.8.1"
sonner: "npm:^1.4.41"
storybook: "npm:^7.6.17"
storybook-dark-mode: "npm:^4.0.0"
storybook-dark-mode: "npm:4.0.1"
swr: "npm:^2.2.5"
typescript: "npm:^5.4.5"
uuid: "npm:^10.0.0"
@ -36220,9 +36220,9 @@ __metadata:
languageName: node
linkType: hard
"storybook-dark-mode@npm:^4.0.0":
version: 4.0.2
resolution: "storybook-dark-mode@npm:4.0.2"
"storybook-dark-mode@npm:4.0.1":
version: 4.0.1
resolution: "storybook-dark-mode@npm:4.0.1"
dependencies:
"@storybook/components": "npm:^8.0.0"
"@storybook/core-events": "npm:^8.0.0"
@ -36232,7 +36232,7 @@ __metadata:
"@storybook/theming": "npm:^8.0.0"
fast-deep-equal: "npm:^3.1.3"
memoizerific: "npm:^1.11.3"
checksum: 10/c9ef7bc6734df7486ff763c9da3c69505269eaf5fd7b5b489553f023b363ea892862241e6d701ad647ca5d1e64fd9a2646b8985c7ea8ac97a3bca87891db6fe5
checksum: 10/3225e5bdaba0ea76b65d642202d9712d7de234e3b5673fb46e444892ab114be207dd287778e2002b662ec35bb8153d2624ff280ce51c5299fb13c711431dad40
languageName: node
linkType: hard