From 6ecc46732718f001658203ce6e70f122b339d68f Mon Sep 17 00:00:00 2001 From: YieldRay Date: Tue, 24 Dec 2024 00:16:12 +0800 Subject: [PATCH] add ResizablePanes --- package.json | 10 +- src/components/slider/Slider.tsx | 2 +- .../ResizablePanes/ResizablePanes.stories.tsx | 49 +++++ .../ResizablePanes/ResizablePanes.tsx | 169 ++++++++++++++++++ src/composition/ResizablePanes/index.ts | 1 + src/utils/misc.ts | 4 +- 6 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 src/composition/ResizablePanes/ResizablePanes.stories.tsx create mode 100644 src/composition/ResizablePanes/ResizablePanes.tsx create mode 100644 src/composition/ResizablePanes/index.ts diff --git a/package.json b/package.json index 882a5df..174c196 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@storybook/blocks": "^8.4.7", "@storybook/react": "^8.4.7", "@storybook/react-vite": "^8.4.7", - "@types/react": "^18.3.16", + "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", "chromatic": "^11.20.2", @@ -45,14 +45,14 @@ "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", "glob": "^10.4.5", - "globals": "^15.13.0", + "globals": "^15.14.0", "prettier": "^3.4.2", "sass": "^1.83.0", "storybook": "^8.4.7", "typescript": "^5.7.2", - "typescript-eslint": "^8.18.0", + "typescript-eslint": "^8.18.1", "vite": "^5.4.11", - "vite-plugin-dts": "^4.3.0" + "vite-plugin-dts": "^4.4.0" }, "publishConfig": { "registry": "https://registry.npmjs.org" @@ -74,5 +74,5 @@ "url": "https://github.com/YieldRay/soda/issues" }, "homepage": "https://github.com/YieldRay/soda#readme", - "packageManager": "pnpm@9.15.0" + "packageManager": "pnpm@9.15.1" } diff --git a/src/components/slider/Slider.tsx b/src/components/slider/Slider.tsx index 9e17fa6..74f626a 100644 --- a/src/components/slider/Slider.tsx +++ b/src/components/slider/Slider.tsx @@ -89,7 +89,7 @@ export const Slider = forwardRef< // just util function const valueLimitRange = useCallback( - (value: number) => clamp(value, minValue, maxValue), + (value: number) => clamp(minValue, value, maxValue), [minValue, maxValue], ) diff --git a/src/composition/ResizablePanes/ResizablePanes.stories.tsx b/src/composition/ResizablePanes/ResizablePanes.stories.tsx new file mode 100644 index 0000000..93d4363 --- /dev/null +++ b/src/composition/ResizablePanes/ResizablePanes.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' +import { clamp } from '@/utils/misc' +import { ResizablePanes } from './ResizablePanes' + +const meta: Meta = { + title: 'composition/ResizablePanes', + component: ResizablePanes, + tags: ['autodocs'], +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + firstPane: <>firstPane, + firstPaneProps: { + style: { + background: 'var(--md-sys-color-primary)', + }, + }, + secondPane: <>secondPane, + secondPaneProps: { + style: { + background: 'var(--md-sys-color-inverse-primary)', + }, + }, + resizerSize: '4px', + resizerProps: { + style: { + background: '#eee', + }, + }, + }, + render: (props) => { + const [size, setSize] = useState(200) + // tips: use clamp to limit pane size + const onSizeChange = (size: number) => setSize(clamp(100, size, 200)) + return ( + + ) + }, +} diff --git a/src/composition/ResizablePanes/ResizablePanes.tsx b/src/composition/ResizablePanes/ResizablePanes.tsx new file mode 100644 index 0000000..a6272d5 --- /dev/null +++ b/src/composition/ResizablePanes/ResizablePanes.tsx @@ -0,0 +1,169 @@ +import { useCallback, useMemo, useRef } from 'react' +import { mergeStyles } from '@/utils/style' +import { ExtendProps } from '@/utils/type' + +function Divider({ + size, + onSizeDelta, + direction, + style, + ...props +}: ExtendProps<{ + size: string + onSizeDelta: (delta: number) => void + direction: 'horizontal' | 'vertical' +}>) { + const isPointerDown = useRef(false) + const pointerDownPosition = useRef({ x: 0, y: 0 }) + + const onPointerDown = useCallback((e: React.PointerEvent) => { + const target = e.target as HTMLDivElement + target.setPointerCapture(e.pointerId) + isPointerDown.current = true + pointerDownPosition.current = { x: e.clientX, y: e.clientY } + }, []) + + const onPointerUp = useCallback((e: React.PointerEvent) => { + const target = e.target as HTMLDivElement + target.releasePointerCapture(e.pointerId) + isPointerDown.current = false + }, []) + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (!isPointerDown.current) return + + const deltaX = e.clientX - pointerDownPosition.current.x + const deltaY = e.clientY - pointerDownPosition.current.y + const delta = direction === 'horizontal' ? deltaX : deltaY + + onSizeDelta(delta) + pointerDownPosition.current = { x: e.clientX, y: e.clientY } + }, + [onSizeDelta, direction], + ) + + const appliedStyle = useMemo( + () => + mergeStyles( + { + cursor: + direction === 'horizontal' + ? 'col-resize' + : 'row-resize', + width: direction === 'horizontal' ? size : '100%', + height: direction === 'vertical' ? size : '100%', + userSelect: 'none', + touchAction: 'none', + }, + style, + ), + [style, direction, size], + ) + + return ( +
+ ) +} + +/** + * @internal + */ +function InternalResizablePanes({ + gridTemplate, + onSizeDelta, + direction, + firstPane, + firstPaneProps, + secondPane, + secondPaneProps, + resizerSize, + resizerProps, + style, + ...props +}: ExtendProps< + Omit & { + direction: 'horizontal' | 'vertical' + gridTemplate: string + onSizeDelta: (delta: number) => void + } +>) { + return ( +
+
{firstPane}
+ +
{secondPane}
+
+ ) +} + +export interface ResizablePanesProps { + size: number + firstPane: React.ReactNode + firstPaneProps: React.HTMLAttributes + secondPane: React.ReactNode + secondPaneProps: React.HTMLAttributes + /** + * @default "4px" + */ + resizerSize?: string + resizerProps: React.HTMLAttributes + onSizeChange: (size: number) => void + /** + * @default "left" + */ + direction?: 'left' | 'right' | 'top' | 'bottom' +} + +export function ResizablePanes({ + size, + onSizeChange, + direction: _direction, + ...props +}: ResizablePanesProps) { + const handleResizeDelta = useCallback( + (delta: number) => onSizeChange(size + delta), + [size, onSizeChange], + ) + const direction = _direction || 'left' + const innerDirection = + direction === 'left' || direction === 'right' + ? 'horizontal' + : 'vertical' + + const gridTemplate = + direction === 'left' || direction === 'top' + ? `${size}px auto 1fr` + : `1fr auto ${size}px` + + return ( + + ) +} diff --git a/src/composition/ResizablePanes/index.ts b/src/composition/ResizablePanes/index.ts new file mode 100644 index 0000000..ae052ea --- /dev/null +++ b/src/composition/ResizablePanes/index.ts @@ -0,0 +1 @@ +export * from './ResizablePanes' diff --git a/src/utils/misc.ts b/src/utils/misc.ts index b9da288..6198580 100644 --- a/src/utils/misc.ts +++ b/src/utils/misc.ts @@ -4,8 +4,8 @@ export const isNumber = (x: unknown): x is number => /** * Clamps number within the inclusive lower and upper bounds. */ -export const clamp = (number: number, lower: number, upper: number) => - Math.min(Math.max(number, lower), upper) +export const clamp = (lower: number, value: number, upper: number) => + Math.min(Math.max(value, lower), upper) export const chunk = ( array: Array | null | undefined,