mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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%)]",
|
||||
|
||||
Reference in New Issue
Block a user