├── .gitignore ├── src ├── index.ts ├── CellWrapper │ ├── Row.tsx │ ├── Col.tsx │ ├── ResizeDirectionOptions.test.ts │ ├── Cell.tsx │ ├── ResizeDirectionOptions.ts │ └── Cell.test.tsx ├── GridWrapper │ ├── ColsWrapper.tsx │ ├── RowsWrapper.tsx │ ├── GridWrapper.test.tsx │ └── GridWrapper.tsx ├── Separators │ ├── dragTestUtils.ts │ ├── Separators.test.tsx │ └── Separator.tsx └── hooks │ └── useRefsWithInitialSize.ts ├── jest.config.js ├── tsconfig.json ├── example ├── index.html └── index.tsx ├── package.json └── Readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | dist 4 | .cache 5 | .vscode -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { RowsWrapper } from './GridWrapper/RowsWrapper'; 2 | export { ColsWrapper } from './GridWrapper/ColsWrapper'; 3 | 4 | export { Row } from './CellWrapper/Row'; 5 | export { Col } from './CellWrapper/Col'; -------------------------------------------------------------------------------- /src/CellWrapper/Row.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Cell } from './Cell'; 3 | 4 | export const Row = styled(Cell) 5 | ` 6 | width: 100%; 7 | flex: ${props => (props.initialHeight) ? 'none' : 1}; 8 | `; -------------------------------------------------------------------------------- /src/CellWrapper/Col.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Cell } from './Cell'; 3 | 4 | export const Col = styled(Cell) 5 | ` 6 | height: 100%; 7 | display: inline-block; 8 | flex: ${props => (props.initialWidth) ? 'none' : 1}; 9 | `; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$", 9 | "moduleFileExtensions": [ 10 | "ts", 11 | "tsx", 12 | "js", 13 | "jsx", 14 | "json", 15 | "node" 16 | ], 17 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./lib", 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "lib": [ 10 | "es2016", 11 | "dom" 12 | ], 13 | "esModuleInterop": true, 14 | "jsx": "react" 15 | }, 16 | "include": [ 17 | "src" 18 | ], 19 | "exclude": [ 20 | "**/*.test.*", 21 | "src/Separators/dragTestUtils.ts" 22 | ] 23 | } -------------------------------------------------------------------------------- /src/GridWrapper/ColsWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { GridWrapper, GridWrapperProps } from './GridWrapper'; 4 | import { CellProps } from '../CellWrapper/Cell'; 5 | 6 | const ColsWrapperDiv = styled.div 7 | ` 8 | display: flex; 9 | height: 100%; 10 | overflow: hidden; 11 | ` 12 | 13 | export const ColsWrapper = (props: GridWrapperProps) => { 14 | return 15 | {GridWrapper({ 16 | ...props, 17 | direction: 'horizontal' 18 | })} 19 | 20 | } 21 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Grid Reziable 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/GridWrapper/RowsWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { GridWrapper, GridWrapperProps } from './GridWrapper'; 4 | import { CellProps } from '../CellWrapper/Cell'; 5 | 6 | const RowsWrapperDiv = styled.div` 7 | display: flex; 8 | height: 100%; 9 | flex-direction: column; 10 | overflow: hidden; 11 | ` 12 | 13 | export const RowsWrapper = (props: GridWrapperProps) => { 14 | return 15 | {GridWrapper({ 16 | ...props, 17 | direction: 'vertical' 18 | })} 19 | 20 | } -------------------------------------------------------------------------------- /src/Separators/dragTestUtils.ts: -------------------------------------------------------------------------------- 1 | import { act, fireEvent, render } from 'react-testing-library'; 2 | import 'react-testing-library/cleanup-after-each'; 3 | 4 | interface DragEventProps { 5 | clientX: number; 6 | clientY: number; 7 | } 8 | 9 | interface DragOptions { 10 | element: Element, 11 | baseElement: Element, 12 | from?: DragEventProps; 13 | to: DragEventProps, 14 | } 15 | 16 | export const actDrag = ({ 17 | element, 18 | baseElement, 19 | from = { 20 | clientX: 0, 21 | clientY: 0, 22 | }, 23 | to, 24 | }: DragOptions) => { 25 | act(() => { 26 | fireEvent.mouseDown(element, from); 27 | }); 28 | 29 | act(() => { 30 | fireEvent.mouseMove(baseElement, to); 31 | }); 32 | }; -------------------------------------------------------------------------------- /src/CellWrapper/ResizeDirectionOptions.test.ts: -------------------------------------------------------------------------------- 1 | import 'react-testing-library/cleanup-after-each' 2 | import { processOptions } from './ResizeDirectionOptions'; 3 | 4 | test('Default resize direction options', () => { 5 | expect(processOptions()).toEqual({ 6 | top: true, 7 | bottom: true, 8 | left: true, 9 | right: true, 10 | }); 11 | }) 12 | 13 | test('Overwrite specific direction option', () => { 14 | 'top,bottom,left,right'.split(',').forEach(prop => { 15 | expect(processOptions({ 16 | [prop]: false 17 | })).toEqual({ 18 | top: true, 19 | bottom: true, 20 | left: true, 21 | right: true, 22 | [prop]: false, 23 | }) 24 | }); 25 | }) 26 | 27 | test('Disable direction options', () => { 28 | expect(processOptions({ 29 | disabled: true 30 | })).toEqual({ 31 | top: false, 32 | bottom: false, 33 | left: false, 34 | right: false, 35 | }); 36 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-grid-resizable", 3 | "version": "1.0.0", 4 | "description": "React Grid Resizable Component", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "jest src --watch", 8 | "build": "tsc", 9 | "start": "parcel example/index.html", 10 | "build-example": "parcel build example/index.html", 11 | "prepare": "npm run build" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/netcell/react-grid-resizable.git" 16 | }, 17 | "author": "netcell", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "@types/jest": "^24.0.11", 21 | "@types/lodash": "^4.14.123", 22 | "@types/react": "^16.8.8", 23 | "@types/react-dom": "^16.8.2", 24 | "jest": "^24.5.0", 25 | "nice-color-palettes": "^2.0.0", 26 | "parcel-bundler": "^1.12.2", 27 | "react-dom": "^16.8.4", 28 | "react-testing-library": "^6.0.1", 29 | "ts-jest": "^24.0.0", 30 | "typescript": "^3.3.3333" 31 | }, 32 | "dependencies": { 33 | "@types/styled-components": "~4.0.3", 34 | "classnames": "^2.2.6", 35 | "react": "^16.8.4", 36 | "styled-components": "~4.0.3" 37 | }, 38 | "peerDependencies": { 39 | "react": "^16.8.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/CellWrapper/Cell.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, ReactElement, useState, useEffect } from 'react'; 2 | import styled from 'styled-components'; 3 | import { ForwardRefProps } from '../hooks/useRefsWithInitialSize'; 4 | import { ResizeDirectionOptions } from './ResizeDirectionOptions'; 5 | 6 | export type CellProps = ForwardRefProps & ResizeDirectionOptions & { 7 | children?: string | ReactElement | ReactElement[]; 8 | style?: CSSProperties; 9 | className?: string; 10 | initialWidth?: number; 11 | initialHeight?: number; 12 | } 13 | 14 | export const Cell = styled((props: CellProps) => { 15 | const [initialWidth, setInitialWidth] = useState(null); 16 | const [initialHeight, setInitialHeight] = useState(null); 17 | 18 | useEffect(() => { 19 | setInitialHeight(props.initialHeight); 20 | setInitialWidth(props.initialWidth); 21 | }, []); 22 | 23 | return
32 | {props.children} 33 |
34 | }) 35 | ` 36 | box-sizing: border-box; 37 | overflow: hidden; 38 | `; -------------------------------------------------------------------------------- /src/CellWrapper/ResizeDirectionOptions.ts: -------------------------------------------------------------------------------- 1 | export interface ResizeDirections { 2 | top : boolean; 3 | bottom : boolean; 4 | left : boolean; 5 | right : boolean; 6 | } 7 | /** 8 | * By default, all of these values are `true`, except for `disabled` which is `false` by default 9 | * 10 | */ 11 | export interface ResizeDirectionOptions { 12 | /** 13 | * Default `true` 14 | * Set to `false` to disable resizing by separator above 15 | */ 16 | top? : boolean; 17 | /** 18 | * Default `true` 19 | * Set to `false` to disable resizing by separator below 20 | */ 21 | bottom? : boolean; 22 | /** 23 | * Default `true` 24 | * Set to `false` to disable resizing by separator on the left 25 | */ 26 | left? : boolean; 27 | /** 28 | * Default `true` 29 | * Set to `false` to disable resizing by separator on the right 30 | */ 31 | right? : boolean; 32 | /** 33 | * Default `false` 34 | * Set to `true` to disable resizing 35 | */ 36 | disabled? : boolean; 37 | } 38 | 39 | export const processOptions = ( 40 | options: ResizeDirectionOptions = {} 41 | ): ResizeDirections => { 42 | let { 43 | top = true, 44 | bottom = true, 45 | left = true, 46 | right = true, 47 | disabled = false, 48 | } = options; 49 | 50 | if (disabled) { 51 | top = bottom = left = right = false; 52 | } 53 | 54 | return { 55 | top, bottom, left, right, 56 | }; 57 | } -------------------------------------------------------------------------------- /src/Separators/Separators.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act, fireEvent, render } from 'react-testing-library'; 3 | import 'react-testing-library/cleanup-after-each'; 4 | import { Direction } from '../hooks/useRefsWithInitialSize'; 5 | import { Separator } from './Separator'; 6 | import { actDrag } from './dragTestUtils'; 7 | 8 | describe('Separator Drag Events', () => { 9 | 10 | const prepare = (direction: Direction) => { 11 | const onDragStart = jest.fn(); 12 | const onDrag = jest.fn(); 13 | 14 | const { 15 | container, 16 | baseElement, 17 | } = render( ); 22 | 23 | const element = container.children[0]; 24 | 25 | actDrag({ 26 | element, 27 | baseElement, 28 | to: { 29 | clientX: 100, 30 | clientY: 150, 31 | } 32 | }); 33 | 34 | return { 35 | onDragStart, 36 | onDrag, 37 | } 38 | }; 39 | 40 | test('Horizontal Separator', () => { 41 | const { 42 | onDragStart, 43 | onDrag 44 | } = prepare('horizontal'); 45 | expect(onDragStart).toBeCalledTimes(1); 46 | expect(onDrag).toBeCalledTimes(1); 47 | expect(onDrag).toBeCalledWith(100); 48 | }); 49 | 50 | test('Vertical Separator', () => { 51 | const { 52 | onDragStart, 53 | onDrag 54 | } = prepare('vertical'); 55 | expect(onDragStart).toBeCalledTimes(1); 56 | expect(onDrag).toBeCalledTimes(1); 57 | expect(onDrag).toBeCalledWith(150); 58 | }); 59 | }); -------------------------------------------------------------------------------- /src/CellWrapper/Cell.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act, render } from 'react-testing-library'; 3 | import 'react-testing-library/cleanup-after-each'; 4 | import { Cell } from './Cell'; 5 | import { Col } from './Col'; 6 | import { Row } from './Row'; 7 | 8 | describe('Cells', () => { 9 | 10 | test('Cell onRef', () => { 11 | const onRef = jest.fn() 12 | 13 | const { 14 | container 15 | } = render(); 16 | 17 | expect(onRef).toBeCalledWith(container.children[0]); 18 | }); 19 | 20 | }); 21 | 22 | describe('Row', () => { 23 | 24 | test('Row without initial height', () => { 25 | const { 26 | container 27 | } = render(); 28 | 29 | const element = (container.children as HTMLCollectionOf)[0]; 30 | 31 | expect(getComputedStyle(element).flex).toBe("1"); 32 | }); 33 | 34 | test('Row with initial height', async () => { 35 | let element; 36 | 37 | act(() => { 38 | const { 39 | container 40 | } = render(); 41 | 42 | element = (container.children as HTMLCollectionOf)[0]; 43 | }); 44 | 45 | expect(getComputedStyle(element).flex).toBe("0 0 auto"); 46 | expect(getComputedStyle(element).width).toBe("100%"); 47 | expect(getComputedStyle(element).height).toBe("100px"); 48 | }); 49 | }); 50 | 51 | describe('Col', () => { 52 | 53 | test('Col without initial width', () => { 54 | const { 55 | container 56 | } = render(); 57 | 58 | const element = (container.children as HTMLCollectionOf)[0]; 59 | 60 | expect(getComputedStyle(element).flex).toBe("1"); 61 | }); 62 | 63 | test('Col with initial width', async () => { 64 | let element; 65 | 66 | act(() => { 67 | const { 68 | container 69 | } = render(); 70 | 71 | element = (container.children as HTMLCollectionOf)[0]; 72 | }); 73 | 74 | expect(getComputedStyle(element).flex).toBe("0 0 auto"); 75 | expect(getComputedStyle(element).height).toBe("100%"); 76 | expect(getComputedStyle(element).width).toBe("100px"); 77 | }); 78 | }); -------------------------------------------------------------------------------- /src/hooks/useRefsWithInitialSize.ts: -------------------------------------------------------------------------------- 1 | import React, { Children, ReactElement, Ref, useRef } from 'react'; 2 | 3 | interface RefWithInitialSize { 4 | element: T; 5 | initialSize: number; 6 | } 7 | 8 | export interface ForwardRefProps { 9 | onRef?: Ref; 10 | } 11 | 12 | interface RefsWithInitialSizeHook { 13 | getRef: (index: number) => RefWithInitialSize; 14 | setRef: (index: number, element: T) => void; 15 | /** 16 | * Update the initial size of the element 17 | */ 18 | resetRef: (index: number) => void; 19 | /** 20 | * Clone the children and pass `onRef` props to record the element ref. 21 | */ 22 | childrenWithRef:

>(children: ReactElement

| ReactElement

[]) => ReactElement

[]; 23 | } 24 | 25 | export type Direction = 'horizontal' | 'vertical'; 26 | 27 | const createRefWithInitialSize = < T extends HTMLElement>(direction: Direction, element: T): RefWithInitialSize => { 28 | const boundingClientRect = element.getBoundingClientRect(); 29 | if (direction == 'horizontal') { 30 | return { 31 | element, 32 | initialSize: boundingClientRect.width, 33 | } 34 | } else { 35 | return { 36 | element, 37 | initialSize: boundingClientRect.height 38 | } 39 | } 40 | 41 | } 42 | /** 43 | * Creates a ref that save the `dom element` and the `initial size` for a list of elements. * 44 | * @param direction ["horizontal"|"vertical"] Direction to save initial size. `horizontal` uses `width` | `vertical` uses `height`. 45 | */ 46 | export const useRefsWithInitialSize = < T extends HTMLElement>(direction: Direction): RefsWithInitialSizeHook => { 47 | const refs = useRef[]>(null); 48 | 49 | const getRef = (index: number) => { 50 | const current = refs.current; 51 | return current ? current[index] : null; 52 | } 53 | 54 | const setRef = (index: number, element?: T) => { 55 | if (!element) return; 56 | 57 | const current = refs.current; 58 | refs.current = current ? [...current] : []; 59 | refs.current[index] = createRefWithInitialSize(direction, element); 60 | } 61 | 62 | const resetRef = (index: number) => { 63 | const current = refs.current; 64 | if (current && current[index] && current[index].element) { 65 | setRef(index, current[index].element) 66 | } 67 | } 68 | 69 | const childrenWithRef =

>(children: ReactElement

| ReactElement

[]) => { 70 | return Children.map(children, (child, index) => { 71 | const newProps: Partial

= {}; 72 | newProps.onRef = (ref: T) => setRef(index, ref); 73 | return React.cloneElement

(child, newProps); 74 | }) 75 | } 76 | 77 | return { 78 | getRef, 79 | setRef, 80 | resetRef, 81 | childrenWithRef, 82 | } 83 | } -------------------------------------------------------------------------------- /example/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import styled from 'styled-components'; 4 | import { Col, ColsWrapper, Row, RowsWrapper } from '../src'; 5 | 6 | const color: string[] = require('nice-color-palettes')[0]; 7 | 8 | const Wrapper = styled.div` 9 | border: 5px grey solid 10 | ` 11 | 12 | const StyledRow = styled(Row)` 13 | 14 | ` 15 | 16 | const StyledCol = styled(Col)` 17 | 18 | ` 19 | 20 | const StyledRowsWrapper = (props: PropsWithChildren) => { 21 | return {props.children} 26 | } 27 | 28 | const StyledColsWrapper = (props: PropsWithChildren) => { 29 | return {props.children} 34 | } 35 | 36 | const App = () => { 37 | 38 | return

39 |

React Grid Resizable Example

40 | 41 | 42 | 43 | 44 | 45 | 1.1 46 | 47 | 48 | 1.2 49 | 50 | 51 | 1.3 52 | 53 | 54 | 55 | 56 | Dragging the separator below will not change the size of the third row 57 | 58 | 59 | 60 | 61 | 3.1 62 | 63 | 64 | 65 | 66 | 3.2.1 67 | 68 | 69 | 3.2.2 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 4.1 79 | 80 | 81 | 4.2 82 | 83 | 84 | 4.3 85 | 86 | 87 | 88 | 89 | 90 |
91 | } 92 | 93 | ReactDOM.render(, document.getElementById('app')); -------------------------------------------------------------------------------- /src/Separators/Separator.tsx: -------------------------------------------------------------------------------- 1 | import React, { CSSProperties, ReactElement, useEffect, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Direction } from '../hooks/useRefsWithInitialSize'; 4 | 5 | export interface SeparatorDivProps { 6 | style? : CSSProperties; 7 | className? : string; 8 | children? : ReactElement | ReactElement[]; 9 | direction? : Direction; 10 | } 11 | 12 | interface SeparatorProps extends SeparatorDivProps { 13 | onDragStart: () => void; 14 | onDrag : (distance: number) => void; 15 | } 16 | 17 | interface SeparatorDragEvent { 18 | clientX: number; 19 | clientY: number; 20 | } 21 | 22 | const getPositionFromDragEvent = (direction: Direction, event: SeparatorDragEvent) => { 23 | if (direction == 'horizontal') { 24 | return event.clientX; 25 | } else { 26 | return event.clientY; 27 | } 28 | } 29 | /** 30 | * Set cursor and size of the separator 31 | */ 32 | const SeparatorDiv = styled.div` 33 | user-select: none; 34 | flex: none; 35 | ${props => props.direction == 'horizontal' ? 'height' : 'width'}: 100%; 36 | ${props => props.direction == 'horizontal' ? 'width' : 'height'}: 10px; 37 | cursor: ${props => props.direction == 'horizontal' ? 'e-resize' : 'n-resize'}; 38 | ` 39 | 40 | export const Separator = (props: SeparatorProps) => { 41 | 42 | const [mousePositionAtLastMouseEvent, setMousePositionAtLastMouseEvent] = useState(null); 43 | 44 | const getMovedDistance = (event: SeparatorDragEvent) => { 45 | const mousePosition = getPositionFromDragEvent(props.direction, event); 46 | return mousePosition - mousePositionAtLastMouseEvent; 47 | } 48 | /** 49 | * Start dragging 50 | * Record the initial mouse position to calculate distance later 51 | */ 52 | const mouseDownEventHandler = (event: SeparatorDragEvent) => { 53 | const mousePosition = getPositionFromDragEvent(props.direction, event); 54 | setMousePositionAtLastMouseEvent(mousePosition); 55 | props.onDragStart(); 56 | } 57 | /** 58 | * End dragging 59 | * Clear initial mouse position, which will stop mouse move handling 60 | */ 61 | const mouseUpEventHandler = (event: SeparatorDragEvent) => { 62 | if (mousePositionAtLastMouseEvent) { 63 | setMousePositionAtLastMouseEvent(null); 64 | } 65 | } 66 | /** 67 | * Calculate distance from the initial mouse position when start dragging 68 | * Send the distance to parent component 69 | */ 70 | const mouseMoveEventHandler = (event: SeparatorDragEvent) => { 71 | if (mousePositionAtLastMouseEvent !== null && mousePositionAtLastMouseEvent !== undefined) { 72 | const movedDistance = getMovedDistance(event); 73 | props.onDrag(movedDistance); 74 | } 75 | } 76 | /** 77 | * Listen to mouse move and mouse up at global because the position of the separator 78 | * will not be updated at mouse position instantly 79 | */ 80 | useEffect(() => { 81 | document.body.addEventListener<'mouseup'>('mouseup', mouseUpEventHandler); 82 | document.body.addEventListener<'mousemove'>('mousemove', mouseMoveEventHandler); 83 | return () => { 84 | document.body.addEventListener<'mouseup'>('mouseup', mouseUpEventHandler); 85 | document.body.removeEventListener<'mousemove'>('mousemove', mouseMoveEventHandler); 86 | } 87 | }, [mousePositionAtLastMouseEvent]); 88 | 89 | return {props.children} 95 | } -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # React Grid Resizable 2 | 3 | > [Demo site](https://react-grid-resizable.netlify.com) 4 | 5 | ![](https://media.giphy.com/media/1QaZ8J9WGHbLh8NAIB/giphy.gif) 6 | 7 | # Install 8 | 9 | ```sh 10 | npm install --save react-grid-resizable 11 | # or 12 | npm install --save netcell/react-grid-resizable 13 | ``` 14 | 15 | # Development 16 | 17 | ``` 18 | npm install 19 | npm start 20 | ``` 21 | 22 | Navigate to the url displayed in the terminal. (Normally `http://localhost:1234`) 23 | 24 | # Usage 25 | 26 | ```js 27 | import { Col, ColsWrapper, Row, RowsWrapper } from '../src'; 28 | ``` 29 | 30 | Put multiple `Row` tags inside a `RowsWrapper` tag for a grid of rows and multiple `Col` tags inside a `ColsWrapper` tag for a grid of columns. 31 | 32 | ```html 33 | // Grid of rows 34 | 35 | 36 | 37 | 38 | 39 | // Grid of columns 40 | 41 | 42 | 43 | 44 | 45 | ``` 46 | 47 | These tags can also be nested : 48 | 49 | ```html 50 | // Columns inside a row 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | // Rows inside a column 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ``` 71 | 72 | ## Wrappers 73 | 74 | `RowWrapper` and `CellWrapper` tags accept an object props `separatorProps` that allows you to pass `children`, `style` and `className` props to the separators of the grid. 75 | 76 | ```ts 77 | interface SeparatorDivProps { 78 | style? : CSSProperties; 79 | className? : string; 80 | children? : ReactElement | ReactElement[]; 81 | } 82 | ``` 83 | 84 | For example: 85 | 86 | ```html 87 | 92 | 93 | 94 | ``` 95 | 96 | ## Rows & Cols 97 | 98 | `Row` and `Col` tags accept `style` and `className` props for customizing the rows and columns. 99 | 100 | ### Initial Size 101 | 102 | `Row` and `Col` tags accept `initialHeight` for `Row` tag and `initialWidth` for `Col` tag to set the initial size of the rendered elements. If these props are omitted, the rendered element will be assigned css property `flex: 1`. 103 | 104 | ```html 105 | 106 | 107 | ``` 108 | 109 | ### Disable Separator 110 | 111 | `Row` and `Col` tags accept a set of resize options to disable specific separators from resizing it. 112 | 113 | ```ts 114 | interface ResizeDirectionOptions { 115 | /** 116 | * Default `true` 117 | * Set to `false` to disable resizing by separator above 118 | */ 119 | top? : boolean; 120 | /** 121 | * Default `true` 122 | * Set to `false` to disable resizing by separator below 123 | */ 124 | bottom? : boolean; 125 | /** 126 | * Default `true` 127 | * Set to `false` to disable resizing by separator on the left 128 | */ 129 | left? : boolean; 130 | /** 131 | * Default `true` 132 | * Set to `false` to disable resizing by separator on the right 133 | */ 134 | right? : boolean; 135 | /** 136 | * Default `false` 137 | * Set to `true` to disable resizing 138 | */ 139 | disabled? : boolean; 140 | } 141 | ``` 142 | 143 | For example: 144 | 145 | ```html 146 | // This row won't be resized by the separator above it 147 | 148 | // This column won't be resized by the separator after it 149 | 150 | // This column won't be resized at all 151 | 152 | ``` 153 | 154 | Setting props `top` and `bottom` doesn't have any effect on `Col` tags and setting props `left` and `right` doesn't have any effect on `Row` tags. 155 | -------------------------------------------------------------------------------- /src/GridWrapper/GridWrapper.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { act, render } from 'react-testing-library'; 3 | import 'react-testing-library/cleanup-after-each'; 4 | import { Row } from '../CellWrapper/Row'; 5 | import { actDrag } from '../Separators/dragTestUtils'; 6 | import { RowsWrapper } from './RowsWrapper'; 7 | 8 | describe('Row Wrapper', () => { 9 | beforeEach(() => { 10 | HTMLElement.prototype.getBoundingClientRect = function() { 11 | const style = getComputedStyle(this); 12 | 13 | const width = parseInt(style.width.replace('px', '')) 14 | const height = parseInt(style.height.replace('px', '')) 15 | 16 | const clientRect = { 17 | bottom: 0, 18 | height: height || 100, 19 | left: 0, 20 | right: 0, 21 | top: 0, 22 | width: width || 200, 23 | } 24 | 25 | return clientRect; 26 | }; 27 | }) 28 | 29 | test('3 rows', async () => { 30 | let baseElement: HTMLElement; 31 | let rowsWrapper: HTMLElement; 32 | let rows: HTMLCollectionOf; 33 | act(() => { 34 | const renderResult = render( 35 | 36 | 37 | 38 | ) 39 | 40 | const container = renderResult.container; 41 | baseElement = renderResult.baseElement; 42 | rowsWrapper = (container.children as HTMLCollectionOf)[0]; 43 | rows = rowsWrapper.children as HTMLCollectionOf; 44 | }); 45 | 46 | expect(rows.length).toEqual(5); 47 | 48 | actDrag({ 49 | element: rows[1], 50 | baseElement, 51 | to: { 52 | clientX: 20, 53 | clientY: 30, 54 | } 55 | }); 56 | 57 | expect(getComputedStyle(rows[0]).height).toEqual('130px'); 58 | expect(getComputedStyle(rows[0]).flex).toEqual('0 0 auto'); 59 | expect(getComputedStyle(rows[2]).height).toEqual('70px'); 60 | expect(getComputedStyle(rows[2]).flex).toEqual('0 0 auto'); 61 | expect(getComputedStyle(rows[4]).height).toEqual('100px'); 62 | expect(getComputedStyle(rows[4]).flex).toEqual('0 0 auto'); 63 | 64 | actDrag({ 65 | element: rows[3], 66 | baseElement, 67 | to: { 68 | clientX: 20, 69 | clientY: 30, 70 | } 71 | }); 72 | 73 | expect(getComputedStyle(rows[0]).height).toEqual('130px'); 74 | expect(getComputedStyle(rows[0]).flex).toEqual('0 0 auto'); 75 | expect(getComputedStyle(rows[2]).height).toEqual('100px'); 76 | expect(getComputedStyle(rows[2]).flex).toEqual('0 0 auto'); 77 | expect(getComputedStyle(rows[4]).height).toEqual('70px'); 78 | expect(getComputedStyle(rows[4]).flex).toEqual('0 0 auto'); 79 | }) 80 | 81 | test('3 rows with 2nd row not resize by the first separator', async () => { 82 | let baseElement: HTMLElement; 83 | let rowsWrapper: HTMLElement; 84 | let rows: HTMLCollectionOf; 85 | act(() => { 86 | const renderResult = render( 87 | 88 | 89 | 90 | ) 91 | 92 | const container = renderResult.container; 93 | baseElement = renderResult.baseElement; 94 | rowsWrapper = (container.children as HTMLCollectionOf)[0]; 95 | rows = rowsWrapper.children as HTMLCollectionOf; 96 | }); 97 | 98 | expect(rows.length).toEqual(5); 99 | 100 | actDrag({ 101 | element: rows[1], 102 | baseElement, 103 | to: { 104 | clientX: 20, 105 | clientY: 30, 106 | } 107 | }); 108 | 109 | expect(getComputedStyle(rows[0]).height).toEqual('130px'); 110 | expect(getComputedStyle(rows[0]).flex).toEqual('0 0 auto'); 111 | expect(getComputedStyle(rows[2]).height).toEqual('100px'); 112 | expect(getComputedStyle(rows[2]).flex).toEqual('0 0 auto'); 113 | expect(getComputedStyle(rows[4]).height).toEqual('100px'); 114 | expect(getComputedStyle(rows[4]).flex).toEqual('0 0 auto'); 115 | 116 | actDrag({ 117 | element: rows[3], 118 | baseElement, 119 | to: { 120 | clientX: 20, 121 | clientY: 30, 122 | } 123 | }); 124 | 125 | expect(getComputedStyle(rows[0]).height).toEqual('130px'); 126 | expect(getComputedStyle(rows[0]).flex).toEqual('0 0 auto'); 127 | expect(getComputedStyle(rows[2]).height).toEqual('130px'); 128 | expect(getComputedStyle(rows[2]).flex).toEqual('0 0 auto'); 129 | expect(getComputedStyle(rows[4]).height).toEqual('70px'); 130 | expect(getComputedStyle(rows[4]).flex).toEqual('0 0 auto'); 131 | }) 132 | 133 | 134 | }); -------------------------------------------------------------------------------- /src/GridWrapper/GridWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { clamp, last } from 'lodash'; 2 | import React, { ReactElement } from 'react'; 3 | import { CellProps } from '../CellWrapper/Cell'; 4 | import { processOptions } from '../CellWrapper/ResizeDirectionOptions'; 5 | import { Direction, useRefsWithInitialSize } from '../hooks/useRefsWithInitialSize'; 6 | import { Separator, SeparatorDivProps } from '../Separators/Separator'; 7 | 8 | /** 9 | * Interface for extending this wrapper by providing specific direction 10 | */ 11 | export interface GridWrapperProps

{ 12 | children? : ReactElement

| ReactElement

[]; 13 | /** 14 | * Provide props to the separators of the grid 15 | */ 16 | separatorProps?: SeparatorDivProps; 17 | } 18 | 19 | interface GridWrapperPropsWithDirection

extends GridWrapperProps

{ 20 | direction: Direction; 21 | } 22 | 23 | const getStylePropertyForSize = (direction: Direction) => { 24 | if (direction == 'horizontal') { 25 | return 'width'; 26 | } else { 27 | return 'height'; 28 | } 29 | } 30 | 31 | const getDirectionOptions = (direction: Direction) => { 32 | if (direction == 'horizontal') { 33 | return { 34 | current: 'left', 35 | previous: 'right' 36 | } 37 | } else { 38 | return { 39 | current: 'top', 40 | previous: 'bottom' 41 | } 42 | } 43 | } 44 | 45 | export const GridWrapper =

(props: GridWrapperPropsWithDirection

) => { 46 | const { 47 | getRef, 48 | resetRef, 49 | childrenWithRef, 50 | } = useRefsWithInitialSize(props.direction); 51 | 52 | const stylePropertyForSize = getStylePropertyForSize(props.direction); 53 | const directionOptions = getDirectionOptions(props.direction); 54 | 55 | const resizeElement = (element: HTMLDivElement, initialSize: number, sizeChange: number) => { 56 | element.style[stylePropertyForSize] = `${initialSize + sizeChange}px`; 57 | /** 58 | * If the element is resized, the flex property must be set to `none` 59 | * Otherwise, the element will not be able to get smaller 60 | */ 61 | element.style.flex = 'none'; 62 | } 63 | /** 64 | * Create an event handler to save the size of the cells around the separator before dragging 65 | * @param currentIndex Index of the element after the separator 66 | */ 67 | const dragStartHandlerCreator = (currentIndex: number) => () => { 68 | resetRef(currentIndex - 1); 69 | resetRef(currentIndex); 70 | } 71 | /** 72 | * Create an event handler to update the size of the cells around the separator when it is dragged 73 | * @param currentIndex Index of the element after the separator 74 | * @param resizeCurrent Should the element after the separator be resized 75 | * @param resizePrevious Should the element before the separator be resized 76 | */ 77 | const dragHandlerCreator = (currentIndex: number, resizeCurrent: boolean, resizePrevious: boolean) => (distance: number) => { 78 | const previousRef = getRef(currentIndex - 1); 79 | const currentRef = getRef(currentIndex); 80 | 81 | const previousInitialSize = previousRef.initialSize; 82 | const currentInitialSize = currentRef.initialSize; 83 | /** 84 | * We need to clamp the distance so that it does not exceed the size of the elements around the separator 85 | * If we do not do this, when one element might receive negative number as size which is not a problem 86 | * but the problem is that the other element will start extending in size 87 | */ 88 | distance = clamp( 89 | distance, 90 | resizePrevious ? -previousInitialSize : distance, 91 | resizeCurrent ? currentInitialSize : distance 92 | ); 93 | 94 | if (resizePrevious) { 95 | resizeElement(previousRef.element, previousInitialSize, distance); 96 | } 97 | 98 | if (resizeCurrent) { 99 | resizeElement(currentRef.element, currentInitialSize, -distance); 100 | } 101 | } 102 | 103 | const childrenWithSeparator = childrenWithRef

(props.children) 104 | /** 105 | * Insert Separator between children and set event handler 106 | */ 107 | .reduce((newChildren, currentChild, currentIndex, children) => { 108 | if (!newChildren.length) { 109 | return [ 110 | currentChild, 111 | ] 112 | } else { 113 | const previousChild = last(newChildren); 114 | 115 | const resizePrevious = processOptions(previousChild.props)[directionOptions.previous]; 116 | /** 117 | * Should not resize the last element in the grid if it is a flex one 118 | */ 119 | const isLastElement = currentIndex == children.length - 1; 120 | const hasInitialSize = currentChild.props.initialHeight || currentChild.props.initialWidth; 121 | const resizeCurrent = (isLastElement && !hasInitialSize) ? false : processOptions(currentChild.props )[directionOptions.current]; 122 | /** 123 | * Separator is not inserted if these elements don't want to be resized here 124 | */ 125 | if (!resizePrevious && !resizeCurrent) { 126 | return [ 127 | ...newChildren, 128 | currentChild, 129 | ]; 130 | } else { 131 | const onDragStart = dragStartHandlerCreator(currentIndex); 132 | const onDrag = dragHandlerCreator(currentIndex, resizeCurrent, resizePrevious); 133 | 134 | return [ 135 | ...newChildren, 136 | , 142 | currentChild, 143 | ]; 144 | } 145 | 146 | } 147 | }, []); 148 | 149 | return <> 150 | {childrenWithSeparator} 151 | 152 | } --------------------------------------------------------------------------------