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