diff --git a/README.md b/README.md index d25a785d..fb070cc7 100755 --- a/README.md +++ b/README.md @@ -64,12 +64,16 @@ ReactDom.render( | onClose | func | null | close click function | | keyboard | boolean | true | Whether support press esc to close | | autoFocus | boolean | true | Whether focusing on the drawer after it opened | +| resizable | boolean | false | Whether the drawer can be resized by dragging | | onMouseEnter | React.MouseEventHandler\ | - | Trigger when mouse enter drawer panel | | onMouseOver | React.MouseEventHandler\ | - | Trigger when mouse over drawer panel | | onMouseLeave | React.MouseEventHandler\ | - | Trigger when mouse leave drawer panel | | onClick | React.MouseEventHandler\ | - | Trigger when mouse click drawer panel | | onKeyDown | React.MouseEventHandler\ | - | Trigger when mouse keydown on drawer panel | | onKeyUp | React.MouseEventHandler\ | - | Trigger when mouse keyup on drawer panel | +| onResize | (size: number) => void | - | Callback when drawer is being resized | +| onResizeStart | () => void | - | Callback when resize starts | +| onResizeEnd | () => void | - | Callback when resize ends | > 2.0 Rename `onMaskClick` -> `onClose`, add `maskClosable`. diff --git a/assets/index.less b/assets/index.less index c08cc6a6..f06c9fde 100755 --- a/assets/index.less +++ b/assets/index.less @@ -30,7 +30,7 @@ &-content-wrapper { position: absolute; z-index: @zIndex; - overflow: hidden; + // overflow: hidden; transition: transform @duration; &-hidden { @@ -58,223 +58,302 @@ background: #fff; pointer-events: auto; } + + // Resizable styles + &-resizable-line { + position: absolute; + z-index: 2; + pointer-events: auto; + + &:hover { + background-color: #1890ff !important; + } + + &-dragging { + background-color: #1890ff !important; + } + + &-left { + top: 0; + bottom: 0; + right: -3px; + width: 6px; + cursor: ew-resize; + } + + &-right { + top: 0; + bottom: 0; + left: -3px; + width: 6px; + cursor: ew-resize; + } + + &-top { + left: 0; + right: 0; + bottom: -3px; + height: 6px; + cursor: ns-resize; + } + + &-bottom { + left: 0; + right: 0; + top: -3px; + height: 6px; + cursor: ns-resize; + } + } } -// .@{drawer} { -// position: fixed; -// z-index: 9999; -// transition: width 0s ease @duration, height 0s ease @duration, transform @duration @ease-in-out-circ; -// >* { -// transition: transform @duration @ease-in-out-circ, opacity @duration @ease-in-out-circ, box-shadow @duration @ease-in-out-circ; -// } -// &.@{drawer}-open { -// transition: transform @duration @ease-in-out-circ; -// } -// & &-mask { -// background: #000; -// opacity: 0; -// width: 100%; -// height: 0; -// position: absolute; -// top: 0; -// left: 0; -// transition: opacity @duration @ease-in-out-circ, -// height 0s ease @duration; -// } -// &-content-wrapper { -// position: absolute; -// background: #fff; -// } -// &-content { -// overflow: auto; -// z-index: 1; -// position: relative; -// } -// &-handle { -// position: absolute; -// top: 72px; -// width: 41px; -// height: 40px; -// cursor: pointer; -// z-index: 0; -// text-align: center; -// line-height: 40px; -// font-size: 16px; -// display: flex; -// justify-content: center; -// align-items: center; -// background: #fff; -// &-icon { -// width: 14px; -// height: 2px; -// background: #333; -// position: relative; -// transition: background @duration @ease-in-out-circ; -// &:before, -// &:after { -// content: ''; -// display: block; -// position: absolute; -// background: #333; -// width: 100%; -// height: 2px; -// transition: transform @duration @ease-in-out-circ; -// } -// &:before { -// top: -5px; -// } -// &:after { -// top: 5px; -// } -// } -// } -// &-left, -// &-right { -// width: 0%; -// height: 100%; -// .@{drawer}-content-wrapper, -// .@{drawer}-content { -// height: 100%; -// } -// &.@{drawer}-open { -// width: 100%; -// &.no-mask { -// width: 0%; -// } -// } -// } -// &-left { -// top: 0; -// left: 0; -// .@{drawer} { -// &-handle { -// right: -40px; -// box-shadow: 2px 0 8px rgba(0, 0, 0, .15); -// border-radius: 0 4px 4px 0; -// } -// } -// &.@{drawer}-open { -// .@{drawer} { -// &-content-wrapper { -// box-shadow: 2px 0 8px rgba(0, 0, 0, .15); -// } -// } -// } -// } -// &-right { -// top: 0; -// right: 0; -// .@{drawer} { -// &-content-wrapper { -// right: 0; -// } -// &-handle { -// left: -40px; -// box-shadow: -2px 0 8px rgba(0, 0, 0, .15); -// border-radius: 4px 0 0 4px; -// } -// } -// &.@{drawer}-open { -// & .@{drawer} { -// &-content-wrapper { -// box-shadow: -2px 0 8px rgba(0, 0, 0, .15); -// } -// } -// &.no-mask { -// // https://github.com/ant-design/ant-design/issues/18607 -// right: 1px; -// transform: translateX(1px); -// } -// } -// } -// &-top, -// &-bottom { -// width: 100%; -// height: 0%; -// .@{drawer}-content-wrapper, -// .@{drawer}-content { -// width: 100%; -// } -// .@{drawer}-content { -// height: 100%; -// } -// &.@{drawer}-open { -// height: 100%; -// &.no-mask { -// height: 0%; -// } -// } - -// .@{drawer} { -// &-handle { -// left: 50%; -// margin-left: -20px; -// } -// } -// } -// &-top { -// top: 0; -// left: 0; -// .@{drawer} { -// &-handle { -// top: auto; -// bottom: -40px; -// box-shadow: 0 2px 8px rgba(0, 0, 0, .15); -// border-radius: 0 0 4px 4px; -// } -// } -// &.@{drawer}-open { -// .@{drawer} { -// &-content-wrapper { -// box-shadow: 0 2px 8px rgba(0, 0, 0, .15); -// } -// } -// } -// } -// &-bottom { -// bottom: 0; -// left: 0; -// .@{drawer} { -// &-content-wrapper { -// bottom: 0; -// } -// &-handle { -// top: -40px; -// box-shadow: 0 -2px 8px rgba(0, 0, 0, .15); -// border-radius: 4px 4px 0 0; -// } -// } -// &.@{drawer}-open { -// .@{drawer} { -// &-content-wrapper { -// box-shadow: 0 -2px 8px rgba(0, 0, 0, .15); -// } -// } -// &.no-mask { -// // https://github.com/ant-design/ant-design/issues/18607 -// bottom: 1px; -// transform: translateY(1px); -// } -// } -// } -// &.@{drawer}-open { -// .@{drawer} { -// &-mask { -// opacity: .3; -// height: 100%; -// transition: opacity 0.3s @ease-in-out-circ; -// } -// &-handle { -// &-icon { -// background: transparent; -// &:before { -// transform: translateY(5px) rotate(45deg); -// } -// &:after { -// transform: translateY(-5px) rotate(-45deg); -// } -// } -// } -// } -// } -// } +.@{prefixCls} { + overflow: hidden; + position: fixed; + z-index: 9999; + transition: + width 0s ease @duration, + height 0s ease @duration, + transform @duration @ease-in-out-circ; + + > * { + transition: + transform @duration @ease-in-out-circ, + opacity @duration @ease-in-out-circ, + box-shadow @duration @ease-in-out-circ; + } + &.@{prefixCls}-open { + transition: transform @duration @ease-in-out-circ; + } + + & &-mask { + background: #000; + opacity: 0; + width: 100%; + height: 0; + position: absolute; + top: 0; + left: 0; + transition: + opacity @duration @ease-in-out-circ, + height 0s ease @duration; + } + + &-content-wrapper { + position: absolute; + background: #fff; + } + + &-content { + overflow: auto; + z-index: 1; + position: relative; + } + + &-handle { + position: absolute; + top: 72px; + width: 41px; + height: 40px; + cursor: pointer; + z-index: 0; + text-align: center; + line-height: 40px; + font-size: 16px; + display: flex; + justify-content: center; + align-items: center; + background: #fff; + + &-icon { + width: 14px; + height: 2px; + background: #333; + position: relative; + transition: background @duration @ease-in-out-circ; + + &::before, + &::after { + content: ''; + display: block; + position: absolute; + background: #333; + width: 100%; + height: 2px; + transition: transform @duration @ease-in-out-circ; + } + + &::before { + top: -5px; + } + + &::after { + top: 5px; + } + } + } + + &-left, + &-right { + width: 0%; + height: 100%; + .@{prefixCls}-content-wrapper, + .@{prefixCls}-content { + height: 100%; + } + &.@{prefixCls}-open { + width: 100%; + + &.no-mask { + width: 0%; + } + } + } + + &-left { + top: 0; + left: 0; + .@{prefixCls} { + &-handle { + right: -40px; + box-shadow: 2px 0 8px rgb(0 0 0 / 15%); + border-radius: 0 4px 4px 0; + } + } + &.@{prefixCls}-open { + .@{prefixCls} { + &-content-wrapper { + box-shadow: 2px 0 8px rgb(0 0 0 / 15%); + } + } + } + } + + &-right { + top: 0; + right: 0; + .@{prefixCls} { + &-content-wrapper { + right: 0; + } + + &-handle { + left: -40px; + box-shadow: -2px 0 8px rgb(0 0 0 / 15%); + border-radius: 4px 0 0 4px; + } + } + &.@{prefixCls}-open { + & .@{prefixCls} { + &-content-wrapper { + box-shadow: -2px 0 8px rgb(0 0 0 / 15%); + } + } + + &.no-mask { + // https://github.com/ant-design/ant-design/issues/18607 + right: 1px; + transform: translateX(1px); + } + } + } + + &-top, + &-bottom { + width: 100%; + height: 0%; + .@{prefixCls}-content-wrapper, + .@{prefixCls}-content { + width: 100%; + } + .@{prefixCls}-content { + height: 100%; + } + &.@{prefixCls}-open { + height: 100%; + + &.no-mask { + height: 0%; + } + } + + .@{prefixCls} { + &-handle { + left: 50%; + margin-left: -20px; + } + } + } + + &-top { + top: 0; + left: 0; + .@{prefixCls} { + &-handle { + top: auto; + bottom: -40px; + box-shadow: 0 2px 8px rgb(0 0 0 / 15%); + border-radius: 0 0 4px 4px; + } + } + &.@{prefixCls}-open { + .@{prefixCls} { + &-content-wrapper { + box-shadow: 0 2px 8px rgb(0 0 0 / 15%); + } + } + } + } + + &-bottom { + bottom: 0; + left: 0; + .@{prefixCls} { + &-content-wrapper { + bottom: 0; + } + + &-handle { + top: -40px; + box-shadow: 0 -2px 8px rgb(0 0 0 / 15%); + border-radius: 4px 4px 0 0; + } + } + &.@{prefixCls}-open { + .@{prefixCls} { + &-content-wrapper { + box-shadow: 0 -2px 8px rgb(0 0 0 / 15%); + } + } + + &.no-mask { + // https://github.com/ant-design/ant-design/issues/18607 + bottom: 1px; + transform: translateY(1px); + } + } + } + &.@{prefixCls}-open { + .@{prefixCls} { + &-mask { + opacity: 0.3; + height: 100%; + transition: opacity 0.3s @ease-in-out-circ; + } + + &-handle { + &-icon { + background: transparent; + + &::before { + transform: translateY(5px) rotate(45deg); + } + + &::after { + transform: translateY(-5px) rotate(-45deg); + } + } + } + } + } +} diff --git a/docs/demo/resizable.md b/docs/demo/resizable.md new file mode 100644 index 00000000..92fdad22 --- /dev/null +++ b/docs/demo/resizable.md @@ -0,0 +1,8 @@ +--- +title: resizable +nav: + title: Resizable + path: /resizable +--- + + diff --git a/docs/examples/assets/motion.less b/docs/examples/assets/motion.less index 5607b491..4aa5a2ed 100644 --- a/docs/examples/assets/motion.less +++ b/docs/examples/assets/motion.less @@ -83,4 +83,46 @@ } } } + + &-top { + .panel-motion(); + + &-enter, + &-appear { + transform: translateY(-100%); + + &-active { + transform: translateX(0); + } + } + + &-leave { + transform: translateX(0); + + &-active { + transform: translateY(-100%) !important; + } + } + } + + &-bottom { + .panel-motion(); + + &-enter, + &-appear { + transform: translateY(100%); + + &-active { + transform: translateX(0); + } + } + + &-leave { + transform: translateX(0); + + &-active { + transform: translateY(100%) !important; + } + } + } } diff --git a/docs/examples/base.tsx b/docs/examples/base.tsx index 1a411b16..d7e02b88 100755 --- a/docs/examples/base.tsx +++ b/docs/examples/base.tsx @@ -20,7 +20,7 @@ const Demo = () => { afterOpenChange={(c: boolean) => { console.log('transitionEnd: ', c); }} - placement="right" + placement="top" // width={400} width="60%" // Motion diff --git a/docs/examples/resizable.tsx b/docs/examples/resizable.tsx new file mode 100644 index 00000000..392cf54e --- /dev/null +++ b/docs/examples/resizable.tsx @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import Drawer, { type Placement } from 'rc-drawer'; + +import '../../assets/index.less'; +import './assets/index.less'; +import motionProps from './motion'; + +export default () => { + const [open, setOpen] = React.useState(false); + const [placement, setPlacement] = React.useState('right'); + const [resizable, setResizable] = React.useState(true); + + return ( +
+
+ + + Placement: + +
+ setOpen(false)} + resizable={resizable} + {...motionProps} + > +
+

+ You can drag the drawer edge to resize (only works when + "Resizable" is checked). +

+
+
+
+ ); +}; diff --git a/src/Drawer.tsx b/src/Drawer.tsx index 4816b962..b5b26083 100644 --- a/src/Drawer.tsx +++ b/src/Drawer.tsx @@ -26,6 +26,11 @@ export interface DrawerProps panelRef?: React.Ref; classNames?: DrawerClassNames; styles?: DrawerStyles; + /** Whether to enable width resize */ + resizable?: boolean; + onResize?: (size: number) => void; + onResizeStart?: () => void; + onResizeEnd?: () => void; } const Drawer: React.FC = props => { @@ -48,6 +53,9 @@ const Drawer: React.FC = props => { onClick, onKeyDown, onKeyUp, + onResize, + onResizeStart, + onResizeEnd, // Refs panelRef, @@ -114,6 +122,9 @@ const Drawer: React.FC = props => { onClick, onKeyDown, onKeyUp, + onResize, + onResizeStart, + onResizeEnd, }; const drawerPopupProps = { ...props, diff --git a/src/DrawerPopup.tsx b/src/DrawerPopup.tsx index cab0b7a9..87562205 100644 --- a/src/DrawerPopup.tsx +++ b/src/DrawerPopup.tsx @@ -11,6 +11,7 @@ import type { DrawerPanelEvents, } from './DrawerPanel'; import DrawerPanel from './DrawerPanel'; +import ResizableLine from './ResizableLine'; import { parseWidthHeight } from './util'; import type { DrawerClassNames, DrawerStyles } from './inter'; @@ -75,6 +76,12 @@ export interface DrawerPopupProps // styles styles?: DrawerStyles; drawerRender?: (node: React.ReactNode) => React.ReactNode; + + // resizable + resizable?: boolean; + onResize?: (size: number) => void; + onResizeStart?: () => void; + onResizeEnd?: () => void; } function DrawerPopup(props: DrawerPopupProps, ref: React.Ref) { @@ -120,9 +127,13 @@ function DrawerPopup(props: DrawerPopupProps, ref: React.Ref) { onClick, onKeyDown, onKeyUp, + onResize, + onResizeStart, + onResizeEnd, styles, drawerRender, + resizable, } = props; // ================================ Refs ================================ @@ -280,6 +291,63 @@ function DrawerPopup(props: DrawerPopupProps, ref: React.Ref) { onKeyUp, }; + // ============================ Resizable ============================ + const [currentSize, setCurrentSize] = React.useState(); + const [isDragging, setIsDragging] = React.useState(false); + + const handleResize = React.useCallback( + (size: number) => { + setCurrentSize(size); + onResize?.(size); + }, + [onResize], + ); + + const handleResizeStart = React.useCallback(() => { + onResizeStart?.(); + }, [onResizeStart]); + + const handleResizeEnd = React.useCallback(() => { + onResizeEnd?.(); + }, [onResizeEnd]); + + const handleDraggingChange = React.useCallback((dragging: boolean) => { + setIsDragging(dragging); + }, []); + + const dynamicWrapperStyle = React.useMemo(() => { + const style: React.CSSProperties = { ...wrapperStyle }; + + if (currentSize !== undefined && resizable) { + if (placement === 'left' || placement === 'right') { + style.width = currentSize; + } else { + style.height = currentSize; + } + } + + if (resizable) { + style.overflow = 'none'; + } else { + style.overflow = 'auto'; + } + + return style; + }, [wrapperStyle, currentSize, resizable, placement]); + + const wrapperRef = React.useRef(null); + const [maxSize, setMaxSize] = React.useState(0); + React.useEffect(() => { + if (wrapperRef.current) { + const rect = wrapperRef.current.parentElement?.getBoundingClientRect(); + setMaxSize( + placement === 'left' || placement === 'right' + ? (rect?.width ?? 0) + : (rect?.height ?? 0), + ); + } + }, [wrapperRef.current]); + const panelNode: React.ReactNode = ( ) { ); return (
+ {resizable && ( + + )} {drawerRender ? drawerRender(content) : content}
); diff --git a/src/ResizableLine.tsx b/src/ResizableLine.tsx new file mode 100644 index 00000000..587f66a9 --- /dev/null +++ b/src/ResizableLine.tsx @@ -0,0 +1,157 @@ +import classNames from 'classnames'; +import * as React from 'react'; +import type { Placement } from './Drawer'; + +export interface ResizableLineProps { + prefixCls?: string; + direction: Placement; + className?: string; + style?: React.CSSProperties; + minSize?: number; + maxSize?: number; + isDragging?: boolean; + onResize?: (size: number) => void; + onResizeEnd?: (size: number) => void; + onResizeStart?: (size: number) => void; + onDraggingChange?: (dragging: boolean) => void; +} + +const ResizableLine: React.FC = ({ + prefixCls = 'resizable', + direction, + className, + style, + minSize = 100, + maxSize, + isDragging = false, + onResize, + onResizeEnd, + onResizeStart, + onDraggingChange, +}) => { + const lineRef = React.useRef(null); + const [startPos, setStartPos] = React.useState(0); + const [startSize, setStartSize] = React.useState(0); + + const isHorizontal = direction === 'left' || direction === 'right'; + + const handleMouseDown = React.useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + onDraggingChange?.(true); + + if (isHorizontal) { + setStartPos(e.clientX); + } else { + setStartPos(e.clientY); + } + + // Get the current size of the parent container + const parentElement = lineRef.current?.parentElement; + if (parentElement) { + const rect = parentElement.getBoundingClientRect(); + const currentSize = isHorizontal ? rect.width : rect.height; + setStartSize(currentSize); + onResizeStart?.(currentSize); + } + }, + [isHorizontal, onResizeStart, onDraggingChange], + ); + + const handleMouseMove = React.useCallback( + (e: MouseEvent) => { + if (!isDragging) return; + + const currentPos = isHorizontal ? e.clientX : e.clientY; + let delta = currentPos - startPos; + + // Adjust delta direction based on placement + if (direction === 'right' || direction === 'bottom') { + delta = -delta; + } + + let newSize = startSize + delta; + + // Apply min/max size limits + if (newSize < minSize) { + newSize = minSize; + } + // Only apply maxSize if it's a valid positive number + if (maxSize !== undefined && maxSize > 0 && newSize > maxSize) { + newSize = maxSize; + } + + onResize?.(newSize); + }, + [ + isDragging, + startPos, + startSize, + direction, + minSize, + maxSize, + onResize, + isHorizontal, + ], + ); + + const handleMouseUp = React.useCallback(() => { + if (isDragging) { + onDraggingChange?.(false); + + // Get the final size after resize + const parentElement = lineRef.current?.parentElement; + if (parentElement) { + const rect = parentElement.getBoundingClientRect(); + const finalSize = isHorizontal ? rect.width : rect.height; + onResizeEnd?.(finalSize); + } + } + }, [isDragging, onResizeEnd, isHorizontal, onDraggingChange]); + + React.useEffect(() => { + if (isDragging) { + // Add global mouse event listeners during drag + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + return () => { + // Clean up event listeners and styles + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + } + }, [isDragging, handleMouseMove, handleMouseUp, isHorizontal]); + + const resizeLineClassName = classNames( + `${prefixCls}-line`, + `${prefixCls}-line-${direction}`, + { + [`${prefixCls}-line-dragging`]: isDragging, + }, + className, + ); + + const resizeLineStyle: React.CSSProperties = { + position: 'absolute', + zIndex: 2, + ...style, + }; + + return ( +
+ ); +}; + +export default ResizableLine; diff --git a/src/index.ts b/src/index.ts index 757f964f..ae3d3a68 100755 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ // export this package's api import Drawer from './Drawer'; -import type { DrawerProps } from './Drawer'; +import type { DrawerProps, Placement } from './Drawer'; -export type { DrawerProps }; +export type { DrawerProps, Placement }; export default Drawer; diff --git a/src/inter.ts b/src/inter.ts index 587de93e..89decee5 100644 --- a/src/inter.ts +++ b/src/inter.ts @@ -2,10 +2,12 @@ export interface DrawerClassNames { mask?: string; wrapper?: string; content?: string; + resizableLine?: string; } export interface DrawerStyles { mask?: React.CSSProperties; wrapper?: React.CSSProperties; content?: React.CSSProperties; + resizableLine?: React.CSSProperties; } diff --git a/tests/index.spec.tsx b/tests/index.spec.tsx index fcd2b11a..ae991450 100755 --- a/tests/index.spec.tsx +++ b/tests/index.spec.tsx @@ -474,4 +474,43 @@ describe('rc-drawer-menu', () => { expect(document.querySelector('#test')).toBeTruthy(); unmount(); }); + + it('should support resizable', () => { + const { unmount } = render( + , + ); + + // Mock getBoundingClientRect for the content wrapper to simulate real DOM dimensions + const contentWrapper = document.querySelector( + '.rc-drawer-content-wrapper', + ) as HTMLElement; + const mockGetBoundingClientRect = jest.fn( + () => + ({ + width: 200, + height: 400, + top: 0, + left: 0, + bottom: 400, + right: 200, + x: 0, + y: 0, + toJSON: () => ({}), + }) as DOMRect, + ); + contentWrapper.getBoundingClientRect = mockGetBoundingClientRect; + + const resizableLine = document.querySelector('.rc-drawer-resizable-line'); + expect(resizableLine).toBeTruthy(); + + // Simulate drag from 200px to 100px (should reduce width by 100px) + fireEvent.mouseDown(resizableLine, { clientX: 200 }); + fireEvent.mouseMove(document, { clientX: 100, clientY: 0 }); + fireEvent.mouseUp(document, { clientX: 100, clientY: 0 }); + + expect(document.querySelector('.rc-drawer-content-wrapper')).toHaveStyle({ + width: '100px', + }); + unmount(); + }); });