Skip to content

Commit 335cf9e

Browse files
authored
Implementation of SortableList component (+ sub components) and menu utility components (#685)
1 parent 385a24d commit 335cf9e

File tree

11 files changed

+1194
-3
lines changed

11 files changed

+1194
-3
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { MenuDivider } from "./menuDivider";
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function MenuDivider(): React.ReactNode {
2+
return <div className="border-t border-gray-200 my-1" />;
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { MenuHeading } from "./menuHeading";
2+
export type { MenuHeadingProps } from "./menuHeading";
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export type MenuHeadingProps = {
2+
children: React.ReactNode;
3+
};
4+
5+
export function MenuHeading(props: MenuHeadingProps): React.ReactNode {
6+
return (
7+
<div className="text-xs text-gray-500 uppercase font-semibold tracking-wider px-3 py-1">{props.children}</div>
8+
);
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { SortableList } from "./sortableList";
2+
export { SortableListItem } from "./sortableListItem";
3+
export { SortableListGroup } from "./sortableListGroup";
4+
5+
export type { SortableListProps } from "./sortableList";
6+
export type { SortableListItemProps } from "./sortableListItem";
7+
export type { SortableListGroupProps } from "./sortableListGroup";

frontend/src/lib/components/SortableList/sortableList.tsx

Lines changed: 726 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export function SortableListDropIndicator() {
2+
return (
3+
<div className="w-full h-0 relative">
4+
<div className="absolute -top-0.5 h-1 w-full bg-blue-800 z-10"></div>
5+
</div>
6+
);
7+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import React from "react";
2+
3+
import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect";
4+
import { createPortal } from "@lib/utils/createPortal";
5+
import { resolveClassNames } from "@lib/utils/resolveClassNames";
6+
import { DragIndicator, ExpandLess, ExpandMore } from "@mui/icons-material";
7+
8+
import { HoveredArea, SortableListContext } from "./sortableList";
9+
import { SortableListDropIndicator } from "./sortableListDropIndicator";
10+
import { SortableListItemProps } from "./sortableListItem";
11+
12+
export type SortableListGroupProps = {
13+
id: string;
14+
title: React.ReactNode;
15+
initiallyExpanded?: boolean;
16+
startAdornment?: React.ReactNode;
17+
endAdornment?: React.ReactNode;
18+
headerStyle?: React.CSSProperties;
19+
contentStyle?: React.CSSProperties;
20+
contentWhenEmpty?: React.ReactNode;
21+
children?: React.ReactElement<SortableListItemProps>[];
22+
};
23+
24+
/**
25+
*
26+
* @param {SortableListGroupProps} props Object of properties for the SortableListGroup component (see below for details).
27+
* @param {string} props.id ID that is unique among all components inside the sortable list.
28+
* @param {React.ReactNode} props.title Title of the list item.
29+
* @param {boolean} props.initiallyExpanded Whether the group should be expanded by default.
30+
* @param {React.ReactNode} props.startAdornment Start adornment to display to the left of the title.
31+
* @param {React.ReactNode} props.endAdornment End adornment to display to the right of the title.
32+
* @param {React.CSSProperties} props.headerStyle Style object to apply to the header of the group.
33+
* @param {React.CSSProperties} props.contentStyle Style object to apply to the content of the group.
34+
* @param {React.ReactNode} props.contentWhenEmpty Content to display when the group is empty.
35+
* @param {React.ReactNode} props.children Child components to display as the content of the list item.
36+
*
37+
* @returns {React.ReactNode} A sortable list group component.
38+
*/
39+
export function SortableListGroup(props: SortableListGroupProps): React.ReactNode {
40+
const [isExpanded, setIsExpanded] = React.useState<boolean>(props.initiallyExpanded ?? true);
41+
42+
const divRef = React.useRef<HTMLDivElement>(null);
43+
const boundingClientRect = useElementBoundingRect(divRef);
44+
const sortableListContext = React.useContext(SortableListContext);
45+
46+
const isHovered = sortableListContext.hoveredElementId === props.id;
47+
const isHeaderHovered =
48+
isHovered &&
49+
(sortableListContext.hoveredArea === HoveredArea.HEADER ||
50+
sortableListContext.hoveredArea === HoveredArea.CENTER);
51+
const isDragging = sortableListContext.draggedElementId === props.id;
52+
const dragPosition = sortableListContext.dragPosition;
53+
54+
function handleToggleExpanded() {
55+
setIsExpanded(!isExpanded);
56+
}
57+
58+
const hasContent = props.children !== undefined && props.children.length > 0;
59+
60+
return (
61+
<>
62+
{isHovered && sortableListContext.hoveredArea === HoveredArea.TOP && <SortableListDropIndicator />}
63+
<div
64+
className={resolveClassNames("sortable-list-element sortable-list-group relative bg-gray-200")}
65+
data-item-id={props.id}
66+
ref={divRef}
67+
>
68+
<div
69+
className={resolveClassNames("z-30 w-full h-full absolute left-0 top-0 bg-blue-500", {
70+
hidden: !isDragging,
71+
})}
72+
></div>
73+
<Header
74+
onToggleExpanded={handleToggleExpanded}
75+
expanded={isExpanded}
76+
expandable={hasContent}
77+
hovered={isHeaderHovered}
78+
{...props}
79+
/>
80+
{isDragging &&
81+
dragPosition &&
82+
createPortal(
83+
<div
84+
className={resolveClassNames(
85+
"flex h-8 bg-blue-50 text-sm items-center gap-1 border-b border-b-gray-300 absolute z-50 opacity-75"
86+
)}
87+
style={{
88+
left: dragPosition.x,
89+
top: dragPosition.y,
90+
width: isDragging ? boundingClientRect.width : undefined,
91+
}}
92+
>
93+
<Header
94+
expanded={isExpanded}
95+
expandable={hasContent}
96+
hovered={isHeaderHovered}
97+
{...props}
98+
/>
99+
</div>
100+
)}
101+
<div
102+
className={resolveClassNames(
103+
"sortable-list-group-content pl-1 bg-white shadow-inner border-b border-b-gray-300",
104+
{
105+
hidden: !isExpanded,
106+
}
107+
)}
108+
style={props.contentStyle}
109+
>
110+
{hasContent ? props.children : props.contentWhenEmpty}
111+
</div>
112+
</div>
113+
{isHovered && sortableListContext.hoveredArea === HoveredArea.BOTTOM && <SortableListDropIndicator />}
114+
</>
115+
);
116+
}
117+
118+
type HeaderProps = {
119+
title: React.ReactNode;
120+
expanded: boolean;
121+
expandable: boolean;
122+
hovered: boolean;
123+
onToggleExpanded?: () => void;
124+
icon?: React.ReactNode;
125+
startAdornment?: React.ReactNode;
126+
endAdornment?: React.ReactNode;
127+
headerStyle?: React.CSSProperties;
128+
};
129+
130+
function Header(props: HeaderProps): React.ReactNode {
131+
return (
132+
<div
133+
className={resolveClassNames(
134+
"sortable-list-item-header flex w-full items-center gap-1 h-8 text-sm border-b border-b-gray-400 px-2",
135+
{
136+
"!bg-blue-300": props.hovered,
137+
"bg-slate-300": !props.hovered,
138+
}
139+
)}
140+
style={props.headerStyle}
141+
>
142+
<div className={resolveClassNames("sortable-list-element-indicator hover:cursor-grab")}>
143+
<DragIndicator fontSize="inherit" className="pointer-events-none" />
144+
</div>
145+
{props.expandable && (
146+
<div
147+
className="hover:cursor-pointer hover:text-blue-800 p-0.5 rounded"
148+
onClick={props.onToggleExpanded}
149+
title={props.expanded ? "Hide children" : "Show children"}
150+
>
151+
{props.expanded ? <ExpandLess fontSize="inherit" /> : <ExpandMore fontSize="inherit" />}
152+
</div>
153+
)}
154+
<div className="flex items-center gap-2 flex-grow">
155+
{props.startAdornment}
156+
<div className="flex-grow font-bold">{props.title}</div>
157+
{props.endAdornment}
158+
</div>
159+
</div>
160+
);
161+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import React from "react";
2+
3+
import { useElementBoundingRect } from "@lib/hooks/useElementBoundingRect";
4+
import { createPortal } from "@lib/utils/createPortal";
5+
import { resolveClassNames } from "@lib/utils/resolveClassNames";
6+
import { DragIndicator } from "@mui/icons-material";
7+
8+
import { HoveredArea, SortableListContext } from "./sortableList";
9+
import { SortableListDropIndicator } from "./sortableListDropIndicator";
10+
11+
export type SortableListItemProps = {
12+
id: string;
13+
title: React.ReactNode;
14+
headerClassNames?: string;
15+
startAdornment?: React.ReactNode;
16+
endAdornment?: React.ReactNode;
17+
children?: React.ReactNode;
18+
};
19+
20+
/**
21+
*
22+
* @param {SortableListItemProps} props Object of properties for the SortableListItem component (see below for details).
23+
* @param {string} props.id ID that is unique among all components inside the sortable list.
24+
* @param {React.ReactNode} props.title Title component of the list item.
25+
* @param {string} props.headerClassNames Class names to apply to the header of the list item.
26+
* @param {React.ReactNode} props.startAdornment Start adornment to display to the left of the title.
27+
* @param {React.ReactNode} props.endAdornment End adornment to display to the right of the title.
28+
* @param {React.ReactNode} props.children Child components to display as the content of the list item.
29+
*
30+
* @returns {React.ReactNode} A sortable list item component.
31+
*/
32+
export function SortableListItem(props: SortableListItemProps): React.ReactNode {
33+
const divRef = React.useRef<HTMLDivElement>(null);
34+
const boundingClientRect = useElementBoundingRect(divRef);
35+
36+
const sortableListContext = React.useContext(SortableListContext);
37+
38+
const isHovered = sortableListContext.hoveredElementId === props.id;
39+
const isDragging = sortableListContext.draggedElementId === props.id;
40+
const dragPosition = sortableListContext.dragPosition;
41+
42+
return (
43+
<>
44+
{isHovered && sortableListContext.hoveredArea === HoveredArea.TOP && <SortableListDropIndicator />}
45+
<div
46+
className={resolveClassNames("sortable-list-element sortable-list-item flex flex-col relative")}
47+
data-item-id={props.id}
48+
ref={divRef}
49+
>
50+
<div
51+
className={resolveClassNames("z-30 w-full h-full absolute left-0 top-0 bg-blue-500", {
52+
hidden: !isDragging,
53+
})}
54+
></div>
55+
<Header {...props} />
56+
{isDragging &&
57+
dragPosition &&
58+
createPortal(
59+
<div
60+
className={resolveClassNames(
61+
"flex h-8 bg-blue-50 text-sm items-center gap-1 border-b border-b-gray-300 absolute z-50 opacity-75"
62+
)}
63+
style={{
64+
left: dragPosition.x,
65+
top: dragPosition.y,
66+
width: isDragging ? boundingClientRect.width : undefined,
67+
}}
68+
>
69+
<Header {...props} />
70+
</div>
71+
)}
72+
{props.children !== undefined && (
73+
<div className={resolveClassNames("bg-white border-b shadow")}>{props.children}</div>
74+
)}
75+
</div>
76+
{isHovered && sortableListContext.hoveredArea === HoveredArea.BOTTOM && <SortableListDropIndicator />}
77+
</>
78+
);
79+
}
80+
81+
type HeaderProps = {
82+
title: React.ReactNode;
83+
startAdornment?: React.ReactNode;
84+
endAdornment?: React.ReactNode;
85+
headerClassNames?: string;
86+
};
87+
88+
function Header(props: HeaderProps): React.ReactNode {
89+
return (
90+
<div
91+
className={resolveClassNames(
92+
"w-full flex gap-1 h-8 bg-slate-100 text-sm items-center border-b border-b-gray-300 px-2",
93+
props.headerClassNames ?? ""
94+
)}
95+
>
96+
<div className={resolveClassNames("sortable-list-element-indicator hover:cursor-grab")}>
97+
<DragIndicator fontSize="inherit" className="pointer-events-none" />
98+
</div>
99+
<div className="flex items-center gap-2 flex-grow">
100+
{props.startAdornment}
101+
<div className="flex-grow">{props.title}</div>
102+
{props.endAdornment}
103+
</div>
104+
</div>
105+
);
106+
}

frontend/src/main.css

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,19 +28,19 @@ body {
2828
border-radius: 2px;
2929
width: 4px;
3030
height: 4px;
31-
background-color: rgba(190, 190, 190, 0);
31+
background-color: rgba(190, 190, 190, 0.3);
3232
transition: background-color 0.5s ease-in-out;
3333
}
3434

3535
*:hover > *::-webkit-scrollbar-thumb {
3636
width: 4px;
3737
height: 4px;
38-
background-color: rgba(190, 190, 190, 0.6);
38+
background-color: rgba(190, 190, 190, 0.8);
3939
transition: background-color 0.5s ease-in-out;
4040
}
4141

4242
*:hover > *::-webkit-scrollbar-thumb:hover {
43-
background-color: rgba(134, 134, 134, 0.8);
43+
background-color: rgba(134, 134, 134, 0.9);
4444
transition: background-color 0.5s ease-in-out;
4545
}
4646

0 commit comments

Comments
 (0)