mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
feat(desktop): polish sidebar resizing
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -297,7 +297,6 @@ export function ChannelMenuButton({
|
||||
data-testid={`channel-${channel.name}`}
|
||||
isActive={isActive}
|
||||
onClick={() => onSelectChannel(channel.id)}
|
||||
tooltip={resolvedLabel}
|
||||
type="button"
|
||||
>
|
||||
<SidebarChannelIcon
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 ({
|
||||
|
||||
Reference in New Issue
Block a user