Skip to content

Commit

Permalink
add ResizablePanes
Browse files Browse the repository at this point in the history
  • Loading branch information
YieldRay committed Dec 23, 2024
1 parent 3208ce7 commit 6ecc467
Show file tree
Hide file tree
Showing 6 changed files with 227 additions and 8 deletions.
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -74,5 +74,5 @@
"url": "https://github.com/YieldRay/soda/issues"
},
"homepage": "https://github.com/YieldRay/soda#readme",
"packageManager": "[email protected].0"
"packageManager": "[email protected].1"
}
2 changes: 1 addition & 1 deletion src/components/slider/Slider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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],
)

Expand Down
49 changes: 49 additions & 0 deletions src/composition/ResizablePanes/ResizablePanes.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ResizablePanes> = {
title: 'composition/ResizablePanes',
component: ResizablePanes,
tags: ['autodocs'],
}

export default meta

type Story = StoryObj<typeof meta>

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 (
<ResizablePanes
{...props}
size={size}
onSizeChange={onSizeChange}
/>
)
},
}
169 changes: 169 additions & 0 deletions src/composition/ResizablePanes/ResizablePanes.tsx
Original file line number Diff line number Diff line change
@@ -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<React.CSSProperties>(
() =>
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 (
<div
style={appliedStyle}
{...props}
onPointerDown={onPointerDown}
onPointerUp={onPointerUp}
onPointerMove={onPointerMove}
/>
)
}

/**
* @internal
*/
function InternalResizablePanes({
gridTemplate,
onSizeDelta,
direction,
firstPane,
firstPaneProps,
secondPane,
secondPaneProps,
resizerSize,
resizerProps,
style,
...props
}: ExtendProps<
Omit<ResizablePanesProps, 'onSizeChange' | 'direction'> & {
direction: 'horizontal' | 'vertical'
gridTemplate: string
onSizeDelta: (delta: number) => void
}
>) {
return (
<div
{...props}
style={{
overflow: 'hidden',
...style,
display: 'grid',
[direction === 'horizontal'
? 'gridTemplateColumns'
: 'gridTemplateRows']: gridTemplate,
}}
>
<div {...firstPaneProps}>{firstPane}</div>
<Divider
direction={direction}
size={resizerSize || '4px'}
onSizeDelta={onSizeDelta}
{...resizerProps}
/>
<div {...secondPaneProps}>{secondPane}</div>
</div>
)
}

export interface ResizablePanesProps {
size: number
firstPane: React.ReactNode
firstPaneProps: React.HTMLAttributes<HTMLDivElement>
secondPane: React.ReactNode
secondPaneProps: React.HTMLAttributes<HTMLDivElement>
/**
* @default "4px"
*/
resizerSize?: string
resizerProps: React.HTMLAttributes<HTMLDivElement>
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 (
<InternalResizablePanes
direction={innerDirection}
size={size}
onSizeDelta={handleResizeDelta}
gridTemplate={gridTemplate}
{...props}
/>
)
}
1 change: 1 addition & 0 deletions src/composition/ResizablePanes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './ResizablePanes'
4 changes: 2 additions & 2 deletions src/utils/misc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(
array: Array<T> | null | undefined,
Expand Down

0 comments on commit 6ecc467

Please sign in to comment.