Move the AutoHideScrollbar to shared components (#33777)

* First version of shared component

* Refactor as a functional component

* Add unit tests and more documentation

* Fix problem where wrappedRef was used by parent before assignment was performed.

* Use the shared component in app/web

* Clean up unused styling

* Added scrollbar-gutters as default styling in shared component

* Make sure the legacy mx_AutoHideScrollbar is set in app/web

* Updated snapshots

* Removing default style, scrollbar-gutter: stable;

* Updated snapshots

* useRef on wrapperRef to avoid loop in rendering

* scrollbar-width does not propagate

* Add AutoHideScrollbar to RoomListView

* Fix Prettier issue

* Updated snapshots

* Updated snapshot after merge

* Fix Sonar issue
This commit is contained in:
rbondesson
2026-06-11 09:08:24 +02:00
committed by GitHub
parent e9a89d9872
commit 8a21fdb127
39 changed files with 17217 additions and 16801 deletions
@@ -0,0 +1,71 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.scrollbar {
overflow-x: hidden;
overflow-y: auto;
overflow-y: overlay; /* where supported */
-ms-overflow-style: -ms-autohiding-scrollbar;
&::-webkit-scrollbar {
background-color: transparent;
width: 6px;
height: 6px;
}
&::-webkit-scrollbar-thumb {
background-color: transparent;
}
scrollbar-width: thin;
scrollbar-color: transparent transparent;
> * {
/* also set on first level children in case this scrollbar is used as container */
scrollbar-width: thin;
}
}
.scrollbar:hover {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
:global(.cpd-theme-dark) &,
:global(.cpd-theme-dark-hc) & {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(0, 0, 0, 0.2);
:global(.cpd-theme-dark) &,
:global(.cpd-theme-dark-hc) & {
background-color: rgba(255, 255, 255, 0.2);
}
}
}
@media (prefers-color-scheme: dark) {
.scrollbar:hover {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
:global(.cpd-theme-light) &,
:global(.cpd-theme-light-hc) & {
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
}
&::-webkit-scrollbar-thumb {
border-radius: 3px;
background-color: rgba(255, 255, 255, 0.2);
:global(.cpd-theme-light) &,
:global(.cpd-theme-light-hc) & {
background-color: rgba(0, 0, 0, 0.2);
}
}
}
}
@@ -0,0 +1,76 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { AutoHideScrollbar } from "./AutoHideScrollbar";
const containerStyle: React.CSSProperties = {
border: "1px solid",
width: "200px",
height: "150px",
};
const meta = {
title: "Core/AutoHideScrollbar",
component: AutoHideScrollbar,
tags: ["autodocs", "skip-test"],
args: {
as: "div",
role: "container",
style: containerStyle,
children: (
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
<li>Item 5</li>
<li>Item 6</li>
<li>Item 7</li>
<li>Item 8</li>
<li>Item 9</li>
</ul>
),
},
} satisfies Meta<typeof AutoHideScrollbar>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const NoOverflowY: Story = {
args: {
children: (
<ul>
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</ul>
),
},
};
export const NoOverflowX: Story = {
args: {
children: (
<ul>
<li>Item 1 with a very long text</li>
<li>Item 2 with a very long text</li>
<li>Item 3 with a very long text</li>
<li>Item 4 with a very long text</li>
<li>Item 5 with a very long text</li>
<li>Item 6 with a very long text</li>
<li>Item 7 with a very long text</li>
<li>Item 8 with a very long text</li>
<li>Item 9 with a very long text</li>
</ul>
),
},
};
@@ -0,0 +1,141 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, waitFor } from "@test-utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AutoHideScrollbar } from "./AutoHideScrollbar";
class RefUpdateProbe extends React.Component<any, { armed: boolean }> {
public state = { armed: false };
public wrappedRefCalls = 0;
private readonly wrappedRef = (node: HTMLDivElement | null): void => {
this.wrappedRefCalls += 1;
if (node && !this.state.armed) {
this.setState({ armed: true });
}
};
public render(): React.ReactNode {
return (
<>
<AutoHideScrollbar data-testid="scrollbar" wrappedRef={this.wrappedRef}>
<div>Item 1</div>
</AutoHideScrollbar>
<div data-testid="status">{this.state.armed ? "armed" : "idle"}</div>
</>
);
}
}
describe("AutoHideScrollbar", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("renders children with the default scrollbar props", () => {
render(
<AutoHideScrollbar data-testid="scrollbar">
<div>Item 1</div>
</AutoHideScrollbar>,
);
const scrollbar = screen.getByTestId("scrollbar");
expect(scrollbar).toHaveAttribute("tabindex", "-1");
expect(scrollbar.className).toContain("scrollbar");
expect(screen.getByText("Item 1")).toBeInTheDocument();
});
it("forwards props to the requested element", () => {
render(
<AutoHideScrollbar as="section" aria-label="Scrollable content" data-testid="scrollbar">
<div>Item 1</div>
</AutoHideScrollbar>,
);
const scrollbar = screen.getByTestId("scrollbar");
expect(scrollbar.tagName).toBe("SECTION");
expect(scrollbar).toHaveAttribute("aria-label", "Scrollable content");
});
it("attaches the scroll listener and reports the scroll container ref", async () => {
const onScroll = vi.fn<(event: Event) => void>();
const wrappedRef = vi.fn<(ref: HTMLDivElement | null) => void>();
const addEventListenerSpy = vi.spyOn(HTMLElement.prototype, "addEventListener");
const removeEventListenerSpy = vi.spyOn(HTMLElement.prototype, "removeEventListener");
const { unmount } = render(
<AutoHideScrollbar data-testid="scrollbar" onScroll={onScroll} wrappedRef={wrappedRef}>
<div style={{ height: 1000 }}>Scrollable content</div>
</AutoHideScrollbar>,
);
const scrollbar = screen.getByTestId("scrollbar");
await waitFor(() => expect(wrappedRef).toHaveBeenCalledWith(scrollbar));
expect(addEventListenerSpy).toHaveBeenCalledWith("scroll", onScroll, { passive: true });
fireEvent.scroll(scrollbar);
expect(onScroll).toHaveBeenCalledTimes(1);
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith("scroll", onScroll);
expect(wrappedRef).toHaveBeenLastCalledWith(null);
fireEvent.scroll(scrollbar);
expect(onScroll).toHaveBeenCalledTimes(1);
});
it("provides wrappedRef before parent componentDidMount runs", () => {
const calls: Array<string> = [];
class TimingProbe extends React.Component {
private scrollNode: HTMLDivElement | null = null;
public componentDidMount(): void {
calls.push("parent-did-mount");
expect(this.scrollNode).not.toBeNull();
}
public render(): React.ReactNode {
return (
<AutoHideScrollbar
data-testid="scrollbar"
wrappedRef={(node) => {
calls.push(node ? "wrapped-ref" : "wrapped-ref-null");
this.scrollNode = node;
}}
>
<div>Item 1</div>
</AutoHideScrollbar>
);
}
}
render(<TimingProbe />);
expect(calls).toEqual(["wrapped-ref", "parent-did-mount"]);
expect(screen.getByTestId("scrollbar")).toBeInTheDocument();
});
it("does not re-enter wrappedRef when the parent rerenders from the callback", async () => {
const probeRef = React.createRef<RefUpdateProbe>();
render(<RefUpdateProbe ref={probeRef} />);
await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("armed"));
expect(probeRef.current?.wrappedRefCalls).toBe(1);
expect(screen.getByTestId("scrollbar")).toBeInTheDocument();
});
});
@@ -0,0 +1,94 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { type HTMLAttributes, type JSX, type ReactNode, type WheelEvent } from "react";
import styles from "./AutoHideScrollbar.module.css";
type DynamicHtmlElementProps<T extends keyof JSX.IntrinsicElements> =
JSX.IntrinsicElements[T] extends HTMLAttributes<object> ? DynamicElementProps<T> : DynamicElementProps<"div">;
type DynamicElementProps<T extends keyof JSX.IntrinsicElements> = Partial<Omit<JSX.IntrinsicElements[T], "ref">>;
/**
* Props for `AutoHideScrollbar`.
*/
export type AutoHideScrollbarProps<T extends keyof JSX.IntrinsicElements = "div"> = Omit<
DynamicHtmlElementProps<T>,
"onScroll"
> & {
/** The type of the HTML element. @default div*/
as?: T;
/** Additional class names to append to the scrollbar root. */
className?: string;
/** Inline styles applied to the root element. */
style?: React.CSSProperties;
/** Tab index override; defaults to `-1`. */
tabIndex?: number;
/** Receives the mounted scroll container element. */
wrappedRef?: (ref: HTMLDivElement | null) => void;
/** Native scroll handler attached with a passive listener. */
onScroll?: (event: Event) => void;
/** Optional wheel handler forwarded to the root element. */
onWheel?: (event: WheelEvent) => void;
/** Scrollable content rendered inside the container. */
children: ReactNode;
};
/**
* Scroll container that hides native scrollbars until hovered.
* Any overflow-x is hidden by default.
*/
export function AutoHideScrollbar<T extends keyof JSX.IntrinsicElements = "div">(
props: AutoHideScrollbarProps<T>,
): React.ReactNode {
const { as = "div" as T, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = props;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const wrappedRefRef = React.useRef(wrappedRef);
wrappedRefRef.current = wrappedRef;
const collectContainer = React.useCallback((node: HTMLDivElement | null): void => {
containerRef.current = node;
}, []);
React.useLayoutEffect(() => {
wrappedRefRef.current?.(containerRef.current);
return (): void => {
wrappedRefRef.current?.(null);
};
}, []);
React.useLayoutEffect(() => {
const container = containerRef.current;
if (!container || !onScroll) {
return;
}
// Using the passive option to not block the main thread.
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
container.addEventListener("scroll", onScroll, { passive: true });
return (): void => {
container.removeEventListener("scroll", onScroll);
};
}, [onScroll]);
return React.createElement(
as,
{
...otherProps,
ref: collectContainer,
className: classNames(styles.scrollbar, className),
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order by default.
tabIndex: tabIndex ?? -1,
},
children,
);
}
@@ -0,0 +1,8 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export { AutoHideScrollbar, type AutoHideScrollbarProps } from "./AutoHideScrollbar";
+1
View File
@@ -74,6 +74,7 @@ export * from "./room-list/VirtualizedRoomListView/RoomListItemWrapper";
export * from "./core/utils/Box";
export * from "./core/utils/Flex";
export * from "./core/utils/LinkedText";
export * from "./core/utils/Scrollbar";
export * from "./core/VirtualizedList";
export * from "./resize";
@@ -9,3 +9,10 @@
position: relative;
flex: 1;
}
.scrollbar {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
}
@@ -17,6 +17,7 @@ import { type RoomListSectionHeaderViewModel } from "../VirtualizedRoomListView/
import { type ToastType, RoomListToast } from "./RoomListToast";
import styles from "./RoomListView.module.css";
import { Flex } from "../../core/utils/Flex";
import { AutoHideScrollbar } from "../../core/utils/Scrollbar";
export type RoomListSection = {
/** Unique identifier for the section */
@@ -120,8 +121,10 @@ export const RoomListView: React.FC<RoomListViewProps> = ({ vm, renderAvatar, on
/>
</div>
<Flex direction="column" className={styles.list}>
{listBody}
{snapshot.toast && <RoomListToast type={snapshot.toast} onClose={vm.closeToast} />}
<AutoHideScrollbar className={styles.scrollbar}>
{listBody}
{snapshot.toast && <RoomListToast type={snapshot.toast} onClose={vm.closeToast} />}
</AutoHideScrollbar>
</Flex>
</>
);