diff --git a/CHANGELOG.md b/CHANGELOG.md index 12af63e..7fdeffd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +## [5.2.0] 2022-06-29 + +### Added + +- Added `HorizontalResizeablePanel` component + ## [5.1.0] 2022-06-28 ### Added diff --git a/package-lock.json b/package-lock.json index 9a595dd..e6e9d6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "@perimetre/ui", - "version": "4.1.1", + "version": "5.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 8fc84e0..2e66ca0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@perimetre/ui", "description": "A component library made by @perimetre", - "version": "5.1.0", + "version": "5.2.0", "repository": { "type": "git", "url": "git+https://github.com/perimetre/ui.git" diff --git a/src/components/HorizontalResizeablePanel/HorizontalResizeablePanel.stories.tsx b/src/components/HorizontalResizeablePanel/HorizontalResizeablePanel.stories.tsx new file mode 100644 index 0000000..3d0ce5e --- /dev/null +++ b/src/components/HorizontalResizeablePanel/HorizontalResizeablePanel.stories.tsx @@ -0,0 +1,70 @@ +// also exported from '@storybook/react' if you can deal with breaking changes in 6.1 +import { Meta, Story } from '@storybook/react/types-6-0'; +import classnames from 'classnames'; +import React from 'react'; +import { HorizontalResizeablePanel } from '.'; +import { colorOptions, widthHeightOptions } from '../../prebuiltTailwindTheme'; +import { backgroundColorClassnameMap, heightClassnameMap, widthClassnameMap } from '../../storybookMappers'; + +export default { + title: 'Components/HorizontalResizeablePanel', + component: HorizontalResizeablePanel, + argTypes: { + resizeRight: { + defaultValue: true + }, + width: { + defaultValue: '1/4', + control: { + type: 'select', + options: widthHeightOptions + } + }, + height: { + defaultValue: 'screen', + control: { + type: 'select', + options: widthHeightOptions + } + }, + backgroundColor: { + defaultValue: 'pui-primary', + control: { + type: 'select', + options: colorOptions + } + }, + className: { + control: { + type: 'text' + } + } + } +} as Meta; + +/** + * A story that displays a horizontal resizeable example + * + * @param props The story props + * @param props.width The example width size + * @param props.height The example height size + * @param props.backgroundColor the example background color + * @param props.className the classname to pass down if any + */ +const Template: Story = ({ width, height, backgroundColor, className, ...props }) => { + return ( +
0, + [widthClassnameMap[width || 'auto']]: width && width.length > 0 + })} + > + +
+ ); +}; + +export const Default = Template.bind({}); diff --git a/src/components/HorizontalResizeablePanel/index.tsx b/src/components/HorizontalResizeablePanel/index.tsx new file mode 100644 index 0000000..0a5583c --- /dev/null +++ b/src/components/HorizontalResizeablePanel/index.tsx @@ -0,0 +1,133 @@ +import classNames from 'classnames'; +import React, { useCallback, useEffect, useRef } from 'react'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type HorizontalResizeablePanelProps = React.HTMLAttributes & { + /** + * Turn on or off the resizing behavior on the left border + */ + resizeLeft?: boolean; + /** + * Turn on or off the resizing behavior on the right border + */ + resizeRight?: boolean; + /** + * The minimum width allowed when resizing from the left border + */ + minLeftSize?: number; + /** + * The maximum width allowed when resizing from the left border + */ + maxLeftSize?: number; + /** + * The minimum width allowed when resizing from the right border + */ + minRightSize?: number; + /** + * The maximum width allowed when resizing from the right border + */ + maxRightSize?: number; +}; + +/** + * A panel with resize abilities on its left and right borders + * + * @param props The component props + * @param props.resizeLeft Turn on or off the resizing behavior on the left border + * @param props.resizeRight Turn on or off the resizing behavior on the right border + * @param props.minLeftSize The minimum width allowed when resizing from the left border + * @param props.maxLeftSize The maximum width allowed when resizing from the left border + * @param props.minRightSize The minimum width allowed when resizing from the right border + * @param props.maxRightSize The maximum width allowed when resizing from the right border + * @param props.children The element children components + */ +export const HorizontalResizeablePanel: React.FC = ({ + resizeLeft, + resizeRight, + minLeftSize, + maxLeftSize, + minRightSize, + maxRightSize, + children, + ...props +}) => { + const dragRef = useRef<{ isResizing: boolean; lastDownX: number; resizeSide: 'left' | 'right' } | null>(null); + const ref = useRef(null); + + useEffect(() => { + /** + * Handler for when the user is moving the mouse + * + * @param e The mouse event + */ + const onMouseMove = (e: MouseEvent) => { + // we don't want to do anything if we aren't resizing. + if (!ref.current || !dragRef.current?.isResizing) return; + + if (dragRef.current.resizeSide === 'left') { + let width = ref.current.clientWidth + (e.clientX - ref.current.offsetLeft) * -1; + + if (minLeftSize && width < minLeftSize) { + width = minLeftSize; + } + + if (maxLeftSize && width > maxLeftSize) { + width = maxLeftSize; + } + + ref.current.style.width = `${width}px`; + } else if (dragRef.current.resizeSide === 'right') { + let width = e.clientX - ref.current.offsetLeft; + + if (minRightSize && width < minRightSize) { + width = minRightSize; + } + + if (maxRightSize && width > maxRightSize) { + width = maxRightSize; + } + + ref.current.style.width = `${width}px`; + } + }; + + /** + * Handler for when the user stops dragging the mouse + */ + const onMouseUp = () => { + // Resets the values + dragRef.current = { isResizing: false, lastDownX: 0, resizeSide: 'left' }; + }; + + window.addEventListener('mousemove', onMouseMove, false); + window.addEventListener('mouseup', onMouseUp, false); + return () => { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + }, [maxLeftSize, maxRightSize, minLeftSize, minRightSize]); + + const onMove = useCallback((e: React.MouseEvent, resizeSide: 'left' | 'right') => { + dragRef.current = { isResizing: true, lastDownX: e.clientX, resizeSide }; + }, []); + + return ( +
+ {resizeLeft && ( +
+ ); +}; diff --git a/src/components/index.tsx b/src/components/index.tsx index 66b473a..57cad9a 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -24,3 +24,4 @@ export * from './ResourcesCard'; export * from './ExpertCard'; export * from './ProgramCard'; export * from './BaseCard'; +export * from './HorizontalResizeablePanel'; diff --git a/src/storybookMappers.tsx b/src/storybookMappers.tsx index 3d90e3f..52f1de4 100644 --- a/src/storybookMappers.tsx +++ b/src/storybookMappers.tsx @@ -109,6 +109,103 @@ export const puiColorClassnameMap = { 'pui-success': 'pui-color-pui-success' }; +export const backgroundColorClassnameMap = { + transparent: 'bg-transparent', + current: 'bg-current', + black: 'bg-black', + white: 'bg-white', + 'gray-50': 'bg-gray-50', + 'gray-100': 'bg-gray-100', + 'gray-200': 'bg-gray-200', + 'gray-300': 'bg-gray-300', + 'gray-400': 'bg-gray-400', + 'gray-500': 'bg-gray-500', + 'gray-600': 'bg-gray-600', + 'gray-700': 'bg-gray-700', + 'gray-800': 'bg-gray-800', + 'gray-900': 'bg-gray-900', + 'red-50': 'bg-red-50', + 'red-100': 'bg-red-100', + 'red-200': 'bg-red-200', + 'red-300': 'bg-red-300', + 'red-400': 'bg-red-400', + 'red-500': 'bg-red-500', + 'red-600': 'bg-red-600', + 'red-700': 'bg-red-700', + 'red-800': 'bg-red-800', + 'red-900': 'bg-red-900', + 'yellow-50': 'bg-yellow-50', + 'yellow-100': 'bg-yellow-100', + 'yellow-200': 'bg-yellow-200', + 'yellow-300': 'bg-yellow-300', + 'yellow-400': 'bg-yellow-400', + 'yellow-500': 'bg-yellow-500', + 'yellow-600': 'bg-yellow-600', + 'yellow-700': 'bg-yellow-700', + 'yellow-800': 'bg-yellow-800', + 'yellow-900': 'bg-yellow-900', + 'green-50': 'bg-green-50', + 'green-100': 'bg-green-100', + 'green-200': 'bg-green-200', + 'green-300': 'bg-green-300', + 'green-400': 'bg-green-400', + 'green-500': 'bg-green-500', + 'green-600': 'bg-green-600', + 'green-700': 'bg-green-700', + 'green-800': 'bg-green-800', + 'green-900': 'bg-green-900', + 'blue-50': 'bg-blue-50', + 'blue-100': 'bg-blue-100', + 'blue-200': 'bg-blue-200', + 'blue-300': 'bg-blue-300', + 'blue-400': 'bg-blue-400', + 'blue-500': 'bg-blue-500', + 'blue-600': 'bg-blue-600', + 'blue-700': 'bg-blue-700', + 'blue-800': 'bg-blue-800', + 'blue-900': 'bg-blue-900', + 'indigo-50': 'bg-indigo-50', + 'indigo-100': 'bg-indigo-100', + 'indigo-200': 'bg-indigo-200', + 'indigo-300': 'bg-indigo-300', + 'indigo-400': 'bg-indigo-400', + 'indigo-500': 'bg-indigo-500', + 'indigo-600': 'bg-indigo-600', + 'indigo-700': 'bg-indigo-700', + 'indigo-800': 'bg-indigo-800', + 'indigo-900': 'bg-indigo-900', + 'purple-50': 'bg-purple-50', + 'purple-100': 'bg-purple-100', + 'purple-200': 'bg-purple-200', + 'purple-300': 'bg-purple-300', + 'purple-400': 'bg-purple-400', + 'purple-500': 'bg-purple-500', + 'purple-600': 'bg-purple-600', + 'purple-700': 'bg-purple-700', + 'purple-800': 'bg-purple-800', + 'purple-900': 'bg-purple-900', + 'pink-50': 'bg-pink-50', + 'pink-100': 'bg-pink-100', + 'pink-200': 'bg-pink-200', + 'pink-300': 'bg-pink-300', + 'pink-400': 'bg-pink-400', + 'pink-500': 'bg-pink-500', + 'pink-600': 'bg-pink-600', + 'pink-700': 'bg-pink-700', + 'pink-800': 'bg-pink-800', + 'pink-900': 'bg-pink-900', + 'pui-primary': 'bg-pui-primary', + 'pui-secondary': 'bg-pui-secondary', + 'pui-paragraph-0': 'bg-pui-paragraph-0', + 'pui-paragraph-300': 'bg-pui-paragraph-300', + 'pui-paragraph-500': 'bg-pui-paragraph-500', + 'pui-paragraph-900': 'bg-pui-paragraph-900', + 'pui-initial': 'bg-pui-initial', + 'pui-placeholder-color': 'bg-pui-placeholder-color', + 'pui-error': 'bg-pui-error', + 'pui-success': 'bg-pui-success' +}; + export type ColorMapper = typeof puiColorClassnameMap; export const gradientFromClassNameMap: ColorMapper = {