Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💄 GC-1464 Allow users to resize the sidebar in the Content Hub #1296

Merged
merged 34 commits into from
Sep 26, 2023
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e5b8600
:construction: PoC for a component that handles resizing with pure CSS
timbryandev Sep 8, 2023
12633d6
:pencil2: use correct export name
timbryandev Sep 8, 2023
e7262ae
:construction: looks like we do need js after all 😞
timbryandev Sep 8, 2023
523fdfc
:fire: remove superfluous stuff from sloppy copy/paste job
timbryandev Sep 8, 2023
1904a4c
:bug: account for the width of the gutter
timbryandev Sep 8, 2023
bf490bc
:pencil2: resizable type
timbryandev Sep 13, 2023
79247db
:recycle: make width stateful
timbryandev Sep 14, 2023
0a38836
:recycle: general cleanup of component post-testing
timbryandev Sep 14, 2023
a589064
:recycle: general cleanup of component post-testing
timbryandev Sep 14, 2023
35faca1
:lipstick: resize bar to have blue line in middle
timbryandev Sep 14, 2023
7b7de8b
:lipstick: add gutter
timbryandev Sep 14, 2023
34a8230
:sparkles: gutter should follow cursor
timbryandev Sep 14, 2023
4d2a42e
:lipstick: remove debugging styles
timbryandev Sep 14, 2023
e6e899d
:recycle: move helpers
timbryandev Sep 14, 2023
42d08cf
:bug: prevent gutter handle from going out-out-of-bounds
timbryandev Sep 14, 2023
761e3ee
:label: correct prop types
timbryandev Sep 15, 2023
2d0d345
:recycle: use ref over state for setting dom element width
timbryandev Sep 15, 2023
92b36eb
:sparkles: gutter offset prop - helps when "position" or "flex" are a…
timbryandev Sep 15, 2023
832a7c5
:coffin: z-index has no effect here
timbryandev Sep 15, 2023
65677b2
:recycle: improve readability of normaliseUnitsToPixelValue
timbryandev Sep 15, 2023
72b83dd
:recycle: better name for `y`
timbryandev Sep 15, 2023
c4b1adb
:recycle: better helper name
timbryandev Sep 15, 2023
3dce17b
:white_check_mark: helper util tests
timbryandev Sep 15, 2023
705cebb
:wrench: include ts in vitest
timbryandev Sep 15, 2023
6b5990c
:lipstick: prevent child elements leaking over the resizer
timbryandev Sep 15, 2023
70aae46
:bug: fix storybook examples
timbryandev Sep 15, 2023
2290f68
:white_check_mark: spy on console.warn
timbryandev Sep 15, 2023
0f5e73c
:lipstick: make examples 100% page width
timbryandev Sep 15, 2023
69183c8
:pencil2: use resizable spelling, containerWidth prop
timbryandev Sep 15, 2023
b1140f0
:zap: keep reference to event handlers for clean event listener removal
timbryandev Sep 26, 2023
6aa07e0
:label: swap jsdoc for better variable names
timbryandev Sep 26, 2023
ed8cb41
:recycle: remove duplicated keepValueWithinRange calculation
timbryandev Sep 26, 2023
3ec581d
:recycle: replace rest prop in favor of prop destructuring
timbryandev Sep 26, 2023
6190595
Merge branch 'main' into feature/GC-1464-resizable-sidebar
timbryandev Sep 26, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions lib/Resizeable/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { PropsWithChildren } from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { keepValueWithinRange, normaliseUnitsToPixelValue } from "../helpers";

export interface ResizeableProps {
defaultWidth?: string;
minWidth?: string;
maxWidth?: string;
}

export function Resizeable({
children,
defaultWidth = "50%",
maxWidth,
minWidth,
}: PropsWithChildren<ResizeableProps>) {
const handleRef = useRef<HTMLSpanElement>(null);
const min = normaliseUnitsToPixelValue(minWidth ?? 0);
const max = normaliseUnitsToPixelValue(maxWidth ?? "100%");
const gutterSize = 16;

const [state, setState] = React.useState({
startX: 0,
startWidth: 0,
width: keepValueWithinRange(
normaliseUnitsToPixelValue(defaultWidth),
min,
max
),
});

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

setState((prev) => ({
...prev,
width: keepValueWithinRange(newWidth, min, max),
}));
};

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

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

const newState = {
...state,
startX: clientX,
startWidth: state.width,
};

setState(newState);

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 y = keepValueWithinRange(
evt.clientY - rect.top,
handleOffset,
gutter.offsetHeight - handleOffset
);

handle.style.top = `${y - handleOffset}px`;
timbryandev marked this conversation as resolved.
Show resolved Hide resolved
};

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

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

.resizeable {
position: relative;
display: flex;

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

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

.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;
}
}
56 changes: 47 additions & 9 deletions lib/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
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)
);
};
timbryandev marked this conversation as resolved.
Show resolved Hide resolved

export const normaliseUnitsToPixelValue = (
value: string | number,
container: HTMLElement = document.body
) => {
if (typeof value === "number") {
// assume value is already in pixels
return value;
}

if (value.endsWith("px")) {
return parseInt(value, 10);
}

if (value.endsWith("%")) {
const containerWidthAsPercent = container.offsetWidth / 100;
return containerWidthAsPercent * parseInt(value, 10);
}

console.warn(
`Could not interpret a normalised value for "${value}. Parsing directly to integer.`
);
return parseInt(value, 10);
};

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 { Resizeable } from "./Resizeable";
export { Progress } from "./Progress";
export { SearchInput } from "./SearchInput";
export { Modal } from "./Modal";
Expand Down
71 changes: 71 additions & 0 deletions stories/components/Resizable.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import React from "react";
import { Resizeable as ResizableComponent } from "../../lib/Resizeable";

import StoryItem from "../styleguide/StoryItem";

export default {
title: "GUI/Resizeable",
component: ResizableComponent,
};

export function Resizeable() {
return (
<>
<StoryItem
title="Vertically split resizeable element"
description="So you could have a split-pane or resizeable horizontal menu"
>
<div
style={{
display: "flex",
flex: "1",
minHeight: "0",
border: "1px solid red",
height: 600,
}}
>
<ResizableComponent
defaultWidth="240px"
maxWidth="33.33%"
minWidth="240px"
>
<div
style={{
width: "400px",
flexShrink: "0",
}}
>
Hello
</div>
</ResizableComponent>
<div
style={{
padding: "20px",
width: "100%",
minWidth: "0",
flexDirection: "column",
display: " flex !important",
}}
>
How are you?
</div>
</div>
</StoryItem>

<StoryItem
title="Single resizeable element"
description="Could be used to reveal a before and after of two images"
>
<div style={{ border: "1px solid red", width: "50%" }}>
Not resizeable
</div>
<ResizableComponent defaultWidth="50%" maxWidth="75%" minWidth="200px">
<div style={{ border: "1px solid green", width: "50%" }}>Hello</div>
</ResizableComponent>
<div style={{ border: "1px solid red", width: "50%" }}>
Not resizeable
</div>
</StoryItem>
</>
);
}
1 change: 1 addition & 0 deletions styles/components/_ui.scss
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
@import "../../lib/DropdownMenu/styles";
@import "../../lib/Progress/styles";
@import "../../lib/ProgressButton/styles";
@import "../../lib/Resizeable/styles";
@import "../../lib/Modal/styles";
@import "../../lib/FigurePlaceholder/styles";
@import "../../lib/Conversation/styles";
Expand Down