-
Notifications
You must be signed in to change notification settings - Fork 0
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
Changes from 29 commits
e5b8600
12633d6
e7262ae
523fdfc
1904a4c
bf490bc
79247db
0a38836
a589064
35faca1
7b7de8b
34a8230
4d2a42e
e6e899d
42d08cf
761e3ee
2d0d345
92b36eb
832a7c5
65677b2
72b83dd
c4b1adb
3dce17b
705cebb
6b5990c
70aae46
2290f68
0f5e73c
69183c8
b1140f0
6aa07e0
ed8cb41
3ec581d
6190595
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,123 @@ | ||
import type { PropsWithChildren } from "react"; | ||
import React, { useCallback, useEffect, useRef } from "react"; | ||
import { keepValueWithinRange, toPixels } from "../helpers"; | ||
|
||
export interface ResizableProps { | ||
defaultWidth?: number | string; | ||
minWidth?: number | string; | ||
maxWidth?: number | string; | ||
useGutterOffset?: boolean; | ||
containerWidth?: number | string; | ||
} | ||
|
||
/** | ||
* A wrapper for making a given child element resizable | ||
* @param children The element to be resized | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's the reasoning for having types and comment blocks 🤔 They are already out of sync and like in this case shouldn't There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For me comment blocks are currently used to explain a complex or smelly piece of implementation, I don't see how the comment blocks add anything other than a maintenance overhead for future refactors. Some ideas to remove the need for the comment blocks...
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks Kyle. I think at the time I was having a bit of trouble trying to remember what props I needed and why I needed them as I was working around new problems and iterating. I'll take them out and refactor the props & interface as advised now the solution is "complete". There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just had a look back at the code to refresh my memory and we are actually defining the I still action the rest though. Thanks again for the renaming suggestions as I think the naming was where my initial confusions must have been occuring! |
||
* @param defaultWidth The width the child should initially render to. If min and max widths are provided, then the provided width will be massaged to meet those constraints | ||
* @param maxWidth The maximum width the element can be resized to | ||
* @param minWidth The minimum width the element can be resized to | ||
* @param useGutterOffset Toggle whether we should take the width of the gutter into account for calculating the width of the resized item. This can be handy depending on the CSS being applied to or around the child element | ||
* @param rest Contains params that need converting such as the containerWidth | ||
*/ | ||
export function Resizable({ | ||
children, | ||
defaultWidth = "50%", | ||
maxWidth, | ||
minWidth, | ||
useGutterOffset = false, | ||
...rest | ||
}: PropsWithChildren<ResizableProps>) { | ||
const containerWidth = toPixels( | ||
rest.containerWidth ?? document.body.offsetWidth | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why is There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah this doesn't look great. I was lazily looking for a way to dynamically set the default value for I'll clean this up! |
||
); | ||
const resizeRef = useRef<HTMLDivElement>(null); | ||
const resizeWrapperRef = useRef<HTMLDivElement>(null); | ||
const handleRef = useRef<HTMLSpanElement>(null); | ||
const gutterSize = useGutterOffset ? 16 : 0; | ||
const min = toPixels(minWidth ?? 0, containerWidth); | ||
const max = toPixels(maxWidth ?? "100%", containerWidth); | ||
|
||
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, min, max) - gutterSize | ||
}px`; | ||
}; | ||
|
||
const doDrag = (evt: MouseEvent) => { | ||
const newWidth = state.startWidth + evt.clientX - state.startX; | ||
setWidth(newWidth); | ||
}; | ||
|
||
const stopDrag = () => { | ||
document.removeEventListener("mousemove", doDrag, false); | ||
document.removeEventListener("mouseup", stopDrag, false); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These events will never be removed. This is because on re-render Talk to @micmcgrorty about this, we had a similar issue with editor server events recently. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting point! I'll ask Michael about it :) |
||
}; | ||
|
||
const initDrag = useCallback( | ||
(evt: React.DragEvent<HTMLDivElement>) => { | ||
const { clientX } = evt; | ||
|
||
const newState = { | ||
...state, | ||
startX: clientX, | ||
startWidth: getWidth(), | ||
}; | ||
|
||
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 yPosition = keepValueWithinRange( | ||
evt.clientY - rect.top, | ||
handleOffset, | ||
gutter.offsetHeight - handleOffset | ||
); | ||
|
||
handle.style.top = `${yPosition - handleOffset}px`; | ||
}; | ||
|
||
useEffect(() => { | ||
const newWidth = keepValueWithinRange(toPixels(defaultWidth), min, max); | ||
setWidth(newWidth); | ||
|
||
// 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> | ||
); | ||
} |
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; | ||
} | ||
} |
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) | ||
); | ||
}; | ||
|
||
timbryandev marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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; | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍