From 827c952e9fdc1af8bebd84215df3f84a72f370ca Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Thu, 27 Jun 2024 06:04:19 +0000 Subject: [PATCH] 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 --- packages/frontend/component/package.json | 2 +- packages/frontend/component/src/index.ts | 2 +- .../component/src/ui/button/radio.tsx | 26 ++- .../frontend/component/src/ui/radio/index.ts | 2 + .../component/src/ui/radio/radio.stories.tsx | 149 ++++++++++++++ .../frontend/component/src/ui/radio/radio.tsx | 185 ++++++++++++++++++ .../component/src/ui/radio/styles.css.ts | 65 ++++++ .../frontend/component/src/ui/radio/types.ts | 68 +++++++ .../src/ui/resize-panel/resize-panel.tsx | 11 +- .../src/ui/resize-panel/styles.css.ts | 27 +++ .../frontend/component/src/utils/with-unit.ts | 19 ++ yarn.lock | 10 +- 12 files changed, 551 insertions(+), 15 deletions(-) create mode 100644 packages/frontend/component/src/ui/radio/index.ts create mode 100644 packages/frontend/component/src/ui/radio/radio.stories.tsx create mode 100644 packages/frontend/component/src/ui/radio/radio.tsx create mode 100644 packages/frontend/component/src/ui/radio/styles.css.ts create mode 100644 packages/frontend/component/src/ui/radio/types.ts create mode 100644 packages/frontend/component/src/utils/with-unit.ts diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index 795373707a..fff572794c 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -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", diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 89a8d483e6..43cf69db87 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -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'; diff --git a/packages/frontend/component/src/ui/button/radio.tsx b/packages/frontend/component/src/ui/button/radio.tsx index 4c0b3cc30c..f365836d8b 100644 --- a/packages/frontend/component/src/ui/button/radio.tsx +++ b/packages/frontend/component/src/ui/button/radio.tsx @@ -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 ( - {children} - {children} - - + + ); }); RadioButton.displayName = 'RadioButton'; +/** + * @deprecated + * use {@link RadioGroup} instead + */ export const RadioButtonGroup = forwardRef< HTMLDivElement, RadioGroupProps & { width?: CSSProperties['width'] } >(({ className, style, width, ...props }, ref) => { return ( - + > ); }); RadioButtonGroup.displayName = 'RadioButtonGroup'; diff --git a/packages/frontend/component/src/ui/radio/index.ts b/packages/frontend/component/src/ui/radio/index.ts new file mode 100644 index 0000000000..084a6fdb90 --- /dev/null +++ b/packages/frontend/component/src/ui/radio/index.ts @@ -0,0 +1,2 @@ +export * from './radio'; +export type { RadioItem, RadioProps } from './types'; diff --git a/packages/frontend/component/src/ui/radio/radio.stories.tsx b/packages/frontend/component/src/ui/radio/radio.stories.tsx new file mode 100644 index 0000000000..9f589fca1b --- /dev/null +++ b/packages/frontend/component/src/ui/radio/radio.stories.tsx @@ -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 ( + <> +

+ width:  + + 300px + +

+ + + ); +}; + +export const AutoWidth = () => { + const [value, setValue] = useState('Radio 1'); + return ( + + ); +}; + +export const DynamicWidth = () => { + const [value, setValue] = useState('Radio 1'); + return ( + + + + ); +}; + +export const IconTabs = () => { + const [value, setValue] = useState('ai'); + const items: RadioItem[] = [ + { + value: 'ai', + label: , + style: { width: 28 }, + testId: 'ai-radio', + }, + { + value: 'calendar', + label: , + style: { width: 28 }, + testId: 'calendar-radio', + }, + { + value: 'outline', + label: , + style: { width: 28 }, + testId: 'outline-radio', + }, + { + value: 'frame', + label: , + style: { width: 28 }, + testId: 'frame-radio', + }, + ]; + return ( + + ); +}; + +export const CustomizeActiveStyle = () => { + const [value, setValue] = useState('ai'); + const items: RadioItem[] = [ + { + value: 'ai', + label: , + style: { width: 28 }, + testId: 'ai-radio', + }, + { + value: 'calendar', + label: , + style: { width: 28 }, + testId: 'calendar-radio', + }, + { + value: 'outline', + label: , + style: { width: 28 }, + testId: 'outline-radio', + }, + { + value: 'frame', + label: , + style: { width: 28 }, + testId: 'frame-radio', + }, + ]; + return ( + + ); +}; diff --git a/packages/frontend/component/src/ui/radio/radio.tsx b/packages/frontend/component/src/ui/radio/radio.tsx new file mode 100644 index 0000000000..894ee12f01 --- /dev/null +++ b/packages/frontend/component/src/ui/radio/radio.tsx @@ -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 + * + * ``` + * + * #### 2. Dynamic width + * ```tsx + * + * ``` + * + * #### 3. `ReactNode` as label + * ```tsx + * const [value, setValue] = useState('ai'); + * const items: RadioItem[] = [ + * { + * value: 'ai', + * label: , + * style: { width: 28 }, + * }, + * { + * value: 'calendar', + * label: , + * style: { width: 28 }, + * }, + * ]; + * return ( + * + * ); + * ``` + */ +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 | null>(null); + const finalItems = useMemo(() => { + return items + .map(value => + typeof value === 'string' ? ({ value } as RadioItem) : value + ) + .map(item => ({ + ...item, + ref: createRef(), + indicatorRef: createRef(), + })); + }, [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 ( + + {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 ( + + + + {customRender + ? customRender(item, index) + : item.label ?? item.value} + + + ); + })} + + ); +}); diff --git a/packages/frontend/component/src/ui/radio/styles.css.ts b/packages/frontend/component/src/ui/radio/styles.css.ts new file mode 100644 index 0000000000..5812aabd87 --- /dev/null +++ b/packages/frontend/component/src/ui/radio/styles.css.ts @@ -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, + }, + }, +}); diff --git a/packages/frontend/component/src/ui/radio/types.ts b/packages/frontend/component/src/ui/radio/types.ts new file mode 100644 index 0000000000..8fe2c228e2 --- /dev/null +++ b/packages/frontend/component/src/ui/radio/types.ts @@ -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, 'className' | 'style'> + > & + Record<`data-${string}`, string>; + customRender?: ( + item: Omit, + index: number + ) => ReactNode; +} diff --git a/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx b/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx index be5a61be29..39477fe7a1 100644 --- a/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx +++ b/packages/frontend/component/src/ui/resize-panel/resize-panel.tsx @@ -37,6 +37,7 @@ export const ResizePanel = ({ }: ResizePanelProps) => { const containerRef = useRef(null); const cornerHandleRef = useRef(null); + const displayRef = useRef(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} -
+
+
+
); }; diff --git a/packages/frontend/component/src/ui/resize-panel/styles.css.ts b/packages/frontend/component/src/ui/resize-panel/styles.css.ts index 0f9dced405..b4e578960f 100644 --- a/packages/frontend/component/src/ui/resize-panel/styles.css.ts +++ b/packages/frontend/component/src/ui/resize-panel/styles.css.ts @@ -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, + }, + }, +}); diff --git a/packages/frontend/component/src/utils/with-unit.ts b/packages/frontend/component/src/utils/with-unit.ts new file mode 100644 index 0000000000..b0f11c64a7 --- /dev/null +++ b/packages/frontend/component/src/utils/with-unit.ts @@ -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; +}; diff --git a/yarn.lock b/yarn.lock index e5d462af12..c0bd48a7ef 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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