mirror of
https://github.com/suitenumerique/messages.git
synced 2026-08-17 21:25:41 +02:00
✨(frontend) make panel sections resizable
Allow user to resize mailbox folder and mailbox label list sections.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+9
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -3,7 +3,6 @@
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.mailbox-labels__drag-over-indicator {
|
||||
|
||||
+2
-1
@@ -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>
|
||||
)
|
||||
|
||||
+5
-1
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user