Move room list search to shared components (#31502)

* refactor: move room list search to shared components

* refactor: add view model

* refactor: use view and vm in room list search component

* refactor: use room list id instead of class for landmark navigation

* refactor: remove old room list search css

* test: add screenshots test for room list search view

* test: fix e2e test using class as selector...
This commit is contained in:
Florian Duros
2025-12-11 16:43:20 +01:00
committed by GitHub
parent 23fbe9cef6
commit 5b900ab6e2
22 changed files with 901 additions and 397 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -19,6 +19,7 @@ export * from "./pill-input/Pill";
export * from "./pill-input/PillInput"; export * from "./pill-input/PillInput";
export * from "./rich-list/RichItem"; export * from "./rich-list/RichItem";
export * from "./rich-list/RichList"; export * from "./rich-list/RichList";
export * from "./room-list/RoomListSearchView";
export * from "./utils/Box"; export * from "./utils/Box";
export * from "./utils/Flex"; export * from "./utils/Flex";

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2025 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.
*/
.view {
/* From figma, this should be aligned with the room header */
min-height: 64px;
box-sizing: border-box;
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
padding: 0 var(--cpd-space-3x);
}
.search {
/* The search button should take all the remaining space */
flex: 1;
/* !important is needed to override compound button in EW */
font: var(--cpd-font-body-md-regular) !important;
color: var(--cpd-color-text-secondary) !important;
min-width: 0;
svg {
fill: var(--cpd-color-icon-secondary);
}
}
.search_container {
flex: 1;
/* Shrink and truncate the search text */
white-space: nowrap;
overflow: hidden;
kbd {
font-family: inherit;
}
}
.search_text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
}

View File

@@ -0,0 +1,74 @@
/*
* Copyright 2025 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 React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryFn } from "@storybook/react-vite";
import {
RoomListSearchView,
type RoomListSearchViewActions,
type RoomListSearchViewSnapshot,
} from "./RoomListSearchView";
import { useMockedViewModel } from "../../useMockedViewModel";
type RoomListSearchProps = RoomListSearchViewSnapshot & RoomListSearchViewActions;
const RoomListSearchViewWrapper = ({
onSearchClick,
onDialPadClick,
onExploreClick,
...rest
}: RoomListSearchProps): JSX.Element => {
const vm = useMockedViewModel(rest, {
onSearchClick,
onDialPadClick,
onExploreClick,
});
return <RoomListSearchView vm={vm} />;
};
export default {
title: "Room List/RoomListSearchView",
component: RoomListSearchViewWrapper,
tags: ["autodocs"],
args: {
displayExploreButton: true,
displayDialButton: false,
searchShortcut: "⌘ K",
onSearchClick: fn(),
onDialPadClick: fn(),
onExploreClick: fn(),
},
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/vlmt46QDdE4dgXDiyBJXqp/ER-33-Left-Panel-2025?node-id=98-1979&t=vafb4zoYMNLRuAbh-4",
},
},
} as Meta<typeof RoomListSearchViewWrapper>;
const Template: StoryFn<typeof RoomListSearchViewWrapper> = (args) => <RoomListSearchViewWrapper {...args} />;
export const Default = Template.bind({});
export const WithDialPad = Template.bind({});
WithDialPad.args = {
displayDialButton: true,
};
export const WithoutExplore = Template.bind({});
WithoutExplore.args = {
displayExploreButton: false,
};
export const AllButtons = Template.bind({});
AllButtons.args = {
displayExploreButton: true,
displayDialButton: true,
searchShortcut: "⌘ K",
};

View File

@@ -0,0 +1,103 @@
/*
* Copyright 2025 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 { render, screen } from "jest-matrix-react";
import { composeStories } from "@storybook/react-vite";
import React from "react";
import userEvent from "@testing-library/user-event";
import * as stories from "./RoomListSearchView.stories";
import {
RoomListSearchView,
type RoomListSearchViewActions,
type RoomListSearchViewSnapshot,
} from "./RoomListSearchView";
import { MockViewModel } from "../../viewmodel/MockViewModel";
const { Default, WithDialPad, WithoutExplore, AllButtons } = composeStories(stories);
describe("RoomListSearchView", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("Storybook snapshots", () => {
it("renders the default state", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders with dial pad button", () => {
const { container } = render(<WithDialPad />);
expect(container).toMatchSnapshot();
});
it("renders without explore button", () => {
const { container } = render(<WithoutExplore />);
expect(container).toMatchSnapshot();
});
it("renders with all buttons visible", () => {
const { container } = render(<AllButtons />);
expect(container).toMatchSnapshot();
});
});
describe("User interactions", () => {
const onSearchClick = jest.fn();
const onDialPadClick = jest.fn();
const onExploreClick = jest.fn();
class TestViewModel extends MockViewModel<RoomListSearchViewSnapshot> implements RoomListSearchViewActions {
public onSearchClick = onSearchClick;
public onDialPadClick = onDialPadClick;
public onExploreClick = onExploreClick;
}
it("should call onSearchClick when search button is clicked", async () => {
const user = userEvent.setup();
const vm = new TestViewModel({
displayExploreButton: false,
displayDialButton: false,
searchShortcut: "⌘ K",
});
render(<RoomListSearchView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Search ⌘ K" }));
expect(onSearchClick).toHaveBeenCalledTimes(1);
});
it("should call onDialPadClick when dial pad button is clicked", async () => {
const user = userEvent.setup();
const vm = new TestViewModel({
displayExploreButton: false,
displayDialButton: true,
searchShortcut: "⌘ K",
});
render(<RoomListSearchView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Open dial pad" }));
expect(onDialPadClick).toHaveBeenCalledTimes(1);
});
it("should call onExploreClick when explore button is clicked", async () => {
const user = userEvent.setup();
const vm = new TestViewModel({
displayExploreButton: true,
displayDialButton: false,
searchShortcut: "⌘ K",
});
render(<RoomListSearchView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Explore rooms" }));
expect(onExploreClick).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,119 @@
/*
* Copyright 2025 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 React, { type JSX, type MouseEventHandler } from "react";
import { Button } from "@vector-im/compound-web";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
import styles from "./RoomListSearchView.module.css";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { Flex } from "../../utils/Flex";
import { useI18n } from "../../utils/i18nContext";
export interface RoomListSearchViewSnapshot {
/**
* Whether to display the explore button.
*/
displayExploreButton: boolean;
/**
* Whether to display the dial pad button.
*/
displayDialButton: boolean;
/**
* The keyboard shortcut text to display for the search action.
* For example: "⌘ K" on macOS or "Ctrl K" on other platforms.
*/
searchShortcut: string;
}
export interface RoomListSearchViewActions {
/**
* Handles the click event on the search button.
*/
onSearchClick: MouseEventHandler<HTMLButtonElement>;
/**
* Handles the click event on the dial pad button.
*/
onDialPadClick: MouseEventHandler<HTMLButtonElement>;
/**
* Handles the click event on the explore button.
*/
onExploreClick: MouseEventHandler<HTMLButtonElement>;
}
/**
* The view model for the room list search component.
*/
export type RoomListSearchViewModel = ViewModel<RoomListSearchViewSnapshot> & RoomListSearchViewActions;
interface RoomListSearchViewProps {
/**
* The view model for the room list search component.
*/
vm: RoomListSearchViewModel;
}
/**
* A search component to be displayed at the top of the room list.
* The component provides search functionality, optional dial pad access, and optional room exploration.
*
* @example
* ```tsx
* <RoomListSearchView vm={roomListSearchViewModel} />
* ```
*/
export function RoomListSearchView({ vm }: Readonly<RoomListSearchViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { displayExploreButton, displayDialButton, searchShortcut } = useViewModel(vm);
return (
<Flex
data-testid="room-list-search"
className={styles.view}
role="search"
gap="var(--cpd-space-2x)"
align="center"
>
<Button
id="room-list-search-button"
className={styles.search}
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={vm.onSearchClick}
>
<Flex className={styles["search_container"]} as="span" justify="space-between">
<span className={styles["search_text"]}>{_t("action|search")}</span>
<kbd>{searchShortcut}</kbd>
</Flex>
</Button>
{displayDialButton && (
<Button
kind="secondary"
size="sm"
Icon={DialPadIcon}
iconOnly={true}
aria-label={_t("left_panel|open_dial_pad")}
onClick={vm.onDialPadClick}
/>
)}
{displayExploreButton && (
<Button
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={vm.onExploreClick}
/>
)}
</Flex>
);
}

View File

@@ -0,0 +1,290 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`RoomListSearchView Storybook snapshots renders the default state 1`] = `
<div>
<div
class="flex view"
data-testid="room-list-search"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
id="room-list-search-button"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="flex search_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="search_text"
>
Search
</span>
<kbd>
⌘ K
</kbd>
</span>
</button>
<button
aria-label="Explore rooms"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 12q0-.424.287-.713A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 12 13m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20m0 0q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4t5.675 2.325T20 12t-2.325 5.675T12 20m1.675-5.85q.15-.075.275-.2t.2-.275l2.925-6.25q.125-.25-.062-.437-.188-.188-.438-.063l-6.25 2.925q-.15.075-.275.2t-.2.275l-2.925 6.25q-.125.25.063.438.186.186.437.062z"
/>
</svg>
</button>
</div>
</div>
`;
exports[`RoomListSearchView Storybook snapshots renders with all buttons visible 1`] = `
<div>
<div
class="flex view"
data-testid="room-list-search"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
id="room-list-search-button"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="flex search_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="search_text"
>
Search
</span>
<kbd>
⌘ K
</kbd>
</span>
</button>
<button
aria-label="Open dial pad"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 18.6c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8M6.6 2.4c-.99 0-1.8.81-1.8 1.8S5.61 6 6.6 6s1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0 5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0 5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8M17.4 6c.99 0 1.8-.81 1.8-1.8s-.81-1.8-1.8-1.8-1.8.81-1.8 1.8.81 1.8 1.8 1.8M12 13.2c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m5.4 0c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0-5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m-5.4 0c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0-5.4c-.99 0-1.8.81-1.8 1.8S11.01 6 12 6s1.8-.81 1.8-1.8-.81-1.8-1.8-1.8"
/>
</svg>
</button>
<button
aria-label="Explore rooms"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 12q0-.424.287-.713A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 12 13m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20m0 0q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4t5.675 2.325T20 12t-2.325 5.675T12 20m1.675-5.85q.15-.075.275-.2t.2-.275l2.925-6.25q.125-.25-.062-.437-.188-.188-.438-.063l-6.25 2.925q-.15.075-.275.2t-.2.275l-2.925 6.25q-.125.25.063.438.186.186.437.062z"
/>
</svg>
</button>
</div>
</div>
`;
exports[`RoomListSearchView Storybook snapshots renders with dial pad button 1`] = `
<div>
<div
class="flex view"
data-testid="room-list-search"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
id="room-list-search-button"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="flex search_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="search_text"
>
Search
</span>
<kbd>
⌘ K
</kbd>
</span>
</button>
<button
aria-label="Open dial pad"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 18.6c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8M6.6 2.4c-.99 0-1.8.81-1.8 1.8S5.61 6 6.6 6s1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0 5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0 5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8M17.4 6c.99 0 1.8-.81 1.8-1.8s-.81-1.8-1.8-1.8-1.8.81-1.8 1.8.81 1.8 1.8 1.8M12 13.2c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m5.4 0c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0-5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m-5.4 0c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0-5.4c-.99 0-1.8.81-1.8 1.8S11.01 6 12 6s1.8-.81 1.8-1.8-.81-1.8-1.8-1.8"
/>
</svg>
</button>
<button
aria-label="Explore rooms"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 12q0-.424.287-.713A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 12 13m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20m0 0q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4t5.675 2.325T20 12t-2.325 5.675T12 20m1.675-5.85q.15-.075.275-.2t.2-.275l2.925-6.25q.125-.25-.062-.437-.188-.188-.438-.063l-6.25 2.925q-.15.075-.275.2t-.2.275l-2.925 6.25q-.125.25.063.438.186.186.437.062z"
/>
</svg>
</button>
</div>
</div>
`;
exports[`RoomListSearchView Storybook snapshots renders without explore button 1`] = `
<div>
<div
class="flex view"
data-testid="room-list-search"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
id="room-list-search-button"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="flex search_container"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="search_text"
>
Search
</span>
<kbd>
⌘ K
</kbd>
</span>
</button>
</div>
</div>
`;

View File

@@ -0,0 +1,9 @@
/*
* Copyright 2025 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.
*/
export type { RoomListSearchViewModel, RoomListSearchViewSnapshot } from "./RoomListSearchView";
export { RoomListSearchView } from "./RoomListSearchView";

View File

@@ -29,7 +29,7 @@ test.describe("Landmark navigation tests", () => {
// Pressing Control+F6 again will focus room search // Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6"); await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomListSearch_search")).toBeFocused(); await expect(page.locator("#room-list-search-button")).toBeFocused();
// Pressing Control+F6 again will focus the message composer // Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6"); await page.keyboard.press("ControlOrMeta+F6");
@@ -44,7 +44,7 @@ test.describe("Landmark navigation tests", () => {
await expect(page.locator(".mx_HomePage")).toBeFocused(); await expect(page.locator(".mx_HomePage")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6"); await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomListSearch_search")).toBeFocused(); await expect(page.locator("#room-list-search-button")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6"); await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
@@ -75,7 +75,7 @@ test.describe("Landmark navigation tests", () => {
// Pressing Control+F6 again will focus room search // Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6"); await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomListSearch_search")).toBeFocused(); await expect(page.locator("#room-list-search-button")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list // Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6"); await page.keyboard.press("ControlOrMeta+F6");
@@ -97,7 +97,7 @@ test.describe("Landmark navigation tests", () => {
await expect(page.locator(".mx_RoomListItemView_selected")).toBeFocused(); await expect(page.locator(".mx_RoomListItemView_selected")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6"); await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomListSearch_search")).toBeFocused(); await expect(page.locator("#room-list-search-button")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6"); await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
@@ -131,7 +131,7 @@ test.describe("Landmark navigation tests", () => {
// Pressing Control+F6 again will focus room search // Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6"); await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomListSearch_search")).toBeFocused(); await expect(page.locator("#room-list-search-button")).toBeFocused();
// Pressing Control+F6 again will focus the room tile in the room list // Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6"); await page.keyboard.press("ControlOrMeta+F6");
@@ -153,7 +153,7 @@ test.describe("Landmark navigation tests", () => {
await expect(page.locator(".mx_RoomListItemView")).toBeFocused(); await expect(page.locator(".mx_RoomListItemView")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6"); await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomListSearch_search")).toBeFocused(); await expect(page.locator("#room-list-search-button")).toBeFocused();
await page.keyboard.press("ControlOrMeta+Shift+F6"); await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

View File

@@ -24,7 +24,7 @@ test.describe("PSTN", () => {
await toasts.rejectToast("Notifications"); await toasts.rejectToast("Notifications");
await toasts.assertNoToasts(); await toasts.assertNoToasts();
await expect(page.locator(".mx_RoomListSearch")).toMatchScreenshot("dialpad-trigger.png"); await expect(page.getByTestId("room-list-search")).toMatchScreenshot("dialpad-trigger.png");
await page.getByLabel("Open dial pad").click(); await page.getByLabel("Open dial pad").click();
await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); await expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png");
}); });

View File

@@ -273,7 +273,6 @@
@import "./views/rooms/RoomListPanel/_RoomListItemView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListItemView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
@import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss";
@import "./views/rooms/_AppsDrawer.pcss"; @import "./views/rooms/_AppsDrawer.pcss";

View File

@@ -1,45 +0,0 @@
/*
* Copyright 2025 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.
*/
.mx_RoomListSearch {
/* From figma, this should be aligned with the room header */
flex: 0 0 64px;
box-sizing: border-box;
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-bg-subtle-primary);
padding: 0 var(--cpd-space-3x);
.mx_RoomListSearch_search {
/* The search button should take all the remaining space */
flex: 1;
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-secondary);
min-width: 0;
svg {
fill: var(--cpd-color-icon-secondary);
}
span {
flex: 1;
kbd {
font-family: inherit;
}
/* Shrink and truncate the search text */
white-space: nowrap;
overflow: hidden;
.mx_RoomListSearch_search_text {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
}
}
}
}

View File

@@ -75,7 +75,7 @@ const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undef
[Landmark.ROOM_SEARCH]: () => [Landmark.ROOM_SEARCH]: () =>
SettingsStore.getValue("feature_new_room_list") SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListSearch_search") ? document.querySelector<HTMLElement>("#room-list-search-button")
: document.querySelector<HTMLElement>(".mx_RoomSearch"), : document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_LIST]: () => [Landmark.ROOM_LIST]: () =>
SettingsStore.getValue("feature_new_room_list") SettingsStore.getValue("feature_new_room_list")

View File

@@ -5,24 +5,10 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX } from "react"; import React, { useEffect, type JSX } from "react";
import { Button } from "@vector-im/compound-web"; import { RoomListSearchView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
import SearchIcon from "@vector-im/compound-design-tokens/assets/web/icons/search";
import DialPadIcon from "@vector-im/compound-design-tokens/assets/web/icons/dial-pad";
import { Flex } from "@element-hq/web-shared-components";
import { IS_MAC, Key } from "../../../../Keyboard"; import { RoomListSearchViewModel } from "../../../../viewmodels/room-list/RoomListSearchViewModel";
import { _t } from "../../../../languageHandler";
import { ALTERNATE_KEY_NAME } from "../../../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { MetaSpace } from "../../../../stores/spaces";
import { Action } from "../../../../dispatcher/actions";
import PosthogTrackers from "../../../../PosthogTrackers";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler";
type RoomListSearchProps = { type RoomListSearchProps = {
/** /**
@@ -37,53 +23,10 @@ type RoomListSearchProps = {
* The `Explore` button is displayed only in the Home meta space and when UIComponent.ExploreRooms is enabled. * The `Explore` button is displayed only in the Home meta space and when UIComponent.ExploreRooms is enabled.
*/ */
export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Element { export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Element {
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms); const vm = useCreateAutoDisposedViewModel(() => new RoomListSearchViewModel({ activeSpace }));
// We only display the dial button if the user is can make PSTN calls useEffect(() => {
const displayDialButton = useTypedEventEmitterState( vm.setActiveSpace(activeSpace);
LegacyCallHandler.instance, }, [activeSpace, vm]);
LegacyCallHandlerEvent.ProtocolSupport,
() => LegacyCallHandler.instance.getSupportsPstnProtocol(),
);
return ( return <RoomListSearchView vm={vm} />;
<Flex className="mx_RoomListSearch" role="search" gap="var(--cpd-space-2x)" align="center">
<Button
className="mx_RoomListSearch_search"
kind="secondary"
size="sm"
Icon={SearchIcon}
onClick={() => defaultDispatcher.fire(Action.OpenSpotlight)}
>
<Flex as="span" justify="space-between">
<span className="mx_RoomListSearch_search_text">{_t("action|search")}</span>
<kbd>{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}</kbd>
</Flex>
</Button>
{displayDialButton && (
<Button
kind="secondary"
size="sm"
Icon={DialPadIcon}
iconOnly={true}
aria-label={_t("left_panel|open_dial_pad")}
onClick={(ev) => {
defaultDispatcher.fire(Action.OpenDialPad);
}}
/>
)}
{displayExploreButton && (
<Button
kind="secondary"
size="sm"
Icon={ExploreIcon}
iconOnly={true}
aria-label={_t("action|explore_rooms")}
onClick={(ev) => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
}}
/>
)}
</Flex>
);
} }

View File

@@ -0,0 +1,117 @@
/*
* Copyright 2025 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 { type MouseEvent } from "react";
import {
BaseViewModel,
type RoomListSearchViewSnapshot,
type RoomListSearchViewModel as RoomListSearchViewModelInterface,
} from "@element-hq/web-shared-components";
import { IS_MAC, Key } from "../../Keyboard";
import { _t } from "../../languageHandler";
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
import { MetaSpace } from "../../stores/spaces";
import { Action } from "../../dispatcher/actions";
import PosthogTrackers from "../../PosthogTrackers";
import defaultDispatcher from "../../dispatcher/dispatcher";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
export interface Props {
/**
* Current active space
* The explore button is only displayed in the Home meta space
*/
activeSpace: string;
}
/**
* ViewModel for the room list search component.
* Manages the state and actions for the search bar, dial pad, and explore buttons.
*/
export class RoomListSearchViewModel
extends BaseViewModel<RoomListSearchViewSnapshot, Props>
implements RoomListSearchViewModelInterface
{
private displayDialButton = false;
/**
* Computes the snapshot based on the current props and PSTN support state.
*/
private static readonly computeSnapshot = (
activeSpace: string,
supportsPstn: boolean,
): RoomListSearchViewSnapshot => {
const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms);
const searchShortcut = IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K";
return {
displayExploreButton,
displayDialButton: supportsPstn,
searchShortcut,
};
};
public constructor(props: Props) {
const supportsPstn = LegacyCallHandler.instance.getSupportsPstnProtocol();
super(props, RoomListSearchViewModel.computeSnapshot(props.activeSpace, supportsPstn));
this.displayDialButton = supportsPstn;
// Listen for changes in PSTN protocol support
this.disposables.trackListener(
LegacyCallHandler.instance,
LegacyCallHandlerEvent.ProtocolSupport,
this.onProtocolSupportChange,
);
}
/**
* Handles changes in protocol support (PSTN).
*/
private readonly onProtocolSupportChange = (): void => {
const supportsPstn = LegacyCallHandler.instance.getSupportsPstnProtocol();
this.displayDialButton = supportsPstn;
this.snapshot.set(RoomListSearchViewModel.computeSnapshot(this.props.activeSpace, supportsPstn));
};
/**
* Handles the search button click event.
* Opens the spotlight search dialog.
*/
public onSearchClick = (): void => {
defaultDispatcher.fire(Action.OpenSpotlight);
};
/**
* Handles the dial pad button click event.
* Opens the dial pad dialog.
*/
public onDialPadClick = (): void => {
defaultDispatcher.fire(Action.OpenDialPad);
};
/**
* Handles the explore button click event.
* Opens the room directory and tracks the interaction.
*/
public onExploreClick = (ev: MouseEvent<HTMLButtonElement>): void => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
};
/**
* Sets the active space and updates the snapshot accordingly.
* @param activeSpace - The new active space ID.
*/
public setActiveSpace(activeSpace: string): void {
if (activeSpace === this.props.activeSpace) return;
this.props.activeSpace = activeSpace;
this.snapshot.set(RoomListSearchViewModel.computeSnapshot(activeSpace, this.displayDialButton));
}
}

View File

@@ -19,7 +19,7 @@ describe("KeyboardLandmarkUtils", () => {
<div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active"> <div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active">
SPACE_BUTTON SPACE_BUTTON
</div> </div>
<div tabIndex={0} className="mx_RoomListSearch_search" data-testid="mx_RoomListSearch_search"> <div tabIndex={0} id="room-list-search-button" data-testid="mx_RoomListSearch_search">
ROOM_SEARCH ROOM_SEARCH
</div> </div>
<div tabIndex={0} className="mx_RoomListItemView" data-testid="mx_RoomListItemView"> <div tabIndex={0} className="mx_RoomListItemView" data-testid="mx_RoomListItemView">
@@ -72,7 +72,7 @@ describe("KeyboardLandmarkUtils", () => {
<div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active"> <div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active">
SPACE_BUTTON SPACE_BUTTON
</div> </div>
<div tabIndex={0} className="mx_RoomListSearch_search" data-testid="mx_RoomListSearch_search"> <div tabIndex={0} id="room-list-search-button" data-testid="mx_RoomListSearch_search">
ROOM_SEARCH ROOM_SEARCH
</div> </div>
<div tabIndex={0} className="mx_RoomListItemView_selected" data-testid="mx_RoomListItemView_selected"> <div tabIndex={0} className="mx_RoomListItemView_selected" data-testid="mx_RoomListItemView_selected">

View File

@@ -6,15 +6,12 @@
*/ */
import React from "react"; import React from "react";
import { render, screen } from "jest-matrix-react"; import { render } from "jest-matrix-react";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { RoomListSearch } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListSearch"; import { RoomListSearch } from "../../../../../../src/components/views/rooms/RoomListPanel/RoomListSearch";
import { MetaSpace } from "../../../../../../src/stores/spaces"; import { MetaSpace } from "../../../../../../src/stores/spaces";
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../../src/dispatcher/actions";
import LegacyCallHandler from "../../../../../../src/LegacyCallHandler"; import LegacyCallHandler from "../../../../../../src/LegacyCallHandler";
jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
@@ -32,79 +29,8 @@ describe("<RoomListSearch />", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false); jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
}); });
it("should display search and explore buttons", () => { it("renders", () => {
const { asFragment } = renderComponent();
// The search and explore buttons should be displayed
expect(screen.getByRole("button", { name: "Search Ctrl K" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Explore rooms" })).toBeInTheDocument();
// The dial button should not be displayed
expect(screen.queryByRole("button", { name: "Open dial pad" })).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should hide the explore button when the active space is not MetaSpace.Home", () => {
const { asFragment } = renderComponent(MetaSpace.VideoRooms); const { asFragment } = renderComponent(MetaSpace.VideoRooms);
// The search button should be displayed but not the explore button
expect(screen.getByRole("button", { name: "Search Ctrl K" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Explore rooms" })).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot(); expect(asFragment()).toMatchSnapshot();
}); });
it("should hide the explore button when UIComponent.ExploreRooms is disabled", () => {
mocked(shouldShowComponent).mockReturnValue(false);
const { asFragment } = renderComponent();
// The search button should be displayed but not the explore button
expect(screen.getByRole("button", { name: "Search Ctrl K" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Explore rooms" })).not.toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should display the dial button when the PTSN protocol is not supported", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
const { asFragment } = renderComponent();
// The dial button should be displayed
expect(screen.getByRole("button", { name: "Open dial pad" })).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should open the spotlight when the search button is clicked", async () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const user = userEvent.setup();
renderComponent();
// Click on the search button
await user.click(screen.getByRole("button", { name: "Search Ctrl K" }));
// The spotlight should be opened
expect(fireSpy).toHaveBeenCalledWith(Action.OpenSpotlight);
});
it("should open the room directory when the explore button is clicked", async () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const user = userEvent.setup();
renderComponent();
// Click on the search button
await user.click(screen.getByRole("button", { name: "Explore rooms" }));
// The spotlight should be opened
expect(fireSpy).toHaveBeenCalledWith(Action.ViewRoomDirectory);
});
it("should open the dial pad when the dial button is clicked", async () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const user = userEvent.setup();
renderComponent();
// Click on the search button
await user.click(screen.getByRole("button", { name: "Open dial pad" }));
// The spotlight should be opened
expect(fireSpy).toHaveBeenCalledWith(Action.OpenDialPad);
});
}); });

View File

@@ -1,16 +1,18 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`<RoomListSearch /> should display search and explore buttons 1`] = ` exports[`<RoomListSearch /> renders 1`] = `
<DocumentFragment> <DocumentFragment>
<div <div
class="_flex_4dswl_9 mx_RoomListSearch" class="_flex_4dswl_9 _view_z7ks9_8"
data-testid="room-list-search"
role="search" role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
> >
<button <button
class="_button_187yx_8 mx_RoomListSearch_search _has-icon_187yx_57" class="_button_187yx_8 _search_z7ks9_16 _has-icon_187yx_57"
data-kind="secondary" data-kind="secondary"
data-size="sm" data-size="sm"
id="room-list-search-button"
role="button" role="button"
tabindex="0" tabindex="0"
> >
@@ -27,206 +29,11 @@ exports[`<RoomListSearch /> should display search and explore buttons 1`] = `
/> />
</svg> </svg>
<span <span
class="_flex_4dswl_9" class="_flex_4dswl_9 _search_container_z7ks9_29"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
> >
<span <span
class="mx_RoomListSearch_search_text" class="_search_text_z7ks9_41"
>
Search
</span>
<kbd>
Ctrl K
</kbd>
</span>
</button>
<button
aria-label="Explore rooms"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 12q0-.424.287-.713A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 12 13m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20m0 0q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4t5.675 2.325T20 12t-2.325 5.675T12 20m1.675-5.85q.15-.075.275-.2t.2-.275l2.925-6.25q.125-.25-.062-.437-.188-.188-.438-.063l-6.25 2.925q-.15.075-.275.2t-.2.275l-2.925 6.25q-.125.25.063.438.186.186.437.062z"
/>
</svg>
</button>
</div>
</DocumentFragment>
`;
exports[`<RoomListSearch /> should display the dial button when the PTSN protocol is not supported 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 mx_RoomListSearch_search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mx_RoomListSearch_search_text"
>
Search
</span>
<kbd>
Ctrl K
</kbd>
</span>
</button>
<button
aria-label="Open dial pad"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 18.6c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8M6.6 2.4c-.99 0-1.8.81-1.8 1.8S5.61 6 6.6 6s1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0 5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0 5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8M17.4 6c.99 0 1.8-.81 1.8-1.8s-.81-1.8-1.8-1.8-1.8.81-1.8 1.8.81 1.8 1.8 1.8M12 13.2c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m5.4 0c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0-5.4c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m-5.4 0c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8m0-5.4c-.99 0-1.8.81-1.8 1.8S11.01 6 12 6s1.8-.81 1.8-1.8-.81-1.8-1.8-1.8"
/>
</svg>
</button>
<button
aria-label="Explore rooms"
class="_button_187yx_8 _has-icon_187yx_57 _icon-only_187yx_50"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 13a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 12q0-.424.287-.713A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713 0 .424-.287.713A.97.97 0 0 1 12 13m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20m0 0q-3.35 0-5.675-2.325T4 12t2.325-5.675T12 4t5.675 2.325T20 12t-2.325 5.675T12 20m1.675-5.85q.15-.075.275-.2t.2-.275l2.925-6.25q.125-.25-.062-.437-.188-.188-.438-.063l-6.25 2.925q-.15.075-.275.2t-.2.275l-2.925 6.25q-.125.25.063.438.186.186.437.062z"
/>
</svg>
</button>
</div>
</DocumentFragment>
`;
exports[`<RoomListSearch /> should hide the explore button when UIComponent.ExploreRooms is disabled 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 mx_RoomListSearch_search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mx_RoomListSearch_search_text"
>
Search
</span>
<kbd>
Ctrl K
</kbd>
</span>
</button>
</div>
</DocumentFragment>
`;
exports[`<RoomListSearch /> should hide the explore button when the active space is not MetaSpace.Home 1`] = `
<DocumentFragment>
<div
class="_flex_4dswl_9 mx_RoomListSearch"
role="search"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
>
<button
class="_button_187yx_8 mx_RoomListSearch_search _has-icon_187yx_57"
data-kind="secondary"
data-size="sm"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
/>
</svg>
<span
class="_flex_4dswl_9"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: space-between; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<span
class="mx_RoomListSearch_search_text"
> >
Search Search
</span> </span>

View File

@@ -0,0 +1,114 @@
/*
* Copyright 2025 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 { mocked } from "jest-mock";
import { RoomListSearchViewModel } from "../../../src/viewmodels/room-list/RoomListSearchViewModel";
import { MetaSpace } from "../../../src/stores/spaces";
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../src/LegacyCallHandler";
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../src/PosthogTrackers", () => ({
trackInteraction: jest.fn(),
}));
describe("RoomListSearchViewModel", () => {
beforeEach(() => {
mocked(shouldShowComponent).mockReturnValue(true);
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
});
afterEach(() => {
jest.restoreAllMocks();
});
describe("snapshot", () => {
it("should show explore button in Home space when UIComponent.ExploreRooms is enabled", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayExploreButton).toBe(true);
});
it("should hide explore button when not in Home space", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.VideoRooms });
expect(vm.getSnapshot().displayExploreButton).toBe(false);
});
it("should hide explore button when UIComponent.ExploreRooms is disabled", () => {
mocked(shouldShowComponent).mockReturnValue(false);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayExploreButton).toBe(false);
});
it("should show dial button when PSTN protocol is supported", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayDialButton).toBe(true);
});
it("should hide dial button when PSTN protocol is not supported", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayDialButton).toBe(false);
});
});
describe("actions", () => {
it("should fire OpenSpotlight action when onSearchClick is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
vm.onSearchClick();
expect(fireSpy).toHaveBeenCalledWith(Action.OpenSpotlight);
});
it("should fire OpenDialPad action when onDialPadClick is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
vm.onDialPadClick();
expect(fireSpy).toHaveBeenCalledWith(Action.OpenDialPad);
});
it("should fire ViewRoomDirectory action and track interaction when onExploreClick is called", () => {
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
const mockEvent = {} as React.MouseEvent<HTMLButtonElement>;
vm.onExploreClick(mockEvent);
expect(fireSpy).toHaveBeenCalledWith(Action.ViewRoomDirectory);
});
});
it("should update snapshot when PSTN protocol support changes", () => {
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
expect(vm.getSnapshot().displayDialButton).toBe(false);
// Simulate PSTN protocol support change
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
LegacyCallHandler.instance.emit(LegacyCallHandlerEvent.ProtocolSupport);
expect(vm.getSnapshot().displayDialButton).toBe(true);
vm.dispose();
});
});