feat(desktop): polish sidebar resizing

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-09-22 10:38:16 +01:00
parent 77729abfb6
commit 17f78307bb
6 changed files with 186 additions and 97 deletions

View File

@@ -297,7 +297,6 @@ export function ChannelMenuButton({
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon

View File

@@ -1,4 +1,4 @@
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { useCustomEmojiQuery } from "@/features/custom-emoji/hooks";
import { cn } from "@/shared/lib/cn";
import { emojiDisplayName } from "@/shared/lib/emojiName";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
@@ -36,7 +36,7 @@ export function StatusEmoji({
decorative = false,
showTitle = true,
}: StatusEmojiProps) {
const customEmoji = useCustomEmoji();
const { data: customEmoji = [], isPending } = useCustomEmojiQuery();
if (!value) return null;
@@ -59,6 +59,7 @@ export function StatusEmoji({
/>
);
}
if (isPending) return null;
}
// Native glyph, or an unknown shortcode we can't resolve — render as text.

View File

@@ -3,16 +3,12 @@
}
.buzz-sidebar-scrollbar:hover {
scrollbar-color: var(
--buzz-sidebar-scrollbar-thumb,
hsl(var(--sidebar-border) / 0.8)
)
transparent;
scrollbar-color: hsl(var(--foreground) / 0.2) transparent;
}
.buzz-sidebar-scrollbar::-webkit-scrollbar {
height: 10px;
width: 10px;
height: 8px;
width: 8px;
}
.buzz-sidebar-scrollbar::-webkit-scrollbar-track {
@@ -20,17 +16,35 @@
}
.buzz-sidebar-scrollbar::-webkit-scrollbar-thumb {
background-clip: content-box;
background-clip: padding-box;
background-color: transparent;
border: 3px solid transparent;
border: 2px solid transparent;
border-radius: 999px;
}
.buzz-sidebar-scrollbar:hover::-webkit-scrollbar-thumb {
background-color: var(
--buzz-sidebar-scrollbar-thumb,
hsl(var(--sidebar-border) / 0.8)
);
background-color: hsl(var(--foreground) / 0.2);
}
[data-buzz-conversation-scroll] {
scrollbar-color: hsl(var(--foreground) / 0.2) transparent;
scrollbar-width: thin;
}
[data-buzz-conversation-scroll]::-webkit-scrollbar {
height: 8px;
width: 8px;
}
[data-buzz-conversation-scroll]::-webkit-scrollbar-track {
background: transparent;
}
[data-buzz-conversation-scroll]::-webkit-scrollbar-thumb {
background-clip: padding-box;
background-color: hsl(var(--foreground) / 0.2);
border: 2px solid transparent;
border-radius: 999px;
}
/* Channel activity keeps its chrome outside the scrolling surface. The slim,

View File

@@ -141,6 +141,12 @@
overscroll-behavior: none;
}
html[data-sidebar-resizing="true"],
html[data-sidebar-resizing="true"] * {
/* biome-ignore lint/complexity/noImportantStyles: an active drag must override descendant cursor utilities */
cursor: col-resize !important;
}
mark {
background-color: transparent;
color: inherit;

View File

@@ -218,6 +218,15 @@ const SidebarProvider = React.forwardRef<
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
React.useEffect(() => {
if (!isResizing) return;
document.documentElement.dataset.sidebarResizing = "true";
return () => {
delete document.documentElement.dataset.sidebarResizing;
};
}, [isResizing]);
// Expose semantic state so Tailwind descendants can style both modes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
@@ -425,6 +434,7 @@ const SidebarRail = React.forwardRef<
(
{
className,
onDoubleClick,
onPointerCancel,
onPointerDown,
onPointerMove,
@@ -471,92 +481,106 @@ const SidebarRail = React.forwardRef<
);
return (
<button
ref={ref}
data-sidebar="rail"
aria-label="Resize sidebar"
tabIndex={-1}
disabled={isRailDisabled || state !== "expanded"}
onPointerCancel={(event) => {
onPointerCancel?.(event);
finishResize(event);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (
isRailDisabled ||
event.defaultPrevented ||
event.button !== 0 ||
state !== "expanded"
) {
return;
}
<Tooltip>
<TooltipTrigger asChild>
<button
ref={ref}
data-sidebar="rail"
aria-label="Resize sidebar"
aria-description="Drag to resize. Double-click to reset."
tabIndex={-1}
disabled={isRailDisabled || state !== "expanded"}
onDoubleClick={(event) => {
onDoubleClick?.(event);
if (event.defaultPrevented) return;
setSidebarWidth(SIDEBAR_WIDTH_DEFAULT);
performSidebarDefaultHaptic();
}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
finishResize(event);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (
isRailDisabled ||
event.defaultPrevented ||
event.button !== 0 ||
state !== "expanded"
) {
return;
}
const side =
event.currentTarget.closest("[data-side='right']") !== null
? "right"
: "left";
resizeStateRef.current = {
currentWidth: sidebarWidth,
hasDragged: false,
hasReachedDefaultWidth: isSidebarWidthNearDefault(sidebarWidth),
pointerId: event.pointerId,
previousCursor: document.documentElement.style.cursor,
previousUserSelect: document.body.style.userSelect,
side,
startWidth: sidebarWidth,
startX: event.clientX,
};
event.currentTarget.setPointerCapture(event.pointerId);
document.documentElement.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setIsResizing(true);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const side =
event.currentTarget.closest("[data-side='right']") !== null
? "right"
: "left";
resizeStateRef.current = {
currentWidth: sidebarWidth,
hasDragged: false,
hasReachedDefaultWidth: isSidebarWidthNearDefault(sidebarWidth),
pointerId: event.pointerId,
previousCursor: document.documentElement.style.cursor,
previousUserSelect: document.body.style.userSelect,
side,
startWidth: sidebarWidth,
startX: event.clientX,
};
event.currentTarget.setPointerCapture(event.pointerId);
document.documentElement.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setIsResizing(true);
}}
onPointerMove={(event) => {
onPointerMove?.(event);
const resizeState = resizeStateRef.current;
if (!resizeState || resizeState.pointerId !== event.pointerId) {
return;
}
const rawDelta = event.clientX - resizeState.startX;
const delta = resizeState.side === "left" ? rawDelta : -rawDelta;
if (!resizeState.hasDragged && Math.abs(delta) < 3) {
return;
}
const rawDelta = event.clientX - resizeState.startX;
const delta = resizeState.side === "left" ? rawDelta : -rawDelta;
if (!resizeState.hasDragged && Math.abs(delta) < 3) {
return;
}
resizeState.hasDragged = true;
event.preventDefault();
const nextWidth = magnetizeSidebarWidth(
resizeState.startWidth + delta,
);
const reachedDefaultWidth = hasReachedSidebarDefaultWidth(
resizeState.currentWidth,
nextWidth,
);
resizeState.hasDragged = true;
event.preventDefault();
const nextWidth = magnetizeSidebarWidth(
resizeState.startWidth + delta,
);
const reachedDefaultWidth = hasReachedSidebarDefaultWidth(
resizeState.currentWidth,
nextWidth,
);
if (reachedDefaultWidth && !resizeState.hasReachedDefaultWidth) {
performSidebarDefaultHaptic();
}
if (reachedDefaultWidth && !resizeState.hasReachedDefaultWidth) {
performSidebarDefaultHaptic();
}
resizeState.hasReachedDefaultWidth = reachedDefaultWidth;
resizeState.currentWidth = nextWidth;
setSidebarWidth(nextWidth);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
finishResize(event);
}}
title="Drag to resize sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"cursor-col-resize",
"after:absolute after:bottom-0 after:left-1/2 after:top-6 after:z-10 after:w-px after:-translate-x-1/2 after:bg-transparent after:content-['']",
"disabled:pointer-events-none disabled:hidden",
className,
)}
{...props}
/>
resizeState.hasReachedDefaultWidth = reachedDefaultWidth;
resizeState.currentWidth = nextWidth;
setSidebarWidth(nextWidth);
}}
onPointerUp={(event) => {
onPointerUp?.(event);
finishResize(event);
}}
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"cursor-col-resize",
"after:absolute after:left-1/2 after:top-1/2 after:z-10 after:h-[7.5%] after:w-[3px] after:-translate-x-1/2 after:-translate-y-1/2 after:rounded-full after:bg-sidebar-ring after:opacity-0 after:transition-opacity after:duration-150 after:ease-out after:content-[''] hover:after:opacity-100 group-data-[resizing=true]:after:opacity-100 motion-reduce:after:transition-none",
"disabled:pointer-events-none disabled:hidden",
className,
)}
{...props}
/>
</TooltipTrigger>
<TooltipContent side="right">
Drag to resize · Double-click to reset
</TooltipContent>
</Tooltip>
);
},
);

View File

@@ -71,6 +71,19 @@ async function dragSidebarRail(page: Page, deltaX: number) {
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + deltaX, startY, { steps: 8 });
await expect
.poll(() =>
page.evaluate(
({ x, y }) => {
const hoveredElement = document.elementFromPoint(x, y);
return hoveredElement
? getComputedStyle(hoveredElement).cursor
: null;
},
{ x: startX + deltaX, y: startY },
),
)
.toBe("col-resize");
await page.mouse.up();
}
@@ -96,6 +109,11 @@ test("sidebar rows separate hover, selected, and reorder states", async ({
"background-color",
establishedActiveBackground,
);
await hoverRow.hover();
await page.waitForTimeout(600);
await expect(page.getByRole("tooltip", { name: "random" })).toHaveCount(0);
// The spacing and motion experiment must preserve the production selected
// row typography.
await expect(selectedRow).toHaveCSS("font-weight", "400");
@@ -675,6 +693,33 @@ test("resizes, persists, and snaps to the default sidebar width", async ({
await expect
.poll(() => storedSidebarWidth(page))
.toBe(String(DEFAULT_SIDEBAR_WIDTH));
await dragSidebarRail(page, 64);
await page.getByRole("button", { name: "Resize sidebar" }).dblclick();
await expect.poll(() => sidebarWidth(page)).toBe(DEFAULT_SIDEBAR_WIDTH);
await expect
.poll(() => storedSidebarWidth(page))
.toBe(String(DEFAULT_SIDEBAR_WIDTH));
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe("");
});
test("reveals the sidebar resize affordance and help after pointer dwell", async ({
page,
}) => {
await page.goto("/");
const rail = page.getByRole("button", { name: "Resize sidebar" });
await rail.hover();
await expect(rail).toHaveCSS("cursor", "col-resize");
await expect(
page.getByRole("tooltip", {
name: "Drag to resize · Double-click to reset",
}),
).toBeVisible();
await expect(
rail,
"the full-height resize target remains available",
).toHaveCSS("height", /\d+px/);
});
test("shows a sidebar update card when an update is ready", async ({