Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion desktop/src/features/sidebar/ui/SidebarSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,6 @@ export function ChannelMenuButton({
data-testid={`channel-${channel.name}`}
isActive={isActive}
onClick={() => onSelectChannel(channel.id)}
tooltip={resolvedLabel}
type="button"
>
<SidebarChannelIcon
Expand Down
5 changes: 3 additions & 2 deletions desktop/src/features/user-status/ui/StatusEmoji.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -36,7 +36,7 @@ export function StatusEmoji({
decorative = false,
showTitle = true,
}: StatusEmojiProps) {
const customEmoji = useCustomEmoji();
const { data: customEmoji = [], isPending } = useCustomEmojiQuery();

if (!value) return null;

Expand All @@ -59,6 +59,7 @@ export function StatusEmoji({
/>
);
}
if (isPending) return null;
}

// Native glyph, or an unknown shortcode we can't resolve — render as text.
Expand Down
40 changes: 27 additions & 13 deletions desktop/src/shared/styles/globals/scrollbars.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,48 @@
}

.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 {
background: transparent;
}

.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,
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/shared/styles/globals/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
196 changes: 110 additions & 86 deletions desktop/src/shared/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>(
Expand Down Expand Up @@ -425,6 +434,7 @@ const SidebarRail = React.forwardRef<
(
{
className,
onDoubleClick,
onPointerCancel,
onPointerDown,
onPointerMove,
Expand Down Expand Up @@ -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;
}

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;
}

resizeState.hasDragged = true;
event.preventDefault();
const nextWidth = magnetizeSidebarWidth(
resizeState.startWidth + delta,
);
const reachedDefaultWidth = hasReachedSidebarDefaultWidth(
resizeState.currentWidth,
nextWidth,
);

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}
/>
<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();
Comment on lines +493 to +497

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the reset action keyboard-reachable

For keyboard-only users, the new reset action is unreachable because it is implemented only in onDoubleClick, while the rail remains outside the tab order with tabIndex={-1} and there is no keyboard handler or alternate reset control. The added ARIA description therefore advertises an action that assistive-technology users cannot invoke; expose a focusable keyboard path and cover it alongside the pointer test.

AGENTS.md reference: AGENTS.md:L232-L239

Useful? React with 👍 / 👎.

}}
onPointerCancel={(event) => {
onPointerCancel?.(event);
finishResize(event);
}}
onPointerDown={(event) => {
onPointerDown?.(event);
if (
isRailDisabled ||
event.defaultPrevented ||
event.button !== 0 ||
state !== "expanded"
) {
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;
}

resizeState.hasDragged = true;
event.preventDefault();
const nextWidth = magnetizeSidebarWidth(
resizeState.startWidth + delta,
);
const reachedDefaultWidth = hasReachedSidebarDefaultWidth(
resizeState.currentWidth,
nextWidth,
);

if (reachedDefaultWidth && !resizeState.hasReachedDefaultWidth) {
performSidebarDefaultHaptic();
}

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>
);
},
);
Expand Down
45 changes: 45 additions & 0 deletions desktop/tests/e2e/sidebar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand All @@ -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");
Expand Down Expand Up @@ -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 ({
Expand Down
Loading