fix(site): stop sidebar scrollbar hit-area from covering chat row controls (#26209)

The Agents chat list scrollbar swallowed clicks on the per-row controls
(the actions menu, timestamp, and unread/shared indicators). PR #26016
added a 24px invisible hit-target to the shared `ScrollArea` thumb,
anchored to the thumb's right edge and extending left. On this sidebar
the visible scrollbar is only ~6px wide (`w-1.5`), so the hit-target
reached ~18px left of it, on top of the controls just inside the edge.

This adds an optional, default-preserving `scrollThumbClassName` prop to
the shared `ScrollArea`, threaded through `ScrollBar` to the thumb the
same way `scrollBarClassName` already is. When the prop is omitted the
output is byte-for-byte unchanged, so every other scrollbar keeps the
24px target. The Agents chat list passes `before:hidden` to drop only
the expanded hit-target: the visible 6px thumb stays draggable and the
scrollbar looks identical, so there is no visible change while the
controls beside it become clickable again.

A new `ScrollArea` story (`ThumbHitAreaOverride`) uses `type="always"`
so the thumb is guaranteed to render, then asserts the default keeps the
24px `::before` target while the override sets `display: none`.

Refs #26016
This commit is contained in:
Jaayden Halko
2026-06-10 10:06:33 +01:00
committed by GitHub
parent 4e87fdb20c
commit c1731722af
3 changed files with 87 additions and 2 deletions
@@ -104,3 +104,74 @@ export const Accessibility: Story = {
).toBeGreaterThanOrEqual(3);
},
};
// type="always" forces the thumb to render so its `::before` hit-target
// pseudo-element is measurable.
export const ThumbHitAreaOverride: Story = {
render: () => (
<div className="flex gap-4">
<div
data-testid="default-area"
className="w-48 rounded-md border border-solid border-border-default bg-surface-primary"
>
<ScrollArea className="h-48" type="always" scrollBarClassName="w-1.5">
<OverflowingContent />
</ScrollArea>
</div>
<div
data-testid="override-area"
className="w-48 rounded-md border border-solid border-border-default bg-surface-primary"
>
<ScrollArea
className="h-48"
type="always"
orientation="both"
scrollBarClassName="w-1.5"
horizontalScrollBarClassName="h-1.5"
scrollThumbClassName="before:hidden"
>
<OverflowingContent />
</ScrollArea>
</div>
</div>
),
play: async ({ canvasElement }) => {
const getThumb = (
testid: string,
orientation: "vertical" | "horizontal",
) => {
const area = canvasElement.querySelector(`[data-testid='${testid}']`);
return area?.querySelector(`[data-orientation="${orientation}"]`)
?.firstElementChild as HTMLElement | null | undefined;
};
await waitFor(() => {
expect(getThumb("default-area", "vertical")).toBeTruthy();
expect(getThumb("override-area", "vertical")).toBeTruthy();
expect(getThumb("override-area", "horizontal")).toBeTruthy();
});
const defaultThumb = getThumb("default-area", "vertical");
const overrideThumb = getThumb("override-area", "vertical");
const overrideHorizontalThumb = getThumb("override-area", "horizontal");
if (!defaultThumb || !overrideThumb || !overrideHorizontalThumb) {
throw new Error("scrollbar thumbs not found");
}
const defaultBefore = getComputedStyle(defaultThumb, "::before");
await expect(defaultBefore.display).not.toBe("none");
await expect(Number.parseFloat(defaultBefore.width)).toBeGreaterThanOrEqual(
24,
);
await expect(getComputedStyle(overrideThumb, "::before").display).toBe(
"none",
);
await expect(
getComputedStyle(overrideHorizontalThumb, "::before").display,
).toBe("none");
await expect(overrideThumb.getBoundingClientRect().width).toBeGreaterThan(
0,
);
},
};
+12 -2
View File
@@ -10,6 +10,8 @@ interface ScrollAreaProps
extends React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.Root> {
scrollBarClassName?: string;
horizontalScrollBarClassName?: string;
/** Extra thumb classes; also reaches the thumb's `::before` hit-target. */
scrollThumbClassName?: string;
viewportClassName?: string;
viewportTabIndex?: number;
/** Which scrollbar(s) to show. Defaults to "vertical". */
@@ -20,6 +22,7 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
className,
scrollBarClassName,
horizontalScrollBarClassName,
scrollThumbClassName,
viewportClassName,
viewportTabIndex,
orientation = "vertical",
@@ -61,6 +64,7 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
<ScrollBar
orientation="vertical"
className={cn("z-10", scrollBarClassName)}
thumbClassName={scrollThumbClassName}
/>
)}
{(orientation === "horizontal" || orientation === "both") && (
@@ -72,6 +76,7 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
? horizontalScrollBarClassName
: (horizontalScrollBarClassName ?? scrollBarClassName),
)}
thumbClassName={scrollThumbClassName}
/>
)}
<ScrollAreaPrimitive.Corner />
@@ -80,8 +85,12 @@ export const ScrollArea: React.FC<ScrollAreaProps> = ({
};
export const ScrollBar: React.FC<
React.ComponentPropsWithRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
> = ({ className, orientation = "vertical", ...props }) => {
React.ComponentPropsWithRef<
typeof ScrollAreaPrimitive.ScrollAreaScrollbar
> & {
thumbClassName?: string;
}
> = ({ className, orientation = "vertical", thumbClassName, ...props }) => {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
orientation={orientation}
@@ -102,6 +111,7 @@ export const ScrollBar: React.FC<
orientation === "vertical"
? "before:right-0 before:top-1/2 before:h-full before:min-h-6 before:w-6 before:-translate-y-1/2"
: "before:bottom-0 before:left-1/2 before:w-full before:min-w-6 before:h-6 before:-translate-x-1/2",
thumbClassName,
)}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
@@ -447,6 +447,10 @@ export const ChatsPanel: FC<ChatsPanelProps> = ({
<ScrollArea
className="min-h-0 flex-1 [&_[data-radix-scroll-area-viewport]>div]:!block"
scrollBarClassName="w-1.5"
// The default 24px hit-target extends ~18px left of this narrow
// scrollbar, onto the row controls (actions menu, timestamp,
// indicators). Disable it so those controls stay clickable.
scrollThumbClassName="before:hidden"
viewportClassName={cn(
"[mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",
"[-webkit-mask-image:linear-gradient(to_bottom,transparent_0,black_20px,black_calc(100%-20px),transparent_100%)]",