Skip to content

Commit

Permalink
Merge pull request #1296 from Bynder/feature/GC-1464-resizable-sidebar
Browse files Browse the repository at this point in the history
💄  GC-1464 Allow users to resize the sidebar in the Content Hub
  • Loading branch information
timbryandev authored Sep 26, 2023
2 parents 2e6bb51 + 6190595 commit 3c040d7
Show file tree
Hide file tree
Showing 8 changed files with 526 additions and 25 deletions.
48 changes: 48 additions & 0 deletions lib/.specs/helpers.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { keepValueWithinRange, toPixels } from "../helpers";

describe("helpers: toPixels", () => {
it("should not convert arguments of type number", () => {
expect(toPixels(100)).toBe(100);
});

it("should convert pixels to number", () => {
expect(toPixels(`100px`)).toBe(100);
});

it("should convert percentage to number", () => {
expect(toPixels(`100%`)).toBe(100);
});

it("should convert to a percentage value of the percentageOf arg", () => {
expect(toPixels("50%", 200)).toBe(100);
});

it("should return number and warning for unknown units", () => {
const warn = vi.spyOn(global.console, "warn");

expect(toPixels("123rem")).toBe(123);
expect(warn).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
`Could not interpret a normalised value for: 123rem.\nParsing directly to integer: 123.`
);
});
});

describe("helpers: keepValueWithinRange", () => {
it("should return the value if there are no other arguments", () => {
expect(keepValueWithinRange(50)).toBe(50);
});

it("should return the value if it is within the range", () => {
expect(keepValueWithinRange(50, 33, 77)).toBe(50);
});

it("should return the max if the value is above the max", () => {
expect(keepValueWithinRange(11, 1, 10)).toBe(10);
});

it("should return the min if the value is below the min", () => {
expect(keepValueWithinRange(0, 1, 10)).toBe(1);
});
});
109 changes: 109 additions & 0 deletions lib/Resizable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { PropsWithChildren } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { keepValueWithinRange, toPixels } from "../helpers";

export interface ResizableProps {
containerWidth?: number | string;
initialWidth?: number | string;
minResizableWidth?: number | string;
maxResizableWidth?: number | string;
useGutterOffset?: boolean;
}

export function Resizable(props: PropsWithChildren<ResizableProps>) {
const { children, initialWidth = "50%", useGutterOffset = false } = props;
const containerWidth: number = toPixels(
props.containerWidth ?? document.body.offsetWidth
);
const gutterSize = useGutterOffset ? 16 : 0;
const minWidth = toPixels(props.minResizableWidth ?? 0, containerWidth);
const maxWidth = toPixels(props.maxResizableWidth ?? "100%", containerWidth);

const doDragRef = useRef<(evt: MouseEvent) => void>(() => {});
const stopDragRef = useRef<(evt: MouseEvent) => void>(() => {});
const resizeRef = useRef<HTMLDivElement>(null);
const resizeWrapperRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLSpanElement>(null);

const [state, setState] = React.useState({
startX: 0,
startWidth: 0,
});

const getWidth = () => toPixels(resizeWrapperRef.current?.style.width || 0);

const setWidth = (value: number) => {
if (resizeWrapperRef.current === null) return;

resizeWrapperRef.current.style.width = `${
keepValueWithinRange(value, minWidth, maxWidth) - gutterSize
}px`;
};

const doDrag = (evt: MouseEvent) => {
const newWidth = state.startWidth + evt.clientX - state.startX;
setWidth(newWidth);
};

const stopDrag = () => {
document.removeEventListener("mousemove", doDragRef.current, false);
document.removeEventListener("mouseup", stopDragRef.current, false);
};

const initDrag = useCallback(
(evt: React.DragEvent<HTMLDivElement>) => {
const { clientX } = evt;

setState({
startX: clientX,
startWidth: getWidth(),
});

doDragRef.current = doDrag;
stopDragRef.current = stopDrag;
document.addEventListener("mousemove", doDrag, false);
document.addEventListener("mouseup", stopDrag, false);
},
[setState]
);

const setGutterHandlePosition = (evt: React.MouseEvent<HTMLSpanElement>) => {
const handle = handleRef.current;
if (handle === null) return;

const rect = evt.currentTarget.getBoundingClientRect();
const gutter = handle.parentElement as HTMLSpanElement;
const handleOffset = handle.offsetHeight / 2;

const yPosition = keepValueWithinRange(
evt.clientY - rect.top,
handleOffset,
gutter.offsetHeight - handleOffset
);

handle.style.top = `${yPosition - handleOffset}px`;
};

useEffect(() => {
setWidth(toPixels(initialWidth));

// remember to remove global listeners on dismount
return () => stopDrag();
}, []);

return (
<div ref={resizeRef} className="resizable">
<div ref={resizeWrapperRef} className="resizable__wrapper">
{children}
<span
role="none"
className="resizable__gutter"
onMouseDown={initDrag}
onMouseMove={setGutterHandlePosition}
>
<span ref={handleRef} className="resizable__gutter-handle" />
</span>
</div>
</div>
);
}
55 changes: 55 additions & 0 deletions lib/Resizable/styles.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
$gutterSize: 16px;
$gutterColour: #006aff;

.resizable {
position: relative;
display: flex;

&__wrapper {
position: relative;
display: flex;
align-items: stretch;
}

&__wrapper > *:first-child {
width: 100% !important;
overflow-x: hidden;
}
}

.resizable__gutter {
position: absolute;
user-select: none;
height: 100%;
width: $gutterSize;
top: 0;
right: -(calc($gutterSize / 2));
cursor: ew-resize;

&::before {
content: "";
width: 2px;
margin: auto;
background-color: $gutterColour;
display: block;
height: 100%;
}
}

.resizable__gutter-handle {
background-color: $gutterColour;
border-radius: 4px;
height: 30px;
margin: auto;
width: 8px;
display: flex;
position: absolute;
transform: translateX(-50%);
left: 50%;
opacity: 0;
transition: opacity 0.1s;

.resizable__gutter:hover & {
opacity: 1;
}
}
51 changes: 42 additions & 9 deletions lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,49 @@
export const pluralisePerson = (count: any) => count === 1 ? `${count} person` : `${count} people`;
export const pluralisePerson = (count: any) =>
count === 1 ? `${count} person` : `${count} people`;

export const pluraliseHas = (count: any) => count === 1 ? 'has' : 'have';
export const pluraliseHas = (count: any) => (count === 1 ? "has" : "have");

export const filterUsers = (users: any, term: any, searchByEmail = false) => {
const safeTerm = term.toLowerCase();
return users.filter(
(user: any) => user.name
.toLowerCase()
.split(' ')
.filter((subStr: any) => subStr.lastIndexOf(safeTerm, 0) === 0).length > 0 ||
user.name.toLowerCase().lastIndexOf(safeTerm, 0) === 0 ||
user.display.toLowerCase().lastIndexOf(safeTerm, 0) === 0 ||
(searchByEmail && user.email.toLowerCase().lastIndexOf(safeTerm, 0) === 0)
(user: any) =>
user.name
.toLowerCase()
.split(" ")
.filter((subStr: any) => subStr.lastIndexOf(safeTerm, 0) === 0).length >
0 ||
user.name.toLowerCase().lastIndexOf(safeTerm, 0) === 0 ||
user.display.toLowerCase().lastIndexOf(safeTerm, 0) === 0 ||
(searchByEmail && user.email.toLowerCase().lastIndexOf(safeTerm, 0) === 0)
);
};

export const toPixels = (value: string | number, percentageOf = 100) => {
if (typeof value === "number") return value;

const integer = parseInt(value, 10);

if (value.endsWith("px")) {
return integer;
}

if (value.endsWith("%")) {
return (percentageOf / 100) * integer;
}

console.warn(
`Could not interpret a normalised value for: ${value}.\nParsing directly to integer: ${integer}.`
);
return integer;
};

export const keepValueWithinRange = (
start: number,
min?: number,
max?: number
) => {
let value = start;
if (typeof max === "number") value = Math.min(value, max);
if (typeof min === "number") value = Math.max(value, min);
return value;
};
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { NotificationInlineWarning } from "./Notification/inline/NotificationInl
export { Dropdown } from "./Dropdown";
export { DropdownMenu } from "./DropdownMenu";
export { ProgressButton } from "./ProgressButton";
export { Resizable } from "./Resizable";
export { Progress } from "./Progress";
export { SearchInput } from "./SearchInput";
export { Modal } from "./Modal";
Expand Down
Loading

0 comments on commit 3c040d7

Please sign in to comment.