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 = {