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:
@@ -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";
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+16731
-16646
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user