(frontend) make panel sections resizable

Allow user to resize mailbox folder and mailbox label list sections.
This commit is contained in:
jbpenrath
2026-05-11 16:39:30 +02:00
parent 858e995062
commit 8a7cb8e0c2
9 changed files with 111 additions and 51 deletions
@@ -3,23 +3,25 @@
flex-direction: column;
height: 100%;
max-height: 100%;
overflow: auto;
font-size: var(--c--globals--font--sizes--sm);
position: relative;
}
.mailbox-panel__header {
position: sticky;
top: 0;
z-index: 10;
flex-shrink: 0;
background: var(--c--contextuals--background--surface--secondary);
z-index: 10;
}
.mailbox-panel__body {
flex: 1;
min-height: 0;
}
.mailbox-panel__mailbox-title {
margin: 0;
flex-shrink: 0;
padding-block: var(--c--globals--spacings--sm);
padding-bottom: var(--c--globals--spacings--xs);
& > .c__dropdown-menu-trigger {
justify-content: flex-start;
@@ -51,13 +53,3 @@
}
}
}
.mailbox-panel .mailbox-list {
flex-shrink: 0;
margin-bottom: var(--c--globals--spacings--sm);
}
.mailbox-panel .mailbox-labels {
border-top: 1px solid var(--c--contextuals--border--surface--primary);
flex: 1;
}
@@ -1,8 +1,3 @@
@keyframes mailbox-panel-actions__spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.mailbox-panel-actions {
display: flex;
justify-content: space-between;
@@ -15,8 +10,16 @@
align-items: center;
justify-content: center;
@keyframes refreshIconSpin {
0% { transform: rotate(0deg); filter: blur(0px); }
33% { filter: blur(1px); }
50% { filter: blur(2px); }
66% { filter: blur(1px); }
100% { transform: rotate(360deg); filter: blur(0px); }
}
&--spinning {
animation: mailbox-panel-actions__spin 700ms ease-in-out infinite;
animation: refreshIconSpin 700ms var(--c--globals--transitions--ease-in-out) infinite;
}
}
@@ -3,7 +3,6 @@
flex-direction: column;
height: 100%;
overflow-y: auto;
min-height: 200px;
}
.mailbox-labels__drag-over-indicator {
@@ -3,7 +3,8 @@
flex-direction: column;
margin-inline: var(--c--globals--spacings--sm);
gap: var(--c--globals--spacings--4xs);
flex-shrink: 0;
height: 100%;
overflow-y: auto;
}
// Wraps a folder link together with its disclosure chevron button. The
@@ -8,6 +8,7 @@ import { useSearchParams } from "next/navigation";
import { useLayoutContext } from "@/features/layouts/components/layout-context";
import { MailboxLabels } from "./components/mailbox-labels";
import { useState } from "react";
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
export const MailboxPanel = () => {
const router = useRouter();
@@ -15,6 +16,10 @@ export const MailboxPanel = () => {
const { selectedMailbox, mailboxes, queryStates } = useMailboxContext();
const { closeLeftPanel } = useLayoutContext();
const [isOpen, setIsOpen] = useState(false);
const { defaultLayout, onLayoutChange } = useDefaultLayout({
groupId: "mailbox-panel-sections",
storage: typeof window !== "undefined" ? localStorage : undefined,
});
const getMailboxOptions = () => {
if (!mailboxes) return [];
@@ -64,11 +69,20 @@ export const MailboxPanel = () => {
</div>
{!selectedMailbox || queryStates.mailboxes.isLoading ? <Spinner /> :
(
<>
<MailboxList />
<MailboxLabels mailbox={selectedMailbox} />
</>
<Group
orientation="vertical"
className="mailbox-panel__body"
defaultLayout={defaultLayout}
onLayoutChange={onLayoutChange}
>
<Panel id="mailbox-panel-folders" defaultSize="40%" minSize="20%">
<MailboxList />
</Panel>
<Separator className="panel__resize-handle" />
<Panel id="mailbox-panel-labels" defaultSize="60%" minSize="20%">
<MailboxLabels mailbox={selectedMailbox} />
</Panel>
</Group>
)}
</div>
)
@@ -98,7 +98,11 @@ export const ThreadPanelFilter = () => {
callback: () => handleSelectFilter(type),
}))}
>
<Tooltip content={getTooltipContent()} className={isDisabled ? "hidden" : ""}>
<Tooltip
placement="right"
content={getTooltipContent()}
className={isDisabled ? "hidden" : ""}
>
<Button
onClick={handleToggleClick}
disabled={isDisabled}
@@ -6,14 +6,19 @@
// so we can place it at viewport coordinates from JS, mark non-interactive,
// and centre horizontally on the trigger's mid-axis.
.transient-tooltip {
--offset: 8px;
position: fixed;
z-index: 1000;
pointer-events: none;
transform: translateX(-50%);
transform: translate(-50%, calc(-1 * var(--offset)));
&--top {
// For top placement, the inline `top` is the trigger's top edge, so
// we lift the bubble by its own height to sit above the trigger.
transform: translate(-50%, -100%);
transform: translate(-50%, calc(-100% + var(--offset)));
}
&--left {
transform: translate(calc(-100% + var(--offset)), -50%);
}
&--right {
transform: translate(calc(-1 * var(--offset)), -50%);
}
}
@@ -3,7 +3,7 @@ import { Portal } from "@/features/ui/components/portal";
const DEFAULT_DURATION_MS = 2000;
export type TransientTooltipPlacement = "top" | "bottom";
export type TransientTooltipPlacement = "top" | "bottom" | "left" | "right";
export type TransientTooltipProps = {
message: string | null;
@@ -31,6 +31,12 @@ export const TransientTooltip = ({
const [position, setPosition] = useState<Position | null>(null);
const [internalMessage, setInternalMessage] = useState<string | null>(null);
const [isExiting, setIsExiting] = useState(false);
// Latest-ref so the auto-hide timer below doesn't reset on every parent
// re-render when `onHide` is passed as an inline arrow function.
const onHideRef = useRef(onHide);
useEffect(() => {
onHideRef.current = onHide;
});
// Delayed-unmount pattern: when the parent clears `message`, we keep the
// bubble mounted under `c__tooltip--exiting` so its exit animation can
@@ -54,10 +60,16 @@ export const TransientTooltip = ({
const updatePosition = () => {
const rect = wrapperRef.current?.getBoundingClientRect();
if (!rect) return;
setPosition({
top: placement === "bottom" ? rect.bottom : rect.top,
left: rect.left + rect.width / 2,
});
let top: number, left: number;
if (["left", "right"].includes(placement)) {
top = rect.top + rect.height / 2;
left = placement === "left" ? rect.left : rect.right;
} else {
top = placement === "bottom" ? rect.bottom : rect.top;
left = rect.left + rect.width / 2;
}
setPosition({ top, left });
};
updatePosition();
window.addEventListener("resize", updatePosition);
@@ -66,9 +78,9 @@ export const TransientTooltip = ({
useEffect(() => {
if (!message) return;
const timer = window.setTimeout(onHide, duration);
const timer = window.setTimeout(() => onHideRef.current(), duration);
return () => window.clearTimeout(timer);
}, [message, duration, onHide]);
}, [message, duration]);
return (
<span ref={wrapperRef} className="transient-tooltip__wrapper">
+41 -11
View File
@@ -32,18 +32,16 @@ body {
}
// Customize resize handle to add an hover effect to explicit that
// this component is interactive.
// this component is interactive. Orientation is driven by the
// `aria-orientation` attribute set by react-resizable-panels:
// "vertical" → vertical line (horizontal Group, drag left/right)
// "horizontal" → horizontal line (vertical Group, drag up/down)
.panel__resize-handle {
width: 1px;
background-color: var(--c--contextuals--border--surface--primary);
position: relative;
&:before {
content: '';
width: 3px;
left: -1px;
top: 0;
bottom: 0;
background: var(--c--contextuals--border--surface--primary);
position: absolute;
opacity: 0;
@@ -51,19 +49,51 @@ body {
z-index: 10;
}
// Little tricks to expand the area to catch hover
// Expand the hover catch zone.
&:after {
content: '';
width: 11px;
left: -5px;
top:0;
bottom: 0;
position: absolute;
}
&:hover:before {
opacity: 1;
}
&[aria-orientation="vertical"] {
width: 1px;
&:before {
width: 3px;
left: -1px;
top: 0;
bottom: 0;
}
&:after {
width: 11px;
left: -5px;
top: 0;
bottom: 0;
}
}
&[aria-orientation="horizontal"] {
height: 1px;
&:before {
height: 3px;
top: -1px;
left: 0;
right: 0;
}
&:after {
height: 11px;
top: -5px;
left: 0;
right: 0;
}
}
}