diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index de4df133d0..47ec61aecf 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -781,10 +781,10 @@ test.describe("Timeline", () => { await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); - await expect(page.locator(".mx_SearchBar")).toMatchScreenshot("search-bar-on-timeline.png"); + await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill("Message"); + await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter"); - await page.locator(".mx_SearchBar_input").getByRole("textbox").fill("Message"); - await page.locator(".mx_SearchBar_input").getByRole("textbox").press("Enter"); + await expect(page.locator(".mx_RoomSearchAuxPanel")).toMatchScreenshot("search-aux-panel.png"); for (const locator of await page .locator(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight") @@ -822,8 +822,8 @@ test.describe("Timeline", () => { await page.locator(".mx_LegacyRoomHeader").getByRole("button", { name: "Search" }).click(); // Search the string to display both the message and TextualEvent on search results panel - await page.locator(".mx_SearchBar").getByRole("textbox").fill(stringToSearch); - await page.locator(".mx_SearchBar").getByRole("textbox").press("Enter"); + await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").fill(stringToSearch); + await page.locator(".mx_RoomSummaryCard_search").getByRole("searchbox").press("Enter"); // On search results panel const resultsPanel = page.locator(".mx_RoomView_searchResultsPanel"); diff --git a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png index 27d51ff123..4211f82b7a 100644 Binary files a/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png and b/playwright/snapshots/crypto/crypto.spec.ts/RoomSummaryCard-with-verified-e2ee-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png index c8b8dba45b..0443552daa 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-with-user-pill-linux.png differ diff --git a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png index 852cb85518..dd6303036b 100644 Binary files a/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png and b/playwright/snapshots/invite/invite-dialog.spec.ts/invite-dialog-room-without-user-linux.png differ diff --git a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png index bb8240913e..7fbf4be835 100644 Binary files a/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png and b/playwright/snapshots/right-panel/right-panel.spec.ts/with-name-and-address-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png index fcf3acd7e8..0d0cc0eec3 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/highlighted-search-results-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png new file mode 100644 index 0000000000..fe96a9e6be Binary files /dev/null and b/playwright/snapshots/timeline/timeline.spec.ts/search-aux-panel-linux.png differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png deleted file mode 100644 index 64d44a9778..0000000000 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-bar-on-timeline-linux.png and /dev/null differ diff --git a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png index 4c553cfdaf..89ce0a9f2d 100644 Binary files a/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png and b/playwright/snapshots/timeline/timeline.spec.ts/search-results-with-TextualEvent-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 1d9fc87575..a454789efc 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -177,9 +177,9 @@ a:visited { color: $accent-alt; } -input[type="text"], -input[type="search"], -input[type="password"] { +:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="text"], +:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="search"], +:not(.mx_no_textinput):not(.mx_textinput):not(.mx_Field) > input[type="password"] { padding: 9px; font: var(--cpd-font-body-md-semibold); font-weight: var(--cpd-font-weight-semibold); diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a7c79bfbf2..043e7b7658 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -306,10 +306,10 @@ @import "./views/rooms/_RoomListHeader.pcss"; @import "./views/rooms/_RoomPreviewBar.pcss"; @import "./views/rooms/_RoomPreviewCard.pcss"; +@import "./views/rooms/_RoomSearchAuxPanel.pcss"; @import "./views/rooms/_RoomSublist.pcss"; @import "./views/rooms/_RoomTile.pcss"; @import "./views/rooms/_RoomUpgradeWarningBar.pcss"; -@import "./views/rooms/_SearchBar.pcss"; @import "./views/rooms/_SendMessageComposer.pcss"; @import "./views/rooms/_SpaceScopeHeader.pcss"; @import "./views/rooms/_Stickers.pcss"; diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index 4c3ff2f888..e3ed7b261b 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -238,25 +238,12 @@ limitations under the License. padding: 15px 12px; } -.mx_RoomSummaryCard_search input { - /* Overriding very broad CSS rules */ - border: 0 !important; - margin: 0 !important; - cursor: pointer; -} +.mx_RoomSummaryCard_search { + flex-grow: 1; + min-width: 0; -.mx_RoomSummaryCard_searchBtn { - background: var(--cpd-color-bg-canvas-default); - color: var(--cpd-color-icon-primary); - border: 1px solid var(--cpd-color-gray-400); - border-radius: 50%; - width: 36px; - height: 36px; - padding: var(--cpd-space-2x); - cursor: pointer; - - &:hover { - background: var(--cpd-color-bg-subtle-primary); + input[type="search"]::-webkit-search-cancel-button { + display: unset; /* override _common.pcss which inhibits this */ } } diff --git a/res/css/views/rooms/_RoomSearchAuxPanel.pcss b/res/css/views/rooms/_RoomSearchAuxPanel.pcss new file mode 100644 index 0000000000..a47616a685 --- /dev/null +++ b/res/css/views/rooms/_RoomSearchAuxPanel.pcss @@ -0,0 +1,72 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomSearchAuxPanel { + /* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */ + min-height: 84px; + display: flex; + align-items: center; + border-color: var(--cpd-color-bg-canvas-default); + border-style: solid; + border-width: 1px 0; + padding: var(--cpd-space-3x); + box-sizing: border-box; + gap: var(--cpd-space-2x); + + .mx_RoomSearchAuxPanel_summary { + flex-grow: 1; + display: inherit; /* flex */ + gap: var(--cpd-space-2x); + align-items: center; + overflow: hidden; + + > svg { + padding: var(--cpd-space-2x); + border-radius: var(--cpd-space-2x); + background-color: var(--cpd-color-bg-subtle-secondary); + color: var(--cpd-color-icon-secondary); + flex-shrink: 0; + } + + .mx_RoomSearchAuxPanel_summary_text { + display: flex; + flex-direction: column; + font-size: $font-15px; + line-height: $font-22px; + overflow: hidden; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + + .mx_SearchWarning { + display: contents; + font-size: $font-13px; + line-height: $font-20px; + color: var(--cpd-color-text-secondary); + } + } + + .mx_RoomSearchAuxPanel_buttons { + display: inherit; /* flex */ + gap: var(--cpd-space-6x); + align-items: center; + flex-shrink: 0; + } +} diff --git a/res/css/views/rooms/_SearchBar.pcss b/res/css/views/rooms/_SearchBar.pcss deleted file mode 100644 index ca999c7bea..0000000000 --- a/res/css/views/rooms/_SearchBar.pcss +++ /dev/null @@ -1,83 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_SearchBar { - /* use `min-height` rather than height, to allow room for the text to wrap if the window is narrow */ - min-height: 56px; - display: flex; - align-items: center; - border-bottom: 1px solid $primary-hairline-color; - - .mx_SearchBar_input { - --size-button-search: 37px; /* size of the search button inside `input` element */ - - /* border: 1px solid $input-border-color; */ - /* font-size: $font-15px; */ - flex: 1 1 0; - margin-left: 22px; - - /* do not allow the input element to shrink below the width needed for the placeholder 'Search…' - and the search button */ - min-width: calc(7em + var(--size-button-search)); - - input { - box-sizing: border-box; /* include padding value into width calculation */ - } - } - - .mx_SearchBar_searchButton { - cursor: pointer; - width: var(--size-button-search); - height: var(--size-button-search); - background-color: $accent; - mask: url("$(res)/img/feather-customised/search-input.svg"); - mask-repeat: no-repeat; - mask-position: center; - } - - .mx_SearchBar_buttons { - display: inherit; /* flex */ - min-width: 0; /* have the close button displayed even on a very narrow timeline */ - } - - .mx_SearchBar_button { - border: 0; - margin: 0 0 0 22px; - padding: 5px; - font-size: $font-15px; - cursor: pointer; - color: $primary-content; - border-bottom: 2px solid $accent; - font-weight: var(--cpd-font-weight-semibold); - word-break: break-all; /* prevent the input area and cancel button from being overlapped by BaseCard */ - } - - .mx_SearchBar_unselected { - color: $input-darker-fg-color; - border-color: transparent; - } - - .mx_SearchBar_cancel { - background-color: $alert; - mask: url("$(res)/img/cancel.svg"); - mask-repeat: no-repeat; - mask-position: center; - mask-size: 14px; - padding: 9px; - margin: 0 12px 0 3px; - cursor: pointer; - } -} diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 68d600ec1d..638011e9dc 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ChangeEvent } from "react"; import { Room, RoomState, RoomStateEvent, RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -57,7 +57,8 @@ interface RoomlessProps extends BaseProps { interface RoomProps extends BaseProps { room: Room; permalinkCreator: RoomPermalinkCreator; - onSearchClick?: () => void; + onSearchChange?: (e: ChangeEvent) => void; + onSearchCancel?: () => void; } type Props = XOR; @@ -296,7 +297,9 @@ export default class RightPanel extends React.Component { onClose={this.onClose} // whenever RightPanel is passed a room it is passed a permalinkcreator permalinkCreator={this.props.permalinkCreator!} - onSearchClick={this.props.onSearchClick} + onSearchChange={this.props.onSearchChange} + onSearchCancel={this.props.onSearchCancel} + focusRoomSearch={cardState?.focusRoomSearch} /> ); } diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index 7e08057211..116c822004 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -48,6 +48,7 @@ if (DEBUG) { interface Props { term: string; scope: SearchScope; + inProgress: boolean; promise: Promise; abortController?: AbortController; resizeNotifier: ResizeNotifier; @@ -58,10 +59,9 @@ interface Props { // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? export const RoomSearchView = forwardRef( - ({ term, scope, promise, abortController, resizeNotifier, className, onUpdate }: Props, ref) => { + ({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => { const client = useContext(MatrixClientContext); const roomContext = useContext(RoomContext); - const [inProgress, setInProgress] = useState(true); const [highlights, setHighlights] = useState(null); const [results, setResults] = useState(null); const aborted = useRef(false); @@ -78,73 +78,71 @@ export const RoomSearchView = forwardRef( const handleSearchResult = useCallback( (searchPromise: Promise): Promise => { - setInProgress(true); + onUpdate(true, null); - return searchPromise - .then( - async (results): Promise => { - debuglog("search complete"); - if (aborted.current) { - logger.error("Discarding stale search results"); - return false; - } + return searchPromise.then( + async (results): Promise => { + debuglog("search complete"); + if (aborted.current) { + logger.error("Discarding stale search results"); + return false; + } - // postgres on synapse returns us precise details of the strings - // which actually got matched for highlighting. - // - // In either case, we want to highlight the literal search term - // whether it was used by the search engine or not. + // postgres on synapse returns us precise details of the strings + // which actually got matched for highlighting. + // + // In either case, we want to highlight the literal search term + // whether it was used by the search engine or not. - let highlights = results.highlights; - if (!highlights.includes(term)) { - highlights = highlights.concat(term); - } + let highlights = results.highlights; + if (!highlights.includes(term)) { + highlights = highlights.concat(term); + } - // For overlapping highlights, - // favour longer (more specific) terms first - highlights = highlights.sort(function (a, b) { - return b.length - a.length; - }); + // For overlapping highlights, + // favour longer (more specific) terms first + highlights = highlights.sort(function (a, b) { + return b.length - a.length; + }); - for (const result of results.results) { - for (const event of result.context.getTimeline()) { - const bundledRelationship = - event.getServerAggregatedRelation( - THREAD_RELATION_TYPE.name, - ); - if (!bundledRelationship || event.getThread()) continue; - const room = client.getRoom(event.getRoomId()); - const thread = room?.findThreadForEvent(event); - if (thread) { - event.setThread(thread); - } else { - room?.createThread(event.getId()!, event, [], true); - } + for (const result of results.results) { + for (const event of result.context.getTimeline()) { + const bundledRelationship = + event.getServerAggregatedRelation( + THREAD_RELATION_TYPE.name, + ); + if (!bundledRelationship || event.getThread()) continue; + const room = client.getRoom(event.getRoomId()); + const thread = room?.findThreadForEvent(event); + if (thread) { + event.setThread(thread); + } else { + room?.createThread(event.getId()!, event, [], true); } } + } - setHighlights(highlights); - setResults({ ...results }); // copy to force a refresh + setHighlights(highlights); + setResults({ ...results }); // copy to force a refresh + onUpdate(false, results); + return false; + }, + (error) => { + if (aborted.current) { + logger.error("Discarding stale search results"); return false; - }, - (error) => { - if (aborted.current) { - logger.error("Discarding stale search results"); - return false; - } - logger.error("Search failed", error); - Modal.createDialog(ErrorDialog, { - title: _t("error_dialog|search_failed|title"), - description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"), - }); - return false; - }, - ) - .finally(() => { - setInProgress(false); - }); + } + logger.error("Search failed", error); + Modal.createDialog(ErrorDialog, { + title: _t("error_dialog|search_failed|title"), + description: error?.message ?? _t("error_dialog|search_failed|server_unavailable"), + }); + onUpdate(false, null); + return false; + }, + ); }, - [client, term], + [client, term, onUpdate], ); // Mount & unmount effect diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 3f9370da80..74c4f91627 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -17,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from "react"; +import React, { ChangeEvent, createRef, ReactElement, ReactNode, RefObject, useContext } from "react"; import classNames from "classnames"; import { IRecommendedVersion, @@ -41,7 +41,7 @@ import { import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; -import { throttle } from "lodash"; +import { debounce, throttle } from "lodash"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle"; @@ -70,7 +70,6 @@ import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; import RoomPreviewCard from "../views/rooms/RoomPreviewCard"; -import SearchBar from "../views/rooms/SearchBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader"; @@ -133,6 +132,7 @@ import { CancelAskToJoinPayload } from "../../dispatcher/payloads/CancelAskToJoi import { SubmitAskToJoinPayload } from "../../dispatcher/payloads/SubmitAskToJoinPayload"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { onView3pidInvite } from "../../stores/right-panel/action-handlers"; +import RoomSearchAuxPanel from "../views/rooms/RoomSearchAuxPanel"; const DEBUG = false; const PREVENT_MULTIPLE_JITSI_WITHIN = 30_000; @@ -1196,9 +1196,6 @@ export class RoomView extends React.Component { ); } break; - case Action.FocusMessageSearch: - this.onSearchClick(); - break; case "local_room_event": this.onLocalRoomEvent(payload.roomId); @@ -1725,13 +1722,14 @@ export class RoomView extends React.Component { }); } - private onSearch = (term: string, scope: SearchScope): void => { + private onSearch = (term: string, scope = SearchScope.Room): void => { const roomId = scope === SearchScope.Room ? this.getRoomId() : undefined; debuglog("sending search request"); const abortController = new AbortController(); const promise = eventSearch(this.context.client!, term, roomId, abortController.signal); this.setState({ + timelineRenderingType: TimelineRenderingType.Search, search: { // make sure that we don't end up showing results from // an aborted search by keeping a unique id. @@ -1745,6 +1743,10 @@ export class RoomView extends React.Component { }); }; + private onSearchScopeChange = (scope: SearchScope): void => { + this.onSearch(this.state.search?.term ?? "", scope); + }; + private onSearchUpdate = (inProgress: boolean, searchResults: ISearchResults | null): void => { this.setState({ search: { @@ -1839,15 +1841,14 @@ export class RoomView extends React.Component { }; private onSearchClick = (): void => { - if (this.state.timelineRenderingType === TimelineRenderingType.Search) { - this.onCancelSearchClick(); - } else { - this.setState({ - timelineRenderingType: TimelineRenderingType.Search, - }); - } + dis.fire(Action.FocusMessageSearch); }; + private onSearchChange = debounce((e: ChangeEvent): void => { + const term = (e.target as HTMLInputElement).value; + this.onSearch(term); + }, 300); + private onCancelSearchClick = (): Promise => { return new Promise((resolve) => { this.setState( @@ -2328,10 +2329,10 @@ export class RoomView extends React.Component { let previewBar; if (this.state.timelineRenderingType === TimelineRenderingType.Search) { aux = ( - ); @@ -2438,6 +2439,7 @@ export class RoomView extends React.Component { scope={this.state.search.scope} promise={this.state.search.promise} abortController={this.state.search.abortController} + inProgress={!!this.state.search.inProgress} resizeNotifier={this.props.resizeNotifier} className={this.messagePanelClassNames} onUpdate={this.onSearchUpdate} @@ -2507,7 +2509,8 @@ export class RoomView extends React.Component { resizeNotifier={this.props.resizeNotifier} permalinkCreator={this.permalinkCreator} e2eStatus={this.state.e2eStatus} - onSearchClick={this.onSearchClick} + onSearchChange={this.onSearchChange} + onSearchCancel={this.onCancelSearchClick} /> ) : undefined; diff --git a/src/components/structures/WaitingForThirdPartyRoomView.tsx b/src/components/structures/WaitingForThirdPartyRoomView.tsx index 1b61abb3bb..7bd176c612 100644 --- a/src/components/structures/WaitingForThirdPartyRoomView.tsx +++ b/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -55,7 +55,6 @@ export const WaitingForThirdPartyRoomView: React.FC = ({ roomView, resize ; if (EventIndexPeg.get()) return <>; @@ -121,7 +122,7 @@ export default function SearchWarning({ isRoomEncrypted, kind }: IProps): JSX.El return (
- {logo} + {showLogo ? logo : null} {text}
); diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index a8c5a85478..2a1359e89d 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -14,11 +14,19 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { SyntheticEvent, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import React, { + ChangeEvent, + SyntheticEvent, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import classNames from "classnames"; import { MenuItem, - Tooltip, Separator, ToggleMenuItem, Text, @@ -26,8 +34,9 @@ import { Heading, IconButton, Link, + Search, + Form, } from "@vector-im/compound-web"; -import { Icon as SearchIcon } from "@vector-im/compound-design-tokens/icons/search.svg"; import { Icon as FavouriteIcon } from "@vector-im/compound-design-tokens/icons/favourite.svg"; import { Icon as UserAddIcon } from "@vector-im/compound-design-tokens/icons/user-add.svg"; import { Icon as UserProfileSolidIcon } from "@vector-im/compound-design-tokens/icons/user-profile-solid.svg"; @@ -63,7 +72,7 @@ import WidgetAvatar from "../avatars/WidgetAvatar"; import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { E2EStatus } from "../../../utils/ShieldUtils"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import RoomContext from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { UIComponent, UIFeature } from "../../../settings/UIFeature"; import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; @@ -89,12 +98,18 @@ import { useTopic } from "../../../hooks/room/useTopic"; import { Linkify, topicToHtml } from "../../../HtmlUtils"; import { Box } from "../../utils/Box"; import { onRoomTopicLinkClick } from "../elements/RoomTopic"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { Key } from "../../../Keyboard"; +import { useTransition } from "../../../hooks/useTransition"; interface IProps { room: Room; permalinkCreator: RoomPermalinkCreator; onClose(): void; - onSearchClick?: () => void; + onSearchChange?: (e: ChangeEvent) => void; + onSearchCancel?: () => void; + focusRoomSearch?: boolean; } interface IAppsSectionProps { @@ -364,7 +379,14 @@ const RoomTopic: React.FC> = ({ room }): JSX.Element | null ); }; -const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, onSearchClick }) => { +const RoomSummaryCard: React.FC = ({ + room, + permalinkCreator, + onClose, + onSearchChange, + onSearchCancel, + focusRoomSearch, +}) => { const cli = useContext(MatrixClientContext); const onShareRoomClick = (): void => { @@ -419,6 +441,26 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, on } }, [room, directRoomsList]); + const searchInputRef = useRef(null); + useDispatcher(defaultDispatcher, (payload) => { + if (payload.action === Action.FocusMessageSearch) { + searchInputRef.current?.focus(); + } + }); + // Clear the search field when the user leaves the search view + useTransition( + (prevTimelineRenderingType) => { + if ( + prevTimelineRenderingType === TimelineRenderingType.Search && + roomContext.timelineRenderingType !== TimelineRenderingType.Search && + searchInputRef.current + ) { + searchInputRef.current.value = ""; + } + }, + [roomContext.timelineRenderingType], + ); + const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || ""; const header = (
@@ -498,18 +540,24 @@ const RoomSummaryCard: React.FC = ({ room, permalinkCreator, onClose, on align="center" justify="space-between" > - - - + {onSearchChange && ( + e.preventDefault()}> + { + if (searchInputRef.current && e.key === Key.ESCAPE) { + searchInputRef.current.value = ""; + onSearchCancel?.(); + } + }} + /> + + )} = ({ searchInfo, isRoomEncrypted, onSearchScopeChange, onCancelClick }) => { + const scope = searchInfo?.scope ?? SearchScope.Room; + + return ( + <> + +
+
+ +
+ {searchInfo + ? _t( + "room|search|summary", + { count: searchInfo.count ?? 0 }, + { query: () => {searchInfo.term} }, + ) + : undefined} + +
+
+
+ + onSearchScopeChange(scope === SearchScope.Room ? SearchScope.All : SearchScope.Room) + } + kind="primary" + > + {scope === SearchScope.All + ? _t("room|search|this_room_button") + : _t("room|search|all_rooms_button")} + + + + +
+
+ + ); +}; + +export default RoomSearchAuxPanel; diff --git a/src/components/views/rooms/SearchBar.tsx b/src/components/views/rooms/SearchBar.tsx deleted file mode 100644 index 19b076f11d..0000000000 --- a/src/components/views/rooms/SearchBar.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React, { createRef, RefObject } from "react"; -import classNames from "classnames"; - -import AccessibleButton from "../elements/AccessibleButton"; -import { _t } from "../../../languageHandler"; -import { PosthogScreenTracker } from "../../../PosthogTrackers"; -import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; -import SearchWarning, { WarningKind } from "../elements/SearchWarning"; -import { SearchScope } from "../../../Searching"; - -interface IProps { - onCancelClick: () => void; - onSearch: (query: string, scope: SearchScope) => void; - searchInProgress?: boolean; - isRoomEncrypted?: boolean; -} - -interface IState { - scope: SearchScope; -} - -export default class SearchBar extends React.Component { - private searchTerm: RefObject = createRef(); - - public constructor(props: IProps) { - super(props); - this.state = { - scope: SearchScope.Room, - }; - } - - private onThisRoomClick = (): void => { - this.setState({ scope: SearchScope.Room }, () => this.searchIfQuery()); - }; - - private onAllRoomsClick = (): void => { - this.setState({ scope: SearchScope.All }, () => this.searchIfQuery()); - }; - - private onSearchChange = (e: React.KeyboardEvent): void => { - const action = getKeyBindingsManager().getAccessibilityAction(e); - switch (action) { - case KeyBindingAction.Enter: - this.onSearch(); - break; - case KeyBindingAction.Escape: - this.props.onCancelClick(); - break; - } - }; - - private searchIfQuery(): void { - if (this.searchTerm.current?.value) { - this.onSearch(); - } - } - - private onSearch = (): void => { - if (!this.searchTerm.current?.value.trim()) return; - this.props.onSearch(this.searchTerm.current.value, this.state.scope); - }; - - public render(): React.ReactNode { - const searchButtonClasses = classNames("mx_SearchBar_searchButton", { - mx_SearchBar_searching: this.props.searchInProgress, - }); - const thisRoomClasses = classNames("mx_SearchBar_button", { - mx_SearchBar_unselected: this.state.scope !== SearchScope.Room, - }); - const allRoomsClasses = classNames("mx_SearchBar_button", { - mx_SearchBar_unselected: this.state.scope !== SearchScope.All, - }); - - return ( - <> - -
-
- - {_t("room|search|this_room")} - - - {_t("room|search|all_rooms")} - -
-
- - -
- -
- - - ); - } -} diff --git a/src/hooks/useTransition.ts b/src/hooks/useTransition.ts new file mode 100644 index 0000000000..cab55e9869 --- /dev/null +++ b/src/hooks/useTransition.ts @@ -0,0 +1,35 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Based on https://stackoverflow.com/a/61680184 + +import { DependencyList, useEffect, useRef } from "react"; + +export const useTransition = (callback: (...params: D) => void, deps: D): void => { + const func = useRef<(...params: D) => void>(callback); + + useEffect(() => { + func.current = callback; + }, [callback]); + + const args = useRef(null); + + useEffect(() => { + if (args.current !== null) func.current(...args.current); + args.current = deps; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6d0f97130d..b8d3cf113f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2030,14 +2030,16 @@ "rejecting": "Rejecting invite…", "rejoin_button": "Re-join", "search": { - "all_rooms": "All Rooms", "all_rooms_button": "Search all rooms", - "field_placeholder": "Search…", + "placeholder": "Search messages…", "result_count": { "one": "(~%(count)s result)", "other": "(~%(count)s results)" }, - "this_room": "This Room", + "summary": { + "one": "1 result found for “”", + "other": "%(count)s results found for “”" + }, "this_room_button": "Search this room" }, "show_labs_settings": "Show Labs settings", diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 3c0084ece7..0cffdb423c 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -77,10 +77,19 @@ export default class RightPanelStore extends ReadyWatchingStore { } protected onDispatcherAction(payload: ActionPayload): void { - if (payload.action !== Action.ActiveRoomChanged) return; + switch (payload.action) { + case Action.ActiveRoomChanged: { + const changePayload = payload; + this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId); + break; + } - const changePayload = payload; - this.handleViewedRoomChange(changePayload.oldRoomId, changePayload.newRoomId); + case Action.FocusMessageSearch: { + if (this.currentCard.phase !== RightPanelPhases.RoomSummary) { + this.setCard({ phase: RightPanelPhases.RoomSummary, state: { focusRoomSearch: true } }); + } + } + } } // Getters diff --git a/src/stores/right-panel/RightPanelStoreIPanelState.ts b/src/stores/right-panel/RightPanelStoreIPanelState.ts index 9993fadb3c..eeb9862959 100644 --- a/src/stores/right-panel/RightPanelStoreIPanelState.ts +++ b/src/stores/right-panel/RightPanelStoreIPanelState.ts @@ -32,6 +32,8 @@ export interface IRightPanelCardState { initialEvent?: MatrixEvent; isInitialEventHighlighted?: boolean; initialEventScrollIntoView?: boolean; + // room summary + focusRoomSearch?: boolean; } export interface IRightPanelCardStateStored { diff --git a/test/components/structures/RoomSearchView-test.tsx b/test/components/structures/RoomSearchView-test.tsx index 7ba39283b6..497981ba28 100644 --- a/test/components/structures/RoomSearchView-test.tsx +++ b/test/components/structures/RoomSearchView-test.tsx @@ -65,6 +65,7 @@ describe("", () => { render( ", () => { render( ({ @@ -142,6 +144,7 @@ describe("", () => { render( ({ @@ -234,10 +237,30 @@ describe("", () => { ], next_batch: undefined, }); + const onUpdate = jest.fn(); - render( + const { rerender } = render( + , + ); + + await screen.findByRole("progressbar"); + await screen.findByText("Potato"); + expect(onUpdate).toHaveBeenCalledWith(false, expect.objectContaining({})); + + rerender( + + ", () => { , ); - await screen.findByRole("progressbar"); - await screen.findByText("Potato"); expect(screen.queryByRole("progressbar")).toBeFalsy(); }); @@ -259,6 +280,7 @@ describe("", () => { const { unmount } = render( ", () => { const { unmount } = render( ", () => { render( ", () => { render( ", () => { render( ({ diff --git a/test/components/views/elements/SearchWarning-test.tsx b/test/components/views/elements/SearchWarning-test.tsx new file mode 100644 index 0000000000..870254d511 --- /dev/null +++ b/test/components/views/elements/SearchWarning-test.tsx @@ -0,0 +1,51 @@ +/* +Copyright 2024 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { render } from "@testing-library/react"; +import React from "react"; + +import SdkConfig from "../../../../src/SdkConfig"; +import SearchWarning, { WarningKind } from "../../../../src/components/views/elements/SearchWarning"; + +describe("", () => { + describe("with desktop builds available", () => { + beforeEach(() => { + SdkConfig.put({ + brand: "Element", + desktop_builds: { + available: true, + logo: "https://logo", + url: "https://url", + }, + }); + }); + + it("renders with a logo by default", () => { + const { asFragment, queryByRole } = render( + , + ); + expect(queryByRole("img")).toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("renders without a logo when showLogo=false", () => { + const { asFragment, queryByRole } = render( + , + ); + + expect(queryByRole("img")).not.toBeInTheDocument(); + expect(asFragment()).toMatchSnapshot(); + }); + }); +}); diff --git a/test/components/views/elements/__snapshots__/SearchWarning-test.tsx.snap b/test/components/views/elements/__snapshots__/SearchWarning-test.tsx.snap new file mode 100644 index 0000000000..cb9d443bba --- /dev/null +++ b/test/components/views/elements/__snapshots__/SearchWarning-test.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` with desktop builds available renders with a logo by default 1`] = ` + +
+ + + + Use the + + Desktop app + + to search encrypted messages + + +
+
+`; + +exports[` with desktop builds available renders without a logo when showLogo=false 1`] = ` + +
+ + + Use the + + Desktop app + + to search encrypted messages + + +
+
+`; diff --git a/test/components/views/right_panel/RoomSummaryCard-test.tsx b/test/components/views/right_panel/RoomSummaryCard-test.tsx index b4288dc357..f90144a3ba 100644 --- a/test/components/views/right_panel/RoomSummaryCard-test.tsx +++ b/test/components/views/right_panel/RoomSummaryCard-test.tsx @@ -15,10 +15,11 @@ limitations under the License. */ import React from "react"; -import { render, fireEvent, screen } from "@testing-library/react"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react"; import { EventType, MatrixEvent, Room, MatrixClient, JoinRule } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { mocked, MockedObject } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import RoomSummaryCard from "../../../../src/components/views/right_panel/RoomSummaryCard"; @@ -37,6 +38,8 @@ import { _t } from "../../../../src/languageHandler"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { tagRoom } from "../../../../src/utils/room/tagRoom"; import { DefaultTagID } from "../../../../src/stores/room-list/models"; +import { Action } from "../../../../src/dispatcher/actions"; +import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; jest.mock("../../../../src/utils/room/tagRoom"); @@ -141,15 +144,82 @@ describe("", () => { expect(container).toMatchSnapshot(); }); - it("opens the search", async () => { - const onSearchClick = jest.fn(); - const { getByLabelText } = getComponent({ - onSearchClick, + describe("search", () => { + it("has the search field", async () => { + const onSearchChange = jest.fn(); + const { getByPlaceholderText } = getComponent({ + onSearchChange, + }); + expect(getByPlaceholderText("Search messages…")).toBeVisible(); }); - const searchBtn = getByLabelText(_t("action|search")); - fireEvent.click(searchBtn); - expect(onSearchClick).toHaveBeenCalled(); + it("should focus the search field if Action.FocusMessageSearch is fired", async () => { + const onSearchChange = jest.fn(); + const { getByPlaceholderText } = getComponent({ + onSearchChange, + }); + expect(getByPlaceholderText("Search messages…")).not.toHaveFocus(); + defaultDispatcher.fire(Action.FocusMessageSearch); + await waitFor(() => { + expect(getByPlaceholderText("Search messages…")).toHaveFocus(); + }); + }); + + it("should focus the search field if focusRoomSearch=true", () => { + const onSearchChange = jest.fn(); + const { getByPlaceholderText } = getComponent({ + onSearchChange, + focusRoomSearch: true, + }); + expect(getByPlaceholderText("Search messages…")).toHaveFocus(); + }); + + it("should cancel search on escape", () => { + const onSearchChange = jest.fn(); + const onSearchCancel = jest.fn(); + const { getByPlaceholderText } = getComponent({ + onSearchChange, + onSearchCancel, + focusRoomSearch: true, + }); + expect(getByPlaceholderText("Search messages…")).toHaveFocus(); + fireEvent.keyDown(getByPlaceholderText("Search messages…"), { key: "Escape" }); + expect(onSearchCancel).toHaveBeenCalled(); + }); + + it("should empty search field when the timeline rendering type changes away", async () => { + const onSearchChange = jest.fn(); + const { rerender } = render( + + + + + , + ); + + await userEvent.type(screen.getByPlaceholderText("Search messages…"), "test"); + expect(screen.getByPlaceholderText("Search messages…")).toHaveValue("test"); + + rerender( + + + + + , + ); + expect(screen.getByPlaceholderText("Search messages…")).toHaveValue(""); + }); }); it("opens room file panel on button click", () => { diff --git a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap index 4763ed4db7..13d038adca 100644 --- a/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/RoomSummaryCard-test.tsx.snap @@ -13,16 +13,6 @@ exports[` has button to edit topic when expanded 1`] = ` class="mx_Flex mx_RoomSummaryCard_header" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);" > -
renders the room summary 1`] = ` class="mx_Flex mx_RoomSummaryCard_header" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);" > -
renders the room topic in the summary 1`] = ` class="mx_Flex mx_RoomSummaryCard_header" style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: space-between; --mx-flex-gap: var(--cpd-space-3x);" > -
{ + it("should render the count of results", () => { + render( + {}), + }} + isRoomEncrypted={false} + onSearchScopeChange={jest.fn()} + onCancelClick={jest.fn()} + />, + ); + + expect(screen.getByText("5 results found for", { exact: false })).toHaveTextContent( + "5 results found for “abcd”", + ); + }); + + it("should allow the user to toggle to all rooms search", async () => { + const onSearchScopeChange = jest.fn(); + + render( + , + ); + + screen.getByText("Search all rooms").click(); + expect(onSearchScopeChange).toHaveBeenCalledWith(SearchScope.All); + }); + + it("should allow the user to toggle back to room-specific search", async () => { + const onSearchScopeChange = jest.fn(); + + render( + {}), + }} + isRoomEncrypted={false} + onSearchScopeChange={onSearchScopeChange} + onCancelClick={jest.fn()} + />, + ); + + screen.getByText("Search this room").click(); + expect(onSearchScopeChange).toHaveBeenCalledWith(SearchScope.Room); + }); + + it("should allow the user to cancel a search", async () => { + const onCancelClick = jest.fn(); + + render( + , + ); + + screen.getByLabelText("Cancel").click(); + expect(onCancelClick).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/rooms/SearchBar-test.tsx b/test/components/views/rooms/SearchBar-test.tsx deleted file mode 100644 index 830b66e3be..0000000000 --- a/test/components/views/rooms/SearchBar-test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/* -Copyright 2022 Emmanuel Ezeka - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import { fireEvent, render } from "@testing-library/react"; - -import SearchBar from "../../../../src/components/views/rooms/SearchBar"; -import { KeyBindingAction } from "../../../../src/accessibility/KeyboardShortcuts"; -import { SearchScope } from "../../../../src/Searching"; - -let mockCurrentEvent = KeyBindingAction.Enter; - -const searchProps = { - onCancelClick: jest.fn(), - onSearch: jest.fn(), - searchInProgress: false, - isRoomEncrypted: false, -}; - -jest.mock("../../../../src/KeyBindingsManager", () => ({ - __esModule: true, - getKeyBindingsManager: jest.fn(() => ({ getAccessibilityAction: jest.fn(() => mockCurrentEvent) })), -})); - -describe("SearchBar", () => { - afterEach(() => { - searchProps.onCancelClick.mockClear(); - searchProps.onSearch.mockClear(); - }); - - it("must not search when input value is empty", () => { - const { container } = render(); - const roomButtons = container.querySelectorAll(".mx_SearchBar_button"); - const searchButton = container.querySelectorAll(".mx_SearchBar_searchButton"); - - expect(roomButtons.length).toEqual(2); - - fireEvent.click(searchButton[0]); - fireEvent.click(roomButtons[0]); - fireEvent.click(roomButtons[1]); - - expect(searchProps.onSearch).not.toHaveBeenCalled(); - }); - - it("must trigger onSearch when value is not empty", () => { - const { container } = render(); - const searchValue = "abcd"; - - const roomButtons = container.querySelectorAll(".mx_SearchBar_button"); - const searchButton = container.querySelectorAll(".mx_SearchBar_searchButton"); - const input = container.querySelector(".mx_SearchBar_input input"); - input!.value = searchValue; - - expect(roomButtons.length).toEqual(2); - - fireEvent.click(searchButton[0]); - - expect(searchProps.onSearch).toHaveBeenCalledTimes(1); - expect(searchProps.onSearch).toHaveBeenNthCalledWith(1, searchValue, SearchScope.Room); - - fireEvent.click(roomButtons[0]); - - expect(searchProps.onSearch).toHaveBeenCalledTimes(2); - expect(searchProps.onSearch).toHaveBeenNthCalledWith(2, searchValue, SearchScope.Room); - - fireEvent.click(roomButtons[1]); - - expect(searchProps.onSearch).toHaveBeenCalledTimes(3); - expect(searchProps.onSearch).toHaveBeenNthCalledWith(3, searchValue, SearchScope.All); - }); - - it("cancel button and esc key should trigger onCancelClick", async () => { - mockCurrentEvent = KeyBindingAction.Escape; - const { container } = render(); - const cancelButton = container.querySelector(".mx_SearchBar_cancel"); - const input = container.querySelector(".mx_SearchBar_input input"); - fireEvent.click(cancelButton!); - expect(searchProps.onCancelClick).toHaveBeenCalledTimes(1); - - fireEvent.focus(input!); - fireEvent.keyDown(input!, { key: "Escape", code: "Escape", charCode: 27 }); - - expect(searchProps.onCancelClick).toHaveBeenCalledTimes(2); - }); -});