From 8a21fdb1279eb8845b1db6e12d4b7cdbbc2e69e1 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Thu, 11 Jun 2026 09:08:24 +0200 Subject: [PATCH] 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 --- .../css/views/emojipicker/_EmojiPicker.pcss | 3 +- .../structures/AutoHideScrollbar.tsx | 70 - .../components/structures/EmbeddedPage.tsx | 3 +- .../src/components/structures/HomePage.tsx | 4 +- .../structures/IndicatorScrollbar.tsx | 10 +- .../src/components/structures/LeftPanel.tsx | 2 +- .../src/components/structures/ScrollPanel.tsx | 4 +- .../src/components/structures/TabbedView.tsx | 6 +- .../dialogs/AddExistingToSpaceDialog.tsx | 10 +- .../views/dialogs/ForwardDialog.tsx | 4 +- .../ManageRestrictedJoinRuleDialog.tsx | 4 +- .../views/emojipicker/EmojiPicker.tsx | 22 +- .../components/views/right_panel/BaseCard.tsx | 4 +- .../src/components/views/rooms/AuxPanel.tsx | 4 +- .../views/rooms/ReadReceiptGroup.tsx | 4 +- .../views/spaces/SpaceChildrenPicker.tsx | 4 +- .../components/views/spaces/SpacePanel.tsx | 2 +- .../__snapshots__/MessagePanel-test.tsx.snap | 6 +- .../__snapshots__/RoomView-test.tsx.snap | 30 +- .../__snapshots__/TabbedView-test.tsx.snap | 2 +- ...nageRestrictedJoinRuleDialog-test.tsx.snap | 4 +- .../MessageEditHistoryDialog-test.tsx.snap | 4 +- .../__snapshots__/BaseCard-test.tsx.snap | 2 +- .../ExtensionsCard-test.tsx.snap | 4 +- .../PinnedMessagesCard-test.tsx.snap | 6 +- .../RoomSummaryCardView-test.tsx.snap | 6 +- .../__snapshots__/UserInfo-test.tsx.snap | 4 +- .../ThirdPartyMemberInfo-test.tsx.snap | 4 +- .../AddExistingToSpaceDialog-test.tsx.snap | 2 +- .../__snapshots__/SpacePanel-test.tsx.snap | 2 +- .../Scrollbar/AutoHideScrollbar.module.css | 71 + .../Scrollbar/AutoHideScrollbar.stories.tsx | 76 + .../Scrollbar/AutoHideScrollbar.test.tsx | 141 + .../utils/Scrollbar/AutoHideScrollbar.tsx | 94 + .../src/core/utils/Scrollbar/index.ts | 8 + packages/shared-components/src/index.ts | 1 + .../RoomListView/RoomListView.module.css | 7 + .../room-list/RoomListView/RoomListView.tsx | 7 +- .../__snapshots__/RoomListView.test.tsx.snap | 33377 ++++++++-------- 39 files changed, 17217 insertions(+), 16801 deletions(-) delete mode 100644 apps/web/src/components/structures/AutoHideScrollbar.tsx create mode 100644 packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.module.css create mode 100644 packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.stories.tsx create mode 100644 packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.test.tsx create mode 100644 packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.tsx create mode 100644 packages/shared-components/src/core/utils/Scrollbar/index.ts diff --git a/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss b/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss index b24fe806d9..e115555cd6 100644 --- a/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/apps/web/res/css/views/emojipicker/_EmojiPicker.pcss @@ -20,8 +20,6 @@ Please see LICENSE files in the repository root for full details. .mx_EmojiPicker_body { flex: 1; overflow-y: scroll; - scrollbar-width: thin; - scrollbar-color: rgb(0, 0, 0, 0.2) transparent; } .mx_EmojiPicker_header { @@ -173,6 +171,7 @@ Please see LICENSE files in the repository root for full details. .mx_EmojiPicker_footer { border-top: 1px solid $message-action-bar-border-color; min-height: 72px; + max-height: 72px; display: flex; align-items: center; diff --git a/apps/web/src/components/structures/AutoHideScrollbar.tsx b/apps/web/src/components/structures/AutoHideScrollbar.tsx deleted file mode 100644 index 9cd68b5c58..0000000000 --- a/apps/web/src/components/structures/AutoHideScrollbar.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2020 The Matrix.org Foundation C.I.C. -Copyright 2018 New Vector 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"; - -type DynamicHtmlElementProps = - JSX.IntrinsicElements[T] extends HTMLAttributes ? DynamicElementProps : DynamicElementProps<"div">; -type DynamicElementProps = Partial>; - -export type IProps = Omit, "onScroll"> & { - element: T; - className?: string; - onScroll?: (event: Event) => void; - onWheel?: (event: WheelEvent) => void; - style?: React.CSSProperties; - tabIndex?: number; - wrappedRef?: (ref: HTMLDivElement | null) => void; - children: ReactNode; -}; - -export default class AutoHideScrollbar extends React.Component> { - public static defaultProps = { - element: "div" as keyof HTMLElementTagNameMap, - }; - - public readonly containerRef = React.createRef(); - - public componentDidMount(): void { - if (this.containerRef.current && this.props.onScroll) { - // 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 - this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true }); - } - - this.props.wrappedRef?.(this.containerRef.current); - } - - public componentWillUnmount(): void { - if (this.containerRef.current && this.props.onScroll) { - this.containerRef.current.removeEventListener("scroll", this.props.onScroll); - } - - this.props.wrappedRef?.(null); - } - - public render(): React.ReactNode { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { element, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = this.props; - - return React.createElement( - element, - { - ...otherProps, - ref: this.containerRef, - className: classNames("mx_AutoHideScrollbar", className), - // Firefox sometimes makes this element focusable due to - // overflow:scroll;, so force it out of tab order by default. - tabIndex: tabIndex ?? -1, - }, - children, - ); - } -} diff --git a/apps/web/src/components/structures/EmbeddedPage.tsx b/apps/web/src/components/structures/EmbeddedPage.tsx index 8ea20d3cb3..a363dc7b5d 100644 --- a/apps/web/src/components/structures/EmbeddedPage.tsx +++ b/apps/web/src/components/structures/EmbeddedPage.tsx @@ -11,12 +11,12 @@ import React from "react"; import sanitizeHtml from "sanitize-html"; import classnames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import { _t } from "../../languageHandler"; import dis from "../../dispatcher/dispatcher"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import AutoHideScrollbar from "./AutoHideScrollbar"; import { type ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions.ts"; @@ -121,6 +121,7 @@ export default class EmbeddedPage extends React.PureComponent { const isGuest = client ? client.isGuest() : true; const className = this.props.className; const classes = classnames(className, { + mx_AutoHideScrollbar: this.props.scrollbar, [`${className}_guest`]: isGuest, [`${className}_loggedIn`]: !!client, }); diff --git a/apps/web/src/components/structures/HomePage.tsx b/apps/web/src/components/structures/HomePage.tsx index 56444789c1..9d4062e186 100644 --- a/apps/web/src/components/structures/HomePage.tsx +++ b/apps/web/src/components/structures/HomePage.tsx @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { useContext, useState } from "react"; import { ChatSolidIcon, ExploreIcon, GroupIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; -import AutoHideScrollbar from "./AutoHideScrollbar"; import { getHomePageUrl } from "../../utils/pages"; import { _t, _tDom } from "../../languageHandler"; import SdkConfig from "../../SdkConfig"; @@ -113,7 +113,7 @@ const HomePage: React.FC = ({ justRegistered = false }) => { } return ( - +
{introSection}
diff --git a/apps/web/src/components/structures/IndicatorScrollbar.tsx b/apps/web/src/components/structures/IndicatorScrollbar.tsx index f566e2d256..0ff535ce3f 100644 --- a/apps/web/src/components/structures/IndicatorScrollbar.tsx +++ b/apps/web/src/components/structures/IndicatorScrollbar.tsx @@ -5,9 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, type JSX } from "react"; +import React, { type JSX } from "react"; +import { AutoHideScrollbar, type AutoHideScrollbarProps } from "@element-hq/web-shared-components"; +import classNames from "classnames"; -import AutoHideScrollbar, { type IProps as AutoHideScrollbarProps } from "./AutoHideScrollbar"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; export type IProps = Omit, "onWheel" | "element"> & { @@ -32,7 +33,6 @@ export default class IndicatorScrollbar e IProps, IState > { - private autoHideScrollbar = createRef>(); private scrollElement?: HTMLDivElement; private likelyTrackpadUser: boolean | null = null; private checkAgainForTrackpad = 0; // ts in milliseconds to recheck this._likelyTrackpadUser @@ -170,7 +170,7 @@ export default class IndicatorScrollbar e public render(): React.ReactNode { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { children, trackHorizontalOverflow, verticalScrollsHorizontally, ...otherProps } = this.props; + const { children, trackHorizontalOverflow, verticalScrollsHorizontally, className, ...otherProps } = this.props; const leftIndicatorStyle = { left: this.state.leftIndicatorOffset }; const rightIndicatorStyle = { right: this.state.rightIndicatorOffset }; @@ -184,7 +184,7 @@ export default class IndicatorScrollbar e return ( diff --git a/apps/web/src/components/structures/LeftPanel.tsx b/apps/web/src/components/structures/LeftPanel.tsx index 52e5a87231..aeeb33424a 100644 --- a/apps/web/src/components/structures/LeftPanel.tsx +++ b/apps/web/src/components/structures/LeftPanel.tsx @@ -329,7 +329,7 @@ export default class LeftPanel extends React.Component { diff --git a/apps/web/src/components/structures/ScrollPanel.tsx b/apps/web/src/components/structures/ScrollPanel.tsx index e206fc3c70..05bf2e5a7a 100644 --- a/apps/web/src/components/structures/ScrollPanel.tsx +++ b/apps/web/src/components/structures/ScrollPanel.tsx @@ -8,10 +8,10 @@ Please see LICENSE files in the repository root for full details. import React, { createRef, type CSSProperties, type ReactNode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import SettingsStore from "../../settings/SettingsStore"; import Timer from "../../utils/Timer"; -import AutoHideScrollbar from "./AutoHideScrollbar"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { SDKContext } from "../../contexts/SDKContext"; @@ -940,7 +940,7 @@ export default class ScrollPanel extends React.Component { {this.props.fixedChildren} diff --git a/apps/web/src/components/structures/TabbedView.tsx b/apps/web/src/components/structures/TabbedView.tsx index efffd49025..c6a9f12335 100644 --- a/apps/web/src/components/structures/TabbedView.tsx +++ b/apps/web/src/components/structures/TabbedView.tsx @@ -10,9 +10,9 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import classNames from "classnames"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import { _t } from "../../languageHandler"; -import AutoHideScrollbar from "./AutoHideScrollbar"; import { PosthogScreenTracker, type ScreenName } from "../../PosthogTrackers"; import { type NonEmptyArray } from "../../@types/common"; import { RovingAccessibleButton, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex"; @@ -74,7 +74,9 @@ function TabPanel({ tab }: ITabPanelProps): JSX.Element { id={domIDForTabID(tab.id)} aria-labelledby={`${domIDForTabID(tab.id)}_label`} > - {tab.body} + + {tab.body} +
); } diff --git a/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index fd1d4117bf..76caa5c90b 100644 --- a/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/apps/web/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -13,6 +13,7 @@ import { KnownMembership } from "matrix-js-sdk/src/types"; import { sleep } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { CheckIcon, ErrorIcon, RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import { _t, _td } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; @@ -22,7 +23,6 @@ import SpaceStore from "../../../stores/spaces/SpaceStore"; import RoomAvatar from "../avatars/RoomAvatar"; import { getDisplayAliasForRoom } from "../../../Rooms"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import DMRoomMap from "../../../utils/DMRoomMap"; import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; import StyledCheckbox from "../elements/StyledCheckbox"; @@ -142,7 +142,7 @@ export const AddExistingToSpace: React.FC = ({ [cli, msc3946ProcessDynamicPredecessor], ); - const scrollRef = useRef>(null); + const scrollRef = useRef(null); const [scrollState, setScrollState] = useState({ // these are estimates which update as soon as it mounts scrollTop: 0, @@ -300,7 +300,7 @@ export const AddExistingToSpace: React.FC = ({ } const onScroll = (): void => { - const body = scrollRef.current?.containerRef.current; + const body = scrollRef.current; if (!body) return; setScrollState({ scrollTop: body.scrollTop, @@ -309,6 +309,7 @@ export const AddExistingToSpace: React.FC = ({ }; const wrappedRef = (body: HTMLDivElement | null): void => { + scrollRef.current = body; if (!body) return; setScrollState({ scrollTop: body.scrollTop, @@ -329,10 +330,9 @@ export const AddExistingToSpace: React.FC = ({ autoFocus={true} /> {rooms.length > 0 && roomsRenderer ? roomsRenderer(rooms, selectedToAdd, roomsScrollState, onChange) diff --git a/apps/web/src/components/views/dialogs/ForwardDialog.tsx b/apps/web/src/components/views/dialogs/ForwardDialog.tsx index 6417fd985c..e26b4278ab 100644 --- a/apps/web/src/components/views/dialogs/ForwardDialog.tsx +++ b/apps/web/src/components/views/dialogs/ForwardDialog.tsx @@ -24,6 +24,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { CheckCircleIcon, CircleIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -34,7 +35,6 @@ import { avatarUrlForUser } from "../../../Avatar"; import EventTile from "../rooms/EventTile"; import SearchBox from "../../structures/SearchBox"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import NotificationBadge from "../rooms/NotificationBadge"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; @@ -384,7 +384,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr /> )} - + {rooms.length > 0 ? (
= ({ room, selected = [], onSearch={setQuery} autoFocus={true} /> - + {filteredSpacesContainingRoom.length > 0 ? (

diff --git a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx index 31e3d42944..47047ef77f 100644 --- a/apps/web/src/components/views/emojipicker/EmojiPicker.tsx +++ b/apps/web/src/components/views/emojipicker/EmojiPicker.tsx @@ -10,10 +10,10 @@ Please see LICENSE files in the repository root for full details. import React, { type Dispatch } from "react"; import { DATA_BY_CATEGORY, getEmojiFromUnicode, type Emoji as IEmoji } from "@matrix-org/emojibase-bindings"; import classNames from "classnames"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import * as recent from "../../../emojipicker/recent"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import Header from "./Header"; import Search from "./Search"; import Preview from "./Preview"; @@ -59,7 +59,7 @@ class EmojiPicker extends React.Component { private readonly memoizedDataByCategory: Record; private readonly categories: ICategory[]; - private scrollRef = React.createRef>(); + private scrollElement: HTMLDivElement | null = null; public constructor(props: IProps) { super(props); @@ -115,7 +115,7 @@ class EmojiPicker extends React.Component { } private onScroll = (): void => { - const body = this.scrollRef.current?.containerRef.current; + const body = this.scrollElement; if (!body) return; this.setState({ scrollTop: body.scrollTop, @@ -174,7 +174,7 @@ class EmojiPicker extends React.Component { }; private updateVisibility = (): void => { - const body = this.scrollRef.current?.containerRef.current; + const body = this.scrollElement; if (!body) return; const rect = body.getBoundingClientRect(); let firstVisibleFound = false; @@ -213,9 +213,7 @@ class EmojiPicker extends React.Component { }; private scrollToCategory = (category: string): void => { - this.scrollRef.current?.containerRef.current - ?.querySelector(`[data-category-id="${category}"]`) - ?.scrollIntoView(); + this.scrollElement?.querySelector(`[data-category-id="${category}"]`)?.scrollIntoView(); }; private onChangeFilter = (filter: string): void => { @@ -293,9 +291,7 @@ class EmojiPicker extends React.Component { // Only select emoji if highlight is shown if (!this.state.showHighlight) return; - const btn = this.scrollRef.current?.containerRef.current?.querySelector( - '.mx_EmojiPicker_item_wrapper [tabindex="0"]', - ); + const btn = this.scrollElement?.querySelector('.mx_EmojiPicker_item_wrapper [tabindex="0"]'); btn?.click(); this.props.onFinished(); }; @@ -357,10 +353,12 @@ class EmojiPicker extends React.Component { /> { + this.scrollElement = ref; + }} onScroll={this.onScroll} > {this.categories.map((category) => { diff --git a/apps/web/src/components/views/right_panel/BaseCard.tsx b/apps/web/src/components/views/right_panel/BaseCard.tsx index e8b466ff44..07ed38b65c 100644 --- a/apps/web/src/components/views/right_panel/BaseCard.tsx +++ b/apps/web/src/components/views/right_panel/BaseCard.tsx @@ -11,8 +11,8 @@ import classNames from "classnames"; import { IconButton, Text } from "@vector-im/compound-web"; import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { _t } from "../../../languageHandler"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { backLabelForPhase } from "../../../stores/right-panel/RightPanelStorePhases"; @@ -100,7 +100,7 @@ const BaseCard: React.FC = ({ } if (!withoutScrollContainer) { - children = {children}; + children = {children}; } const shouldRenderHeader = header || !hideHeaderButtons; diff --git a/apps/web/src/components/views/rooms/AuxPanel.tsx b/apps/web/src/components/views/rooms/AuxPanel.tsx index f547c29d2e..c8d0afebbe 100644 --- a/apps/web/src/components/views/rooms/AuxPanel.tsx +++ b/apps/web/src/components/views/rooms/AuxPanel.tsx @@ -8,10 +8,10 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import AppsDrawer from "./AppsDrawer"; import SettingsStore from "../../../settings/SettingsStore"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { UIFeature } from "../../../settings/UIFeature"; import LegacyCallViewForRoom from "../voip/LegacyCallViewForRoom"; import { objectHasDiff } from "../../../utils/objects"; @@ -44,7 +44,7 @@ export default class AuxPanel extends React.Component { } return ( - + {this.props.children} {appsDrawer} {callView} diff --git a/apps/web/src/components/views/rooms/ReadReceiptGroup.tsx b/apps/web/src/components/views/rooms/ReadReceiptGroup.tsx index 910e0cfb52..8b2d839a1b 100644 --- a/apps/web/src/components/views/rooms/ReadReceiptGroup.tsx +++ b/apps/web/src/components/views/rooms/ReadReceiptGroup.tsx @@ -9,12 +9,12 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type PropsWithChildren } from "react"; import { type User } from "matrix-js-sdk/src/matrix"; import { Tooltip } from "@vector-im/compound-web"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import ReadReceiptMarker, { type IReadReceiptPosition } from "./ReadReceiptMarker"; import { type IReadReceiptProps } from "./EventTile"; import AccessibleButton from "../elements/AccessibleButton"; import MemberAvatar from "../avatars/MemberAvatar"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { formatDate } from "../../../DateUtils"; import { Action } from "../../../dispatcher/actions"; import dis from "../../../dispatcher/dispatcher"; @@ -146,7 +146,7 @@ export function ReadReceiptGroup({ const buttonRect = button.current.getBoundingClientRect(); contextMenu = ( - + {_t("timeline|read_receipt_title", { count: readReceipts.length })} diff --git a/apps/web/src/components/views/spaces/SpaceChildrenPicker.tsx b/apps/web/src/components/views/spaces/SpaceChildrenPicker.tsx index 870108ff35..1173e93b95 100644 --- a/apps/web/src/components/views/spaces/SpaceChildrenPicker.tsx +++ b/apps/web/src/components/views/spaces/SpaceChildrenPicker.tsx @@ -8,12 +8,12 @@ Please see LICENSE files in the repository root for full details. import React, { useEffect, useMemo, useState } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; +import { AutoHideScrollbar } from "@element-hq/web-shared-components"; import { _t } from "../../../languageHandler"; import StyledRadioGroup from "../elements/StyledRadioGroup"; import QueryMatcher from "../../../autocomplete/QueryMatcher"; import SearchBox from "../../structures/SearchBox"; -import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { Entry } from "../dialogs/AddExistingToSpaceDialog"; import { filterBoolean } from "../../../utils/arrays"; @@ -61,7 +61,7 @@ const SpecificChildrenPicker: React.FC = ({ onSearch={setQuery} autoFocus={true} /> - + {filteredRooms.map((room) => { return ( ( } : undefined } - element="ul" + as="ul" role="tree" aria-label={_t("common|spaces")} > diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap index 64d7061c81..931f952a9c 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -3,7 +3,7 @@ exports[`MessagePanel should handle large numbers of hidden events quickly 1`] = `

should support events with 1`] = `
should close when clicking X button 1`] = `
diff --git a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap index 39a1ac61dc..36f3dc76c5 100644 --- a/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/right_panel/__snapshots__/ExtensionsCard-test.tsx.snap @@ -46,7 +46,7 @@ exports[` should render empty state 1`] = `
should show two pinned messages 1`] = `
unpin all should not allow to unpinall 1`] = `
has button to edit topic 1`] = `
renders the room summary 1`] = `
renders the room topic in the summary 1`] = `
with crypto enabled renders 1`] = `
with crypto enabled should render a deactivate button for
should render invite 1`] = `
should render invite when room in not availabl
looks as expected 1`] = `
should show all activated MetaSpaces in the correct orde
    * { + /* 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); + } + } + } +} diff --git a/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.stories.tsx b/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.stories.tsx new file mode 100644 index 0000000000..34391c2321 --- /dev/null +++ b/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.stories.tsx @@ -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: ( +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    • Item 4
    • +
    • Item 5
    • +
    • Item 6
    • +
    • Item 7
    • +
    • Item 8
    • +
    • Item 9
    • +
    + ), + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoOverflowY: Story = { + args: { + children: ( +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    + ), + }, +}; + +export const NoOverflowX: Story = { + args: { + children: ( +
      +
    • Item 1 with a very long text
    • +
    • Item 2 with a very long text
    • +
    • Item 3 with a very long text
    • +
    • Item 4 with a very long text
    • +
    • Item 5 with a very long text
    • +
    • Item 6 with a very long text
    • +
    • Item 7 with a very long text
    • +
    • Item 8 with a very long text
    • +
    • Item 9 with a very long text
    • +
    + ), + }, +}; diff --git a/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.test.tsx b/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.test.tsx new file mode 100644 index 0000000000..a80b5352d0 --- /dev/null +++ b/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.test.tsx @@ -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 { + 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 ( + <> + +
    Item 1
    +
    +
    {this.state.armed ? "armed" : "idle"}
    + + ); + } +} + +describe("AutoHideScrollbar", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders children with the default scrollbar props", () => { + render( + +
    Item 1
    +
    , + ); + + 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( + +
    Item 1
    +
    , + ); + + 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( + +
    Scrollable content
    +
    , + ); + + 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 = []; + + 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 ( + { + calls.push(node ? "wrapped-ref" : "wrapped-ref-null"); + this.scrollNode = node; + }} + > +
    Item 1
    +
    + ); + } + } + + render(); + + 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(); + + render(); + + await waitFor(() => expect(screen.getByTestId("status")).toHaveTextContent("armed")); + expect(probeRef.current?.wrappedRefCalls).toBe(1); + expect(screen.getByTestId("scrollbar")).toBeInTheDocument(); + }); +}); diff --git a/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.tsx b/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.tsx new file mode 100644 index 0000000000..3d89266147 --- /dev/null +++ b/packages/shared-components/src/core/utils/Scrollbar/AutoHideScrollbar.tsx @@ -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 = + JSX.IntrinsicElements[T] extends HTMLAttributes ? DynamicElementProps : DynamicElementProps<"div">; +type DynamicElementProps = Partial>; + +/** + * Props for `AutoHideScrollbar`. + */ +export type AutoHideScrollbarProps = Omit< + DynamicHtmlElementProps, + "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( + props: AutoHideScrollbarProps, +): React.ReactNode { + const { as = "div" as T, className, onScroll, tabIndex, wrappedRef, children, ...otherProps } = props; + const containerRef = React.useRef(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, + ); +} diff --git a/packages/shared-components/src/core/utils/Scrollbar/index.ts b/packages/shared-components/src/core/utils/Scrollbar/index.ts new file mode 100644 index 0000000000..7d78b22b7b --- /dev/null +++ b/packages/shared-components/src/core/utils/Scrollbar/index.ts @@ -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"; diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 4e758912c9..8b22018de6 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css index c20d3006bf..05b2bcb4a0 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.module.css @@ -9,3 +9,10 @@ position: relative; flex: 1; } + +.scrollbar { + display: flex; + flex-direction: column; + flex: 1; + width: 100%; +} diff --git a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx index 0a3b5d2113..b0f1159794 100644 --- a/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx +++ b/packages/shared-components/src/room-list/RoomListView/RoomListView.tsx @@ -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 = ({ vm, renderAvatar, on /> - {listBody} - {snapshot.toast && } + + {listBody} + {snapshot.toast && } + ); diff --git a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap index 7683836f5f..19386bd10d 100644 --- a/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap +++ b/packages/shared-components/src/room-list/RoomListView/__snapshots__/RoomListView.test.tsx.snap @@ -62,2611 +62,2616 @@ exports[` > renders Default story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - RA -
    -
    +
    - Random +
    + Random +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - - -
    -
    +
    -
    - EN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - DE -
    -
    +
    - Design +
    + Design +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - MA -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - SA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - SU -
    -
    +
    - Support +
    + Support +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - AN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - OF -
    -
    +
    - Off-topic +
    + Off-topic +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - TE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - TE -
    -
    +
    - Team Beta +
    + Team Beta +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    +
    - Project Y +
    + Project Y +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - WA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - ID -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - BU -
    -
    +
    - Bugs +
    + Bugs +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - RE -
    -
    +
    - Releases +
    + Releases +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - + +
    @@ -2738,66 +2743,71 @@ exports[` > renders Empty story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - No chats yet - - - Get started by messaging someone or by creating a room -
    - - + + Start chat + + +
    @@ -2849,20 +2859,25 @@ exports[` > renders EmptyFavouriteFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - You don't have favourite chats yet - - - You can add a chat to your favourites in the chat settings - + + You don't have favourite chats yet + + + You can add a chat to your favourites in the chat settings + +
    @@ -2913,24 +2928,29 @@ exports[` > renders EmptyInvitesFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - You don't have any unread invites - - + + You don't have any unread invites + + +
    @@ -2981,24 +3001,29 @@ exports[` > renders EmptyLowPriorityFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - You don't have any low priority rooms - - + + You don't have any low priority rooms + + +
    @@ -3049,24 +3074,29 @@ exports[` > renders EmptyMentionsFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - You don't have any unread mentions - - + + You don't have any unread mentions + + +
    @@ -3117,20 +3147,25 @@ exports[` > renders EmptyPeopleFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - You don’t have direct chats with anyone yet - - - You can deselect filters in order to see your other chats - + + You don’t have direct chats with anyone yet + + + You can deselect filters in order to see your other chats + +
    @@ -3181,20 +3216,25 @@ exports[` > renders EmptyRoomsFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - You’re not in any room yet - - - You can deselect filters in order to see your other chats - + + You’re not in any room yet + + + You can deselect filters in order to see your other chats + +
    @@ -3245,24 +3285,29 @@ exports[` > renders EmptyUnreadFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - Congrats! You don’t have any unread messages - - + + Congrats! You don’t have any unread messages + + +
    @@ -3331,45 +3376,50 @@ exports[` > renders EmptyWithoutCreatePermission story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    - - No chats yet - - - Get started by messaging someone -
    - + + Start chat + +
    @@ -3439,4831 +3489,4836 @@ exports[` > renders LargeFlatList story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - RA -
    -
    +
    - Random +
    + Random +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - - -
    -
    +
    -
    - EN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - DE -
    -
    +
    - Design +
    + Design +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - MA -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - SA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - SU -
    -
    +
    - Support +
    + Support +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - AN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - OF -
    -
    +
    - Off-topic +
    + Off-topic +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - TE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - TE -
    -
    +
    - Team Beta +
    + Team Beta +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    +
    - Project Y +
    + Project Y +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - WA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - ID -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - BU -
    -
    +
    - Bugs +
    + Bugs +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - RE -
    -
    +
    - Releases +
    + Releases +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - GE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - RA -
    -
    +
    - Random +
    + Random +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - EN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - DE -
    -
    +
    - Design +
    + Design +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - MA -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - SA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - SU -
    -
    +
    - Support +
    + Support +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - AN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - OF -
    -
    +
    - Off-topic +
    + Off-topic +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - TE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - TE -
    -
    +
    - Team Beta +
    + Team Beta +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    +
    - Project Y +
    + Project Y +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - WA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - ID -
    -
    - - +
    +
    + + +
    + - -
    - + +
    @@ -8335,4944 +8390,4949 @@ exports[` > renders LargeSectionList story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    +
    - +
    +
    +
    +
    -
    - - - - - Favourites - -
    +
    + GE +
    +
    +
    +
    + General +
    +
    + Last message in General +
    +
    +
    + + +
    + +
    +
    - -
    -
    -
    -
    -
    - - -
    - -
    -
    -
    -
    - - -
    - -
    - -
    -
    -
    -
    -
    -
    - - -
    - -
    - -
    -
    - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    -
    - - - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    -
    - - - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    -
    - - - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    -
    - - - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    -
    - - -
    - -
    - -
    - - -
    -
    - + +
    + +
    + - + - -
    +
    + + +
    + +
    + +
    +
    + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    +
    + + + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    +
    + + + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    +
    + + + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    +
    + + + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    +
    + + +
    + +
    + +
    + + +
    +
    - -
    -
    - -
    - - + + + +
    + + +
    + - -
    - + + - -
    - - -
    - -
    - + +
    - -
    -
    +
    + + +
    +
    - -
    - + + - -
    - - +
    - + + + +
    + +
    + - - - + + - -
    - - + + + +
    + + +
    + - -
    - + + - -
    - - +
    - + + + +
    + +
    + - - - + + - -
    - - -
    - +
    + + +
    +
    -
    - + + - -
    - + +
    +
    -
    - - -
    - -
    - + + - -
    - - + + + +
    + + +
    + - -
    - + + - -
    - - +
    - + + + +
    + +
    + - - - + + - -
    - + +
    +
    + +
    + + +
    +
    +
    + - +
    +
    + + +
    + - -
    - + +
    @@ -13346,8 +13406,13 @@ exports[` > renders Loading story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    + class="AutoHideScrollbar-module_scrollbar RoomListView-module_scrollbar" + tabindex="-1" + > +
    +
    @@ -13415,292 +13480,297 @@ exports[` > renders SmallFlatList story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - RA -
    -
    +
    - Random +
    + Random +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - + + @@ -13772,417 +13842,422 @@ exports[` > renders SmallSectionList story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    +
    - -
    -
    -
    -
    +
    +
    +
    +
    +
    +
    +
    + + +
    + -
    -
    - + +
    -
    -
    +
    + + +
    + +
    + +
    +
    +
    +
    +
    - -
    -
    -
    -
    - -
    -
    @@ -14253,2648 +14328,2653 @@ exports[` > renders Toast story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    -
    - - -
    - -
    - -
    -
    - - -
    - -
    - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - -
    - - -
    - - - - - - - -
    -
    - Section created -
    - + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + +
    + + +
    + + + + + - + +
    +
    + Section created +
    + +
    @@ -16963,2611 +17043,2616 @@ exports[` > renders WithActiveFilter story 1`] = ` style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" >
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - RA -
    -
    +
    - Random +
    + Random +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - - -
    -
    +
    -
    - EN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - DE -
    -
    +
    - Design +
    + Design +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - MA -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - SA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - SU -
    -
    +
    - Support +
    + Support +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - AN -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - OF -
    -
    +
    - Off-topic +
    + Off-topic +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - TE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - TE -
    -
    +
    - Team Beta +
    + Team Beta +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - PR -
    -
    +
    - Project Y +
    + Project Y +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - WA -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    -
    +
    + + +
    + -
    -
    - -
    -
    -
    +
    -
    - ID -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - BU -
    -
    +
    - Bugs +
    + Bugs +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - -
    -
    -
    +
    -
    - FE -
    -
    - - +
    +
    + + +
    + - -
    - -
    -
    -
    +
    -
    - RE -
    -
    +
    - Releases +
    + Releases +
    -
    -
    - - +
    - + + + +
    + +
    + - -
    - + +