diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--all-buttons-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--all-buttons-linux.png new file mode 100644 index 0000000000..0d7f17d511 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--all-buttons-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--default-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--default-linux.png new file mode 100644 index 0000000000..2f41682551 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--default-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--with-dial-pad-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--with-dial-pad-linux.png new file mode 100644 index 0000000000..0d7f17d511 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--with-dial-pad-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--without-explore-linux.png b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--without-explore-linux.png new file mode 100644 index 0000000000..e3e14ccfb4 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-list-roomlistsearchview--without-explore-linux.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index a806971a3f..849634e9da 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -19,6 +19,7 @@ export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; export * from "./rich-list/RichItem"; export * from "./rich-list/RichList"; +export * from "./room-list/RoomListSearchView"; export * from "./utils/Box"; export * from "./utils/Flex"; diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.module.css b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.module.css new file mode 100644 index 0000000000..9b7097373c --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.module.css @@ -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; +} diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.stories.tsx b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.stories.tsx new file mode 100644 index 0000000000..66f5af461c --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.stories.tsx @@ -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 ; +}; + +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; + +const Template: StoryFn = (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", +}; diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.test.tsx b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.test.tsx new file mode 100644 index 0000000000..1a256d63b1 --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.test.tsx @@ -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(); + expect(container).toMatchSnapshot(); + }); + + it("renders with dial pad button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders without explore button", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders with all buttons visible", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); + + describe("User interactions", () => { + const onSearchClick = jest.fn(); + const onDialPadClick = jest.fn(); + const onExploreClick = jest.fn(); + + class TestViewModel extends MockViewModel 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(); + + 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(); + + 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(); + + await user.click(screen.getByRole("button", { name: "Explore rooms" })); + expect(onExploreClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.tsx b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.tsx new file mode 100644 index 0000000000..0c652328ec --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/RoomListSearchView.tsx @@ -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; + /** + * Handles the click event on the dial pad button. + */ + onDialPadClick: MouseEventHandler; + /** + * Handles the click event on the explore button. + */ + onExploreClick: MouseEventHandler; +} + +/** + * The view model for the room list search component. + */ +export type RoomListSearchViewModel = ViewModel & 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 + * + * ``` + */ +export function RoomListSearchView({ vm }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + const { displayExploreButton, displayDialButton, searchShortcut } = useViewModel(vm); + + return ( + + + {displayDialButton && ( + + + + +`; + +exports[`RoomListSearchView Storybook snapshots renders with all buttons visible 1`] = ` +
+ +
+`; + +exports[`RoomListSearchView Storybook snapshots renders with dial pad button 1`] = ` +
+ +
+`; + +exports[`RoomListSearchView Storybook snapshots renders without explore button 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/room-list/RoomListSearchView/index.ts b/packages/shared-components/src/room-list/RoomListSearchView/index.ts new file mode 100644 index 0000000000..a750dca7db --- /dev/null +++ b/packages/shared-components/src/room-list/RoomListSearchView/index.ts @@ -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"; diff --git a/playwright/e2e/accessibility/keyboard-navigation.spec.ts b/playwright/e2e/accessibility/keyboard-navigation.spec.ts index 6635a01d57..8ba6cc3a92 100644 --- a/playwright/e2e/accessibility/keyboard-navigation.spec.ts +++ b/playwright/e2e/accessibility/keyboard-navigation.spec.ts @@ -29,7 +29,7 @@ test.describe("Landmark navigation tests", () => { // Pressing Control+F6 again will focus room search 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 await page.keyboard.press("ControlOrMeta+F6"); @@ -44,7 +44,7 @@ test.describe("Landmark navigation tests", () => { await expect(page.locator(".mx_HomePage")).toBeFocused(); 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 expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); @@ -75,7 +75,7 @@ test.describe("Landmark navigation tests", () => { // Pressing Control+F6 again will focus room search 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 await page.keyboard.press("ControlOrMeta+F6"); @@ -97,7 +97,7 @@ test.describe("Landmark navigation tests", () => { await expect(page.locator(".mx_RoomListItemView_selected")).toBeFocused(); 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 expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); @@ -131,7 +131,7 @@ test.describe("Landmark navigation tests", () => { // Pressing Control+F6 again will focus room search 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 await page.keyboard.press("ControlOrMeta+F6"); @@ -153,7 +153,7 @@ test.describe("Landmark navigation tests", () => { await expect(page.locator(".mx_RoomListItemView")).toBeFocused(); 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 expect(page.locator(".mx_SpaceButton_active")).toBeFocused(); diff --git a/playwright/e2e/voip/pstn.spec.ts b/playwright/e2e/voip/pstn.spec.ts index 0275b88067..4241db6522 100644 --- a/playwright/e2e/voip/pstn.spec.ts +++ b/playwright/e2e/voip/pstn.spec.ts @@ -24,7 +24,7 @@ test.describe("PSTN", () => { await toasts.rejectToast("Notifications"); 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 expect(page.locator(".mx_Dialog")).toMatchScreenshot("dialpad.png"); }); diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 21e39a1584..fe527fdc5b 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -273,7 +273,6 @@ @import "./views/rooms/RoomListPanel/_RoomListItemView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPanel.pcss"; @import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss"; -@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss"; @import "./views/rooms/RoomListPanel/_RoomListSkeleton.pcss"; @import "./views/rooms/_AppsDrawer.pcss"; diff --git a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss b/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss deleted file mode 100644 index 472badc3ad..0000000000 --- a/res/css/views/rooms/RoomListPanel/_RoomListSearch.pcss +++ /dev/null @@ -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; - } - } - } -} diff --git a/src/accessibility/LandmarkNavigation.ts b/src/accessibility/LandmarkNavigation.ts index 404e01e99c..632c7ec613 100644 --- a/src/accessibility/LandmarkNavigation.ts +++ b/src/accessibility/LandmarkNavigation.ts @@ -75,7 +75,7 @@ const landmarkToDomElementMap: Record HTMLElement | null | undef [Landmark.ROOM_SEARCH]: () => SettingsStore.getValue("feature_new_room_list") - ? document.querySelector(".mx_RoomListSearch_search") + ? document.querySelector("#room-list-search-button") : document.querySelector(".mx_RoomSearch"), [Landmark.ROOM_LIST]: () => SettingsStore.getValue("feature_new_room_list") diff --git a/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx b/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx index f1c3c2e66d..0354831a9e 100644 --- a/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx +++ b/src/components/views/rooms/RoomListPanel/RoomListSearch.tsx @@ -5,24 +5,10 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } 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 { Flex } from "@element-hq/web-shared-components"; +import React, { useEffect, type JSX } from "react"; +import { RoomListSearchView, useCreateAutoDisposedViewModel } 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 { useTypedEventEmitterState } from "../../../../hooks/useEventEmitter"; -import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../../LegacyCallHandler"; +import { RoomListSearchViewModel } from "../../../../viewmodels/room-list/RoomListSearchViewModel"; 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. */ export function RoomListSearch({ activeSpace }: RoomListSearchProps): JSX.Element { - const displayExploreButton = activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms); - // We only display the dial button if the user is can make PSTN calls - const displayDialButton = useTypedEventEmitterState( - LegacyCallHandler.instance, - LegacyCallHandlerEvent.ProtocolSupport, - () => LegacyCallHandler.instance.getSupportsPstnProtocol(), - ); + const vm = useCreateAutoDisposedViewModel(() => new RoomListSearchViewModel({ activeSpace })); + useEffect(() => { + vm.setActiveSpace(activeSpace); + }, [activeSpace, vm]); - return ( - - - {displayDialButton && ( - - - - -`; - -exports[` should display the dial button when the PTSN protocol is not supported 1`] = ` - - - -`; - -exports[` should hide the explore button when UIComponent.ExploreRooms is disabled 1`] = ` - - - -`; - -exports[` should hide the explore button when the active space is not MetaSpace.Home 1`] = ` - -