diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
index 1205aecd7c..28e845f1ec 100644
--- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx
+++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
@@ -9,8 +9,6 @@ import useWaitForI18n from '../../../shared/hooks/use-wait-for-i18n'
import CurrentPlanWidget from './current-plan-widget/current-plan-widget'
import NewProjectButton from './new-project-button'
import ProjectListTable from './table/project-list-table'
-import SidebarFilters from './sidebar/sidebar-filters'
-import AddAffiliation, { useAddAffiliation } from './sidebar/add-affiliation'
import SurveyWidget from './survey-widget'
import WelcomeMessage from './welcome-message'
import LoadingBranded from '../../../shared/components/loading-branded'
@@ -20,6 +18,7 @@ import SearchForm from './search-form'
import ProjectsDropdown from './dropdown/projects-dropdown'
import SortByDropdown from './dropdown/sort-by-dropdown'
import ProjectTools from './table/project-tools/project-tools'
+import Sidebar from './sidebar/sidebar'
import LoadMore from './load-more'
import { useEffect } from 'react'
@@ -43,7 +42,6 @@ function ProjectListPageContent() {
setSearchText,
selectedProjects,
} = useProjectListContext()
- const { show: showAddAffiliationWidget } = useAddAffiliation()
useEffect(() => {
eventTracking.sendMB('loads_v2_dash', {})
@@ -59,16 +57,7 @@ function ProjectListPageContent() {
{totalProjectsCount > 0 ? (
<>
-
-
-
-
-
+
{error ?
: ''}
diff --git a/services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx b/services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx
new file mode 100644
index 0000000000..42029f94f0
--- /dev/null
+++ b/services/web/frontend/js/features/project-list/components/sidebar/sidebar.tsx
@@ -0,0 +1,45 @@
+import NewProjectButton from '../new-project-button'
+import SidebarFilters from './sidebar-filters'
+import AddAffiliation, { useAddAffiliation } from './add-affiliation'
+import { usePersistedResize } from '../../../../shared/hooks/use-resize'
+
+function Sidebar() {
+ const { show: showAddAffiliationWidget } = useAddAffiliation()
+ const { mousePos, getHandleProps, getTargetProps } = usePersistedResize({
+ name: 'project-sidebar',
+ })
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export default Sidebar
diff --git a/services/web/frontend/js/shared/hooks/use-resize.ts b/services/web/frontend/js/shared/hooks/use-resize.ts
new file mode 100644
index 0000000000..4524b90af7
--- /dev/null
+++ b/services/web/frontend/js/shared/hooks/use-resize.ts
@@ -0,0 +1,113 @@
+import { useState, useEffect, useRef } from 'react'
+import usePersistedState from './use-persisted-state'
+import { Nullable } from '../../../../types/utils'
+
+type Pos = Nullable<{
+ x: number
+}>
+
+function useResizeBase(
+ state: [Pos, React.Dispatch>]
+) {
+ const [mousePos, setMousePos] = state
+ const isResizingRef = useRef(false)
+ const handleRef = useRef(null)
+ const defaultHandleStyles = useRef({
+ cursor: 'col-resize',
+ userSelect: 'none',
+ })
+
+ useEffect(() => {
+ const handleMouseDown = function (e: MouseEvent) {
+ if (e.button !== 0) {
+ return
+ }
+
+ if (defaultHandleStyles.current.cursor) {
+ document.body.style.cursor = defaultHandleStyles.current.cursor
+ }
+
+ isResizingRef.current = true
+ }
+
+ const handle = handleRef.current
+ handle?.addEventListener('mousedown', handleMouseDown)
+
+ return () => {
+ handle?.removeEventListener('mousedown', handleMouseDown)
+ }
+ }, [])
+
+ useEffect(() => {
+ const handleMouseUp = function () {
+ document.body.style.cursor = 'default'
+ isResizingRef.current = false
+ }
+
+ document.addEventListener('mouseup', handleMouseUp)
+
+ return () => {
+ document.removeEventListener('mouseup', handleMouseUp)
+ }
+ }, [])
+
+ useEffect(() => {
+ const handleMouseMove = function (e: MouseEvent) {
+ if (isResizingRef.current) {
+ setMousePos({ x: e.clientX })
+ }
+ }
+
+ document.addEventListener('mousemove', handleMouseMove)
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove)
+ }
+ }, [setMousePos])
+
+ const getTargetProps = ({ style }: { style?: React.CSSProperties } = {}) => {
+ return {
+ style: {
+ ...style,
+ },
+ }
+ }
+
+ const setHandleRef = (node: HTMLElement | null) => {
+ handleRef.current = node
+ }
+
+ const getHandleProps = ({ style }: { style?: React.CSSProperties } = {}) => {
+ if (style?.cursor) {
+ defaultHandleStyles.current.cursor = style.cursor
+ }
+
+ return {
+ style: {
+ ...defaultHandleStyles.current,
+ ...style,
+ },
+ ref: setHandleRef,
+ }
+ }
+
+ return {
+ mousePos,
+ getHandleProps,
+ getTargetProps,
+ }
+}
+
+function useResize() {
+ const state = useState(null)
+
+ return useResizeBase(state)
+}
+
+function usePersistedResize({ name }: { name: string }) {
+ const state = usePersistedState(`resizeable-${name}`, null)
+
+ return useResizeBase(state)
+}
+
+export { useResize, usePersistedResize }
diff --git a/services/web/frontend/stylesheets/app/project-list-react.less b/services/web/frontend/stylesheets/app/project-list-react.less
index 348e57e4a0..a66fe572cc 100644
--- a/services/web/frontend/stylesheets/app/project-list-react.less
+++ b/services/web/frontend/stylesheets/app/project-list-react.less
@@ -33,9 +33,12 @@
}
.project-list-sidebar-wrapper-react {
+ position: relative;
background-color: @sidebar-bg;
flex: @project-list-sidebar-wrapper-flex;
min-height: calc(~'100vh -' @header-height);
+ max-width: 320px;
+ min-width: 200px;
.project-list-sidebar-subwrapper {
display: flex;
@@ -722,6 +725,7 @@
.project-list-sidebar-survey-wrapper {
position: fixed;
+ z-index: 1;
bottom: 0;
left: 0;
width: 15%;
diff --git a/services/web/test/frontend/shared/hooks/use-resize.spec.tsx b/services/web/test/frontend/shared/hooks/use-resize.spec.tsx
new file mode 100644
index 0000000000..7ff40394a2
--- /dev/null
+++ b/services/web/test/frontend/shared/hooks/use-resize.spec.tsx
@@ -0,0 +1,117 @@
+import {
+ usePersistedResize,
+ useResize,
+} from '../../../../frontend/js/shared/hooks/use-resize'
+
+function Template({
+ mousePos,
+ getTargetProps,
+ getHandleProps,
+}: ReturnType) {
+ return (
+
+
+
+ Demo content demo content demo content demo content demo content demo
+ content
+
+
+
+
+ )
+}
+
+function PersistedResizeTest() {
+ const props = usePersistedResize({ name: 'test' })
+
+ return
+}
+
+function ResizeTest() {
+ const props = useResize()
+
+ return
+}
+
+describe('useResize', function () {
+ it('should apply provided styles to the target', function () {
+ cy.mount()
+
+ // test a css prop being applied
+ cy.get('#target').should('have.css', 'width', '200px')
+ })
+
+ it('should apply provided styles to the handle', function () {
+ cy.mount()
+
+ // test a css prop being applied
+ cy.get('#handle').should('have.css', 'width', '4px')
+ })
+
+ it('should apply default styles to the handle', function () {
+ cy.mount()
+
+ cy.get('#handle')
+ .should('have.css', 'cursor', 'col-resize')
+ .and('have.css', 'user-select', 'none')
+ })
+
+ it('should resize the target horizontally on mousedown and mousemove', function () {
+ const xPos = 400
+ cy.mount()
+
+ cy.get('#handle')
+ .trigger('mousedown', { button: 0 })
+ .trigger('mousemove', { clientX: xPos })
+ .trigger('mouseup')
+
+ cy.get('#target').should('have.css', 'width', `${xPos}px`)
+ })
+
+ it('should persist the resize data', function () {
+ const xPos = 400
+ cy.mount()
+
+ cy.get('#handle')
+ .trigger('mousedown', { button: 0 })
+ .trigger('mousemove', { clientX: xPos })
+ .trigger('mouseup')
+
+ cy.window()
+ .its('localStorage.resizeable-test')
+ .should('eq', `{"x":${xPos}}`)
+
+ // render the component again
+ cy.mount()
+
+ cy.get('#target').should('have.css', 'width', `${xPos}px`)
+ })
+})