From 26430a3a6a1d8b4c0d87d6544db783dd9f68ab82 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 18 Oct 2024 15:57:39 +0100 Subject: [PATCH] Replace legacy Tooltips with Compound tooltips (#28231) * Ditch legacy Tooltips in favour of Compound Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Remove dead code Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Extract markdown CodeBlock into React component Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Upgrade compound Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 4 +- playwright/e2e/editing/editing.spec.ts | 1 - playwright/e2e/register/register.spec.ts | 6 +- playwright/element-web-test.ts | 3 +- res/css/_components.pcss | 1 - res/css/views/auth/_PassphraseField.pcss | 3 +- res/css/views/elements/_Field.pcss | 8 - .../views/elements/_MiniAvatarUploader.pcss | 12 -- res/css/views/elements/_Tooltip.pcss | 107 ---------- res/css/views/elements/_Validation.pcss | 1 + src/components/structures/ContextMenu.tsx | 30 --- src/components/structures/HomePage.tsx | 6 +- src/components/views/auth/EmailField.tsx | 5 +- .../views/auth/PassphraseConfirmField.tsx | 5 +- src/components/views/auth/PassphraseField.tsx | 5 +- .../views/auth/RegistrationForm.tsx | 7 +- .../context_menus/GenericTextContextMenu.tsx | 23 --- .../views/elements/CopyableText.tsx | 24 ++- src/components/views/elements/Field.tsx | 68 +++--- .../views/elements/MiniAvatarUploader.tsx | 48 ++--- src/components/views/elements/Tooltip.tsx | 194 ------------------ src/components/views/elements/Validation.tsx | 6 +- src/components/views/messages/CodeBlock.tsx | 136 ++++++++++++ src/components/views/messages/TextualBody.tsx | 175 ++-------------- src/components/views/settings/SetIdServer.tsx | 4 +- .../components/views/elements/Field-test.tsx | 12 +- .../views/messages/TextualBody-test.tsx | 13 +- .../__snapshots__/TextualBody-test.tsx.snap | 163 +++++++++++++-- yarn.lock | 10 +- 29 files changed, 410 insertions(+), 670 deletions(-) delete mode 100644 res/css/views/elements/_Tooltip.pcss delete mode 100644 src/components/views/context_menus/GenericTextContextMenu.tsx delete mode 100644 src/components/views/elements/Tooltip.tsx create mode 100644 src/components/views/messages/CodeBlock.tsx diff --git a/package.json b/package.json index 482067aa22..44e1a73f00 100644 --- a/package.json +++ b/package.json @@ -79,8 +79,6 @@ "@types/seedrandom": "3.0.8", "oidc-client-ts": "3.1.0", "jwt-decode": "4.0.0", - "@vector-im/compound-design-tokens": "1.8.0", - "@vector-im/compound-web": "7.0.0", "@floating-ui/react": "0.26.11", "@radix-ui/react-id": "1.1.0", "caniuse-lite": "1.0.30001668", @@ -98,7 +96,7 @@ "@sentry/browser": "^8.0.0", "@testing-library/react-hooks": "^8.0.1", "@vector-im/compound-design-tokens": "^1.8.0", - "@vector-im/compound-web": "^7.0.0", + "@vector-im/compound-web": "^7.1.0", "@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-en": "^3.0.2", diff --git a/playwright/e2e/editing/editing.spec.ts b/playwright/e2e/editing/editing.spec.ts index 2cc47f8edf..206d91982e 100644 --- a/playwright/e2e/editing/editing.spec.ts +++ b/playwright/e2e/editing/editing.spec.ts @@ -263,7 +263,6 @@ test.describe("Editing", () => { checkA11y, }) => { axe.disableRules("color-contrast"); // XXX: We have some known contrast issues here - axe.exclude(".mx_Tooltip_visible"); // XXX: this is fine but would be good to fix await page.goto(`#/room/${room.roomId}`); diff --git a/playwright/e2e/register/register.spec.ts b/playwright/e2e/register/register.spec.ts index 8ea64f8a65..2dd3779573 100644 --- a/playwright/e2e/register/register.spec.ts +++ b/playwright/e2e/register/register.spec.ts @@ -96,7 +96,7 @@ test.describe("Registration", () => { }); }); await page.getByRole("textbox", { name: "Username", exact: true }).fill("_alice"); - await expect(page.getByRole("alert").filter({ hasText: "Some characters not allowed" })).toBeVisible(); + await expect(page.getByRole("tooltip").filter({ hasText: "Some characters not allowed" })).toBeVisible(); await page.route("**/_matrix/client/*/register/available?username=bob", async (route) => { await route.fulfill({ @@ -108,9 +108,9 @@ test.describe("Registration", () => { }); }); await page.getByRole("textbox", { name: "Username", exact: true }).fill("bob"); - await expect(page.getByRole("alert").filter({ hasText: "Someone already has that username" })).toBeVisible(); + await expect(page.getByRole("tooltip").filter({ hasText: "Someone already has that username" })).toBeVisible(); await page.getByRole("textbox", { name: "Username", exact: true }).fill("foobar"); - await expect(page.getByRole("alert")).not.toBeVisible(); + await expect(page.getByRole("tooltip")).not.toBeVisible(); }); }); diff --git a/playwright/element-web-test.ts b/playwright/element-web-test.ts index 2973f88cda..66dd666389 100644 --- a/playwright/element-web-test.ts +++ b/playwright/element-web-test.ts @@ -345,8 +345,7 @@ export const expect = baseExpect.extend({ if (!options?.showTooltips) { css += ` - [role="tooltip"], - .mx_Tooltip_visible { + [role="tooltip"] { visibility: hidden !important; } `; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 42cd010e08..c0dd2ee0b0 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -217,7 +217,6 @@ @import "./views/elements/_TagComposer.pcss"; @import "./views/elements/_TextWithTooltip.pcss"; @import "./views/elements/_ToggleSwitch.pcss"; -@import "./views/elements/_Tooltip.pcss"; @import "./views/elements/_UseCaseSelection.pcss"; @import "./views/elements/_UseCaseSelectionButton.pcss"; @import "./views/elements/_Validation.pcss"; diff --git a/res/css/views/auth/_PassphraseField.pcss b/res/css/views/auth/_PassphraseField.pcss index 6020fb2b33..293a81cbe2 100644 --- a/res/css/views/auth/_PassphraseField.pcss +++ b/res/css/views/auth/_PassphraseField.pcss @@ -16,7 +16,8 @@ progress.mx_PassphraseField_progress { border: 0; height: 4px; position: absolute; - top: -12px; + top: -10px; + left: 0; @mixin ProgressBarBorderRadius "2px"; @mixin ProgressBarColour $PassphraseStrengthLow; diff --git a/res/css/views/elements/_Field.pcss b/res/css/views/elements/_Field.pcss index 92f8e41f0a..2659c4d389 100644 --- a/res/css/views/elements/_Field.pcss +++ b/res/css/views/elements/_Field.pcss @@ -164,14 +164,6 @@ Please see LICENSE files in the repository root for full details. } } -.mx_Field_tooltip { - width: 200px; -} - -.mx_Field_tooltip.mx_Field_valid { - animation: mx_fadeout 1s 2s forwards; -} - /* Customise other components when placed inside a Field */ .mx_Field .mx_Dropdown_input { diff --git a/res/css/views/elements/_MiniAvatarUploader.pcss b/res/css/views/elements/_MiniAvatarUploader.pcss index 9b1845b122..bcb47e28ec 100644 --- a/res/css/views/elements/_MiniAvatarUploader.pcss +++ b/res/css/views/elements/_MiniAvatarUploader.pcss @@ -10,18 +10,6 @@ Please see LICENSE files in the repository root for full details. position: relative; width: min-content; - /* this isn't a floating tooltip so override some things to not need to bother with z-index and floating */ - .mx_Tooltip { - display: inline-block; - position: absolute; - z-index: unset; - width: max-content; - left: 72px; - /* top edge starting at 50 % of parent - 50 % of itself -> centered vertically */ - top: 50%; - transform: translateY(-50%); - } - .mx_MiniAvatarUploader_indicator { position: absolute; diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss deleted file mode 100644 index 30308ed234..0000000000 --- a/res/css/views/elements/_Tooltip.pcss +++ /dev/null @@ -1,107 +0,0 @@ -/* -Copyright 2019-2024 New Vector Ltd. -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -@keyframes mx_fadein { - from { - opacity: 0; - } - - to { - opacity: 1; - } -} - -@keyframes mx_fadeout { - from { - opacity: 1; - } - - to { - opacity: 0; - } -} - -.mx_Tooltip_chevron { - position: absolute; - left: -7px; - top: calc(50% - 6px); - width: 0; - height: 0; - border-top: 7px solid transparent; - border-right: 7px solid $menu-border-color; - border-bottom: 7px solid transparent; -} - -.mx_Tooltip_chevron::after { - content: ""; - width: 0; - height: 0; - border-top: 6px solid transparent; - border-right: 6px solid $menu-bg-color; - border-bottom: 6px solid transparent; - position: absolute; - top: -6px; - left: 1px; -} - -.mx_Tooltip { - display: none; - position: fixed; - border-radius: 8px; - z-index: 6000; /* Higher than context menu so tooltips can be used everywhere */ - padding: 10px; - pointer-events: none; - line-height: $font-14px; - font-size: $font-12px; - font-weight: 500; - max-width: 300px; - word-break: break-word; - - background-color: var(--cpd-color-alpha-gray-1400); - color: var(--cpd-color-text-on-solid-primary); - border: 0; - text-align: center; - - .mx_Tooltip_chevron { - display: none; - } - - &.mx_Tooltip_visible { - animation: mx_fadein 0.2s forwards; - } - - &.mx_Tooltip_invisible { - animation: mx_fadeout 0.1s forwards; - } - - ul, - ol { - text-align: start; /* for list items */ - } -} - -/* These tooltips use an older style with a chevron */ -.mx_Field_tooltip { - background-color: $menu-bg-color; - color: $primary-content; - border: 1px solid $menu-border-color; - text-align: unset; - - .mx_Tooltip_chevron { - display: unset; - } -} - -.mx_Tooltip_title { - font-weight: var(--cpd-font-weight-semibold); -} - -.mx_Tooltip_sub { - opacity: 0.7; - margin-top: 4px; -} diff --git a/res/css/views/elements/_Validation.pcss b/res/css/views/elements/_Validation.pcss index 1f7c22ff05..5bf8dfb794 100644 --- a/res/css/views/elements/_Validation.pcss +++ b/res/css/views/elements/_Validation.pcss @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. .mx_Validation { position: relative; + max-width: 200px; } .mx_Validation_details { diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index d589376610..2c7a864db5 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -12,7 +12,6 @@ import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } fro import ReactDOM from "react-dom"; import classNames from "classnames"; import FocusLock from "react-focus-lock"; -import { TooltipProvider } from "@vector-im/compound-web"; import { Writeable } from "../../@types/common"; import UIStore from "../../stores/UIStore"; @@ -611,35 +610,6 @@ export const useContextMenu = (inputRef?: RefObject return [button.current ? isOpen : false, button, open, close, setIsOpen]; }; -// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs. -export function createMenu( - ElementClass: typeof React.Component, - props: Record, -): { close: (...args: any[]) => void } { - const onFinished = function (...args: any[]): void { - ReactDOM.unmountComponentAtNode(getOrCreateContainer()); - props?.onFinished?.apply(null, args); - }; - - const menu = ( - - - - - - ); - - ReactDOM.render(menu, getOrCreateContainer()); - - return { close: onFinished }; -} - // re-export the semantic helper components for simplicity export { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton"; export { ContextMenuTooltipButton } from "../../accessibility/context_menu/ContextMenuTooltipButton"; diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 893a7d4d5b..31deb381dd 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -11,7 +11,7 @@ import { useContext, useState } from "react"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { getHomePageUrl } from "../../utils/pages"; -import { _tDom } from "../../languageHandler"; +import { _t, _tDom } from "../../languageHandler"; import SdkConfig from "../../SdkConfig"; import dis from "../../dispatcher/dispatcher"; import { Action } from "../../dispatcher/actions"; @@ -66,8 +66,8 @@ const UserWelcomeTop: React.FC = () => {
cli.setAvatarUrl(url)} isUserAvatar onClick={(ev) => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)} diff --git a/src/components/views/auth/EmailField.tsx b/src/components/views/auth/EmailField.tsx index acd5597259..1a72872b95 100644 --- a/src/components/views/auth/EmailField.tsx +++ b/src/components/views/auth/EmailField.tsx @@ -6,13 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { PureComponent, RefCallback, RefObject } from "react"; +import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react"; import Field, { IInputProps } from "../elements/Field"; import { _t, _td, TranslationKey } from "../../../languageHandler"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import * as Email from "../../../email"; -import { Alignment } from "../elements/Tooltip"; interface IProps extends Omit { id?: string; @@ -23,7 +22,7 @@ interface IProps extends Omit { label: TranslationKey; labelRequired: TranslationKey; labelInvalid: TranslationKey; - tooltipAlignment?: Alignment; + tooltipAlignment?: ComponentProps["tooltipAlignment"]; // When present, completely overrides the default validation rules. validationRules?: (fieldState: IFieldState) => Promise; diff --git a/src/components/views/auth/PassphraseConfirmField.tsx b/src/components/views/auth/PassphraseConfirmField.tsx index ec26099ded..2b27d3ecaf 100644 --- a/src/components/views/auth/PassphraseConfirmField.tsx +++ b/src/components/views/auth/PassphraseConfirmField.tsx @@ -6,12 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { PureComponent, RefCallback, RefObject } from "react"; +import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react"; import Field, { IInputProps } from "../elements/Field"; import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; import { _t, _td, TranslationKey } from "../../../languageHandler"; -import { Alignment } from "../elements/Tooltip"; interface IProps extends Omit { id?: string; @@ -23,7 +22,7 @@ interface IProps extends Omit { label: TranslationKey; labelRequired: TranslationKey; labelInvalid: TranslationKey; - tooltipAlignment?: Alignment; + tooltipAlignment?: ComponentProps["tooltipAlignment"]; onChange(ev: React.FormEvent): void; onValidate?(result: IValidationResult): void; } diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 6770b141a5..90201d1ec1 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { PureComponent, RefCallback, RefObject } from "react"; +import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react"; import classNames from "classnames"; import type { ZxcvbnResult } from "@zxcvbn-ts/core"; @@ -15,7 +15,6 @@ import withValidation, { IFieldState, IValidationResult } from "../elements/Vali import { _t, _td, TranslationKey } from "../../../languageHandler"; import Field, { IInputProps } from "../elements/Field"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { Alignment } from "../elements/Tooltip"; interface IProps extends Omit { autoFocus?: boolean; @@ -31,7 +30,7 @@ interface IProps extends Omit { labelEnterPassword: TranslationKey; labelStrongPassword: TranslationKey; labelAllowedButUnsafe: TranslationKey; - tooltipAlignment?: Alignment; + tooltipAlignment?: ComponentProps["tooltipAlignment"]; onChange(ev: React.FormEvent): void; onValidate?(result: IValidationResult): void; diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index 4df3313758..540e5905a3 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { BaseSyntheticEvent, ReactNode } from "react"; +import React, { BaseSyntheticEvent, ComponentProps, ReactNode } from "react"; import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; @@ -26,7 +26,6 @@ import RegistrationEmailPromptDialog from "../dialogs/RegistrationEmailPromptDia import CountryDropdown from "./CountryDropdown"; import PassphraseConfirmField from "./PassphraseConfirmField"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; -import { Alignment } from "../elements/Tooltip"; enum RegistrationField { Email = "field_email", @@ -441,9 +440,9 @@ export default class RegistrationForm extends React.PureComponent["tooltipAlignment"] | undefined { if (this.props.mobileRegister) { - return Alignment.Bottom; + return "bottom"; } return undefined; } diff --git a/src/components/views/context_menus/GenericTextContextMenu.tsx b/src/components/views/context_menus/GenericTextContextMenu.tsx deleted file mode 100644 index ac9a947c7a..0000000000 --- a/src/components/views/context_menus/GenericTextContextMenu.tsx +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React from "react"; - -interface IProps { - message: string; -} - -export default class GenericTextContextMenu extends React.Component { - public render(): React.ReactNode { - return ( -
- {this.props.message} -
- ); - } -} diff --git a/src/components/views/elements/CopyableText.tsx b/src/components/views/elements/CopyableText.tsx index 18c82d5992..c7b0df0679 100644 --- a/src/components/views/elements/CopyableText.tsx +++ b/src/components/views/elements/CopyableText.tsx @@ -21,7 +21,7 @@ interface IProps extends React.HTMLAttributes { className?: string; } -const CopyableText: React.FC = ({ children, getTextToCopy, border = true, className, ...props }) => { +export const CopyTextButton: React.FC> = ({ getTextToCopy, className }) => { const [tooltip, setTooltip] = useState(undefined); const onCopyClickInternal = async (e: ButtonEvent): Promise => { @@ -37,6 +37,19 @@ const CopyableText: React.FC = ({ children, getTextToCopy, border = true } }; + return ( + { + if (!open) onHideTooltip(); + }} + /> + ); +}; + +const CopyableText: React.FC = ({ children, getTextToCopy, border = true, className, ...props }) => { const combinedClassName = classNames("mx_CopyableText", className, { mx_CopyableText_border: border, }); @@ -44,14 +57,7 @@ const CopyableText: React.FC = ({ children, getTextToCopy, border = true return (
{children} - { - if (!open) onHideTooltip(); - }} - /> +
); }; diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 6cc5dffc40..540cf37cbe 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -11,14 +11,13 @@ import React, { TextareaHTMLAttributes, RefObject, createRef, - KeyboardEvent, + ComponentProps, } from "react"; import classNames from "classnames"; import { debounce } from "lodash"; +import { Tooltip } from "@vector-im/compound-web"; import { IFieldState, IValidationResult } from "./Validation"; -import Tooltip, { Alignment } from "./Tooltip"; -import { Key } from "../../../Keyboard"; // Invoke validation from user input (when typing, etc.) at most once every N ms. const VALIDATION_THROTTLE_MS = 200; @@ -57,11 +56,11 @@ interface IProps { forceValidity?: boolean; // If specified, contents will appear as a tooltip on the element and // validation feedback tooltips will be suppressed. - tooltipContent?: React.ReactNode; + tooltipContent?: JSX.Element | string; // If specified the tooltip will be shown regardless of feedback forceTooltipVisible?: boolean; // If specified, the tooltip with be aligned accorindly with the field, defaults to Right. - tooltipAlignment?: Alignment; + tooltipAlignment?: ComponentProps["placement"]; // If specified alongside tooltipContent, the class name to apply to the // tooltip itself. tooltipClassName?: string; @@ -112,7 +111,7 @@ type PropShapes = IInputProps | ISelectProps | ITextareaProps | INativeOnChangeI interface IState { valid?: boolean; - feedback?: React.ReactNode; + feedback?: JSX.Element | string; feedbackVisible: boolean; focused: boolean; } @@ -127,6 +126,7 @@ export default class Field extends React.PureComponent { validateOnFocus: true, validateOnBlur: true, validateOnChange: true, + tooltipAlignment: "right", }; /* @@ -233,16 +233,10 @@ export default class Field extends React.PureComponent { return this.props.inputRef ?? this._inputRef; } - private onKeyDown = (evt: KeyboardEvent): void => { - // If the tooltip is displayed to show a feedback and Escape is pressed - // The tooltip is hided - if (this.state.feedbackVisible && evt.key === Key.ESCAPE) { - evt.preventDefault(); - evt.stopPropagation(); - this.setState({ - feedbackVisible: false, - }); - } + private onTooltipOpenChange = (open: boolean): void => { + this.setState({ + feedbackVisible: open, + }); }; public render(): React.ReactNode { @@ -268,31 +262,15 @@ export default class Field extends React.PureComponent { } = this.props; // Handle displaying feedback on validity - let fieldTooltip: JSX.Element | undefined; + const tooltipProps: Pick, "aria-live" | "aria-atomic"> = {}; + let tooltipOpen = false; if (tooltipContent || this.state.feedback) { - const tooltipId = `${this.id}_tooltip`; - const visible = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible; - if (visible) { - inputProps["aria-describedby"] = tooltipId; - } + tooltipOpen = (this.state.focused && forceTooltipVisible) || this.state.feedbackVisible; - let role: React.AriaRole; - if (tooltipContent) { - role = "tooltip"; - } else { - role = this.state.valid ? "status" : "alert"; + if (!tooltipContent) { + tooltipProps["aria-atomic"] = "true"; + tooltipProps["aria-live"] = this.state.valid ? "polite" : "assertive"; } - - fieldTooltip = ( - - ); } inputProps.placeholder = inputProps.placeholder ?? inputProps.label; @@ -332,12 +310,20 @@ export default class Field extends React.PureComponent { }); return ( -
+
{prefixContainer} - {fieldInput} + + {fieldInput} + {postfixContainer} - {fieldTooltip}
); } diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 24a3a29ce6..8bbca5b309 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import { EventType } from "matrix-js-sdk/src/matrix"; import React, { useContext, useRef, useState, MouseEvent, ReactNode } from "react"; +import { Tooltip } from "@vector-im/compound-web"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import RoomContext from "../../../contexts/RoomContext"; import { useTimeout } from "../../../hooks/useTimeout"; -import { TranslatedString } from "../../../languageHandler"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import AccessibleButton from "./AccessibleButton"; import Spinner from "./Spinner"; @@ -22,8 +22,8 @@ export const AVATAR_SIZE = "52px"; interface IProps { hasAvatar: boolean; - noAvatarLabel?: TranslatedString; - hasAvatarLabel?: TranslatedString; + noAvatarLabel?: string; + hasAvatarLabel?: string; setAvatarUrl(url: string): Promise; isUserAvatar?: boolean; onClick?(ev: MouseEvent): void; @@ -82,34 +82,24 @@ const MiniAvatarUploader: React.FC = ({ accept="image/*" /> - { - uploadRef.current?.click(); - }} - onMouseOver={() => setHover(true)} - onMouseLeave={() => setHover(false)} - > - {children} - -
- {busy ? :
} -
- -
+ { + uploadRef.current?.click(); + }} > -
- {label} -
-
+ {children} + +
+ {busy ? :
} +
+ + ); }; diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx deleted file mode 100644 index 5b7f01588b..0000000000 --- a/src/components/views/elements/Tooltip.tsx +++ /dev/null @@ -1,194 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. -Copyright 2019 New Vector Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only -Please see LICENSE files in the repository root for full details. -*/ - -import React, { CSSProperties } from "react"; -import ReactDOM from "react-dom"; -import classNames from "classnames"; - -import UIStore from "../../../stores/UIStore"; -import { objectHasDiff } from "../../../utils/objects"; - -export enum Alignment { - Natural, // Pick left or right - Left, - Right, - Top, // Centered - Bottom, // Centered - InnerBottom, // Inside the target, at the bottom - TopRight, // On top of the target, right aligned -} - -export interface ITooltipProps { - // Class applied to the element used to position the tooltip - className?: string; - // Class applied to the tooltip itself - tooltipClassName?: string; - // Whether the tooltip is visible or hidden. - // The hidden state allows animating the tooltip away via CSS. - // Defaults to visible if unset. - visible?: boolean; - // the react element to put into the tooltip - label: React.ReactNode; - alignment?: Alignment; // defaults to Natural - // id describing tooltip - // used to associate tooltip with target for a11y - id?: string; - // If the parent is over this width, act as if it is only this wide - maxParentWidth?: number; - // aria-role passed to the tooltip - role?: React.AriaRole; -} - -type State = Partial>; - -/** - * @deprecated Use [compound tooltip](https://element-hq.github.io/compound-web/?path=/docs/tooltip--docs) instead - */ -export default class Tooltip extends React.PureComponent { - private static container: HTMLElement; - private parent: Element | null = null; - - // XXX: This is because some components (Field) are unable to `import` the Tooltip class, - // so we expose the Alignment options off of us statically. - public static readonly Alignment = Alignment; - - public static readonly defaultProps = { - visible: true, - alignment: Alignment.Natural, - }; - - public constructor(props: ITooltipProps) { - super(props); - - this.state = {}; - - // Create a wrapper for the tooltips and attach it to the body element - if (!Tooltip.container) { - Tooltip.container = document.createElement("div"); - Tooltip.container.className = "mx_Tooltip_wrapper"; - document.body.appendChild(Tooltip.container); - } - } - - public componentDidMount(): void { - window.addEventListener("scroll", this.updatePosition, { - passive: true, - capture: true, - }); - - this.parent = (ReactDOM.findDOMNode(this)?.parentNode as Element) ?? null; - - this.updatePosition(); - } - - public componentDidUpdate(prevProps: ITooltipProps): void { - if (objectHasDiff(prevProps, this.props)) { - this.updatePosition(); - } - } - - // Remove the wrapper element, as the tooltip has finished using it - public componentWillUnmount(): void { - window.removeEventListener("scroll", this.updatePosition, { - capture: true, - }); - } - - // Add the parent's position to the tooltips, so it's correctly - // positioned, also taking into account any window zoom - private updatePosition = (): void => { - // When the tooltip is hidden, no need to thrash the DOM with `style` attribute updates (performance) - if (!this.props.visible || !this.parent) return; - - const parentBox = this.parent.getBoundingClientRect(); - const width = UIStore.instance.windowWidth; - const spacing = 6; - const parentWidth = this.props.maxParentWidth - ? Math.min(parentBox.width, this.props.maxParentWidth) - : parentBox.width; - const baseTop = parentBox.top + window.scrollY; - const centerTop = parentBox.top + window.scrollY + parentBox.height / 2; - const right = width - parentBox.left - window.scrollX; - const left = parentBox.right + window.scrollX; - const horizontalCenter = parentBox.left - window.scrollX + parentWidth / 2; - - const style: State = {}; - switch (this.props.alignment) { - case Alignment.Natural: - if (parentBox.right > width / 2) { - style.right = right + spacing; - style.top = centerTop; - style.transform = "translateY(-50%)"; - break; - } - // fall through to Right - case Alignment.Right: - style.left = left + spacing; - style.top = centerTop; - style.transform = "translateY(-50%)"; - break; - case Alignment.Left: - style.right = right + spacing; - style.top = centerTop; - style.transform = "translateY(-50%)"; - break; - case Alignment.Top: - style.top = baseTop - spacing; - // Attempt to center the tooltip on the element while clamping - // its horizontal translation to keep it on screen - // eslint-disable-next-line max-len - style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))), -100%)`; - break; - case Alignment.Bottom: - style.top = baseTop + parentBox.height + spacing; - // Attempt to center the tooltip on the element while clamping - // its horizontal translation to keep it on screen - // eslint-disable-next-line max-len - style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; - break; - case Alignment.InnerBottom: - style.top = baseTop + parentBox.height - 50; - // Attempt to center the tooltip on the element while clamping - // its horizontal translation to keep it on screen - // eslint-disable-next-line max-len - style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; - break; - case Alignment.TopRight: - style.top = baseTop - spacing; - style.right = width - parentBox.right - window.scrollX; - style.transform = "translateY(-100%)"; - break; - } - - this.setState(style); - }; - - public render(): React.ReactNode { - const tooltipClasses = classNames("mx_Tooltip", this.props.tooltipClassName, { - mx_Tooltip_visible: this.props.visible, - mx_Tooltip_invisible: !this.props.visible, - }); - - const style = { ...this.state }; - // Hide the entire container when not visible. - // This prevents flashing of the tooltip if it is not meant to be visible on first mount. - style.display = this.props.visible ? "block" : "none"; - - const tooltip = ( -
-
- {this.props.label} -
- ); - - return
{ReactDOM.createPortal(tooltip, Tooltip.container)}
; - } -} diff --git a/src/components/views/elements/Validation.tsx b/src/components/views/elements/Validation.tsx index db944f8e98..f859c00beb 100644 --- a/src/components/views/elements/Validation.tsx +++ b/src/components/views/elements/Validation.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ReactChild, ReactNode } from "react"; +import React, { ReactNode } from "react"; import classNames from "classnames"; import memoizeOne from "memoize-one"; @@ -44,7 +44,7 @@ export interface IFieldState { export interface IValidationResult { valid?: boolean; - feedback?: React.ReactChild; + feedback?: JSX.Element | string; } /** @@ -189,7 +189,7 @@ export default function withValidation({ summary = content ?
{content}
: undefined; } - let feedback: ReactChild | undefined; + let feedback: JSX.Element | undefined; if (summary || details) { feedback = (
diff --git a/src/components/views/messages/CodeBlock.tsx b/src/components/views/messages/CodeBlock.tsx new file mode 100644 index 0000000000..8061991405 --- /dev/null +++ b/src/components/views/messages/CodeBlock.tsx @@ -0,0 +1,136 @@ +/* +Copyright 2024 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import React, { useState } from "react"; +import classNames from "classnames"; +import { TooltipProvider } from "@vector-im/compound-web"; + +import { useSettingValue } from "../../../hooks/useSettings.ts"; +import { CopyTextButton } from "../elements/CopyableText.tsx"; + +const MAX_HIGHLIGHT_LENGTH = 4096; +const MAX_LINES_BEFORE_COLLAPSE = 5; + +interface Props { + children: HTMLElement; + onHeightChanged?(): void; +} + +const ExpandCollapseButton: React.FC<{ + expanded: boolean; + onClick(): void; +}> = ({ expanded, onClick }) => { + return ( + + ); +}; + +const CodeBlock: React.FC = ({ children, onHeightChanged }) => { + const enableSyntaxHighlightLanguageDetection = useSettingValue("enableSyntaxHighlightLanguageDetection"); + const showCodeLineNumbers = useSettingValue("showCodeLineNumbers"); + const expandCodeByDefault = useSettingValue("expandCodeByDefault"); + const [expanded, setExpanded] = useState(expandCodeByDefault); + + let expandCollapseButton: JSX.Element | undefined; + if (children.textContent && children.textContent.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) { + expandCollapseButton = ( + { + setExpanded(!expanded); + // By expanding/collapsing we changed the height, therefore we call this + onHeightChanged?.(); + }} + /> + ); + } + + let lineNumbers: JSX.Element | undefined; + if (showCodeLineNumbers) { + // Calculate number of lines in pre + const number = children.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length; + // Iterate through lines starting with 1 (number of the first line is 1) + lineNumbers = ( + + {Array.from({ length: number }, (_, i) => i + 1).map((i) => ( + {i} + ))} + + ); + } + + async function highlightCode(div: HTMLElement | null): Promise { + const code = div?.getElementsByTagName("code")[0]; + if (!code) return; + const { default: highlight } = await import("highlight.js"); + + if (code.textContent && code.textContent.length > MAX_HIGHLIGHT_LENGTH) { + console.log( + `Code block is bigger than highlight limit (${code.textContent.length} > ${MAX_HIGHLIGHT_LENGTH}): not highlighting`, + ); + return; + } + + let advertisedLang: string | undefined; + for (const cl of code.className.split(/\s+/)) { + if (cl.startsWith("language-")) { + const maybeLang = cl.split("-", 2)[1]; + if (highlight.getLanguage(maybeLang)) { + advertisedLang = maybeLang; + break; + } + } + } + + if (advertisedLang) { + // If the code says what language it is, highlight it in that language + // We don't use highlightElement here because we can't force language detection + // off. It should use the one we've found in the CSS class but we'd rather pass + // it in explicitly to make sure. + code.innerHTML = highlight.highlight(code.textContent ?? "", { language: advertisedLang }).value; + } else if (enableSyntaxHighlightLanguageDetection) { + // User has language detection enabled, so highlight the block with auto-highlighting enabled. + // We pass highlightjs the text to highlight rather than letting it + // work on the DOM with highlightElement because that also adds CSS + // classes to the pre/code element that we don't want (the CSS + // conflicts with our own). + code.innerHTML = highlight.highlightAuto(code.textContent ?? "").value; + } + } + + return ( + +
+                {lineNumbers}
+                
+
+ {expandCollapseButton} + children.getElementsByTagName("code")[0]?.textContent ?? null} + className={classNames("mx_EventTile_button mx_EventTile_copyButton", { + mx_EventTile_buttonBottom: !!expandCollapseButton, + })} + /> +
+ ); +}; + +export default CodeBlock; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index eb1c94e20d..08b7991807 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -16,17 +16,12 @@ import { formatDate } from "../../../DateUtils"; import Modal from "../../../Modal"; import dis from "../../../dispatcher/dispatcher"; import { _t } from "../../../languageHandler"; -import * as ContextMenu from "../../structures/ContextMenu"; -import { ChevronFace, toRightOf } from "../../structures/ContextMenu"; import SettingsStore from "../../../settings/SettingsStore"; import { pillifyLinks, unmountPills } from "../../../utils/pillify"; import { tooltipifyLinks, unmountTooltips } from "../../../utils/tooltipify"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import { isPermalinkHost, tryTransformPermalinkToLocalHref } from "../../../utils/permalinks/Permalinks"; -import { copyPlaintext } from "../../../utils/strings"; -import UIStore from "../../../stores/UIStore"; import { Action } from "../../../dispatcher/actions"; -import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; import Spoiler from "../elements/Spoiler"; import QuestionDialog from "../dialogs/QuestionDialog"; import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; @@ -40,8 +35,7 @@ import { getParentEventId } from "../../../utils/Reply"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { IEventTileOps } from "../rooms/EventTile"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; - -const MAX_HIGHLIGHT_LENGTH = 4096; +import CodeBlock from "./CodeBlock"; interface IState { // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. @@ -54,9 +48,9 @@ interface IState { export default class TextualBody extends React.Component { private readonly contentRef = createRef(); - private unmounted = false; private pills: Element[] = []; private tooltips: Element[] = []; + private reactRoots: Element[] = []; public static contextType = RoomContext; public declare context: React.ContextType; @@ -76,7 +70,6 @@ export default class TextualBody extends React.Component { // Function is only called from render / componentDidMount → contentRef is set const content = this.contentRef.current!; - const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers"); this.activateSpoilers([content]); HtmlUtils.linkifyElement(content); @@ -103,27 +96,9 @@ export default class TextualBody extends React.Component { } // Wrap a div around
 so that the copy button can be correctly positioned
                     // when the 
 overflows and is scrolled horizontally.
-                    const div = this.wrapInDiv(pres[i]);
-                    this.handleCodeBlockExpansion(pres[i]);
-                    this.addCodeExpansionButton(div, pres[i]);
-                    this.addCodeCopyButton(div);
-                    if (showLineNumbers) {
-                        this.addLineNumbers(pres[i]);
-                    }
+                    this.wrapPreInReact(pres[i]);
                 }
             }
-            // Highlight code
-            const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
-            if (codes.length > 0) {
-                // Do this asynchronously: parsing code takes time and we don't
-                // need to block the DOM update on it.
-                window.setTimeout(() => {
-                    if (this.unmounted) return;
-                    for (let i = 0; i < codes.length; i++) {
-                        this.highlightCode(codes[i]);
-                    }
-                }, 10);
-            }
         }
     }
 
@@ -133,141 +108,15 @@ export default class TextualBody extends React.Component {
         pre.appendChild(code);
     }
 
-    private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
-        // Calculate how many percent does the pre element take up.
-        // If it's less than 30% we don't add the expansion button.
-        // We also round the number as it sometimes can be 29.99...
-        const percentageOfViewport = Math.round((pre.offsetHeight / UIStore.instance.windowHeight) * 100);
-        // TODO: additionally show the button if it's an expanded quoted message
-        if (percentageOfViewport < 30) return;
-
-        const button = document.createElement("span");
-        button.className = "mx_EventTile_button ";
-        if (pre.className == "mx_EventTile_collapsedCodeBlock") {
-            button.className += "mx_EventTile_expandButton";
-        } else {
-            button.className += "mx_EventTile_collapseButton";
-        }
-
-        button.onclick = async (): Promise => {
-            button.className = "mx_EventTile_button ";
-            if (pre.className == "mx_EventTile_collapsedCodeBlock") {
-                pre.className = "";
-                button.className += "mx_EventTile_collapseButton";
-            } else {
-                pre.className = "mx_EventTile_collapsedCodeBlock";
-                button.className += "mx_EventTile_expandButton";
-            }
-
-            // By expanding/collapsing we changed
-            // the height, therefore we call this
-            this.props.onHeightChanged?.();
-        };
-
-        div.appendChild(button);
-    }
-
-    private addCodeCopyButton(div: HTMLDivElement): void {
-        const button = document.createElement("span");
-        button.className = "mx_EventTile_button mx_EventTile_copyButton ";
-
-        // Check if expansion button exists. If so we put the copy button to the bottom
-        const expansionButtonExists = div.getElementsByClassName("mx_EventTile_button");
-        if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
-
-        button.onclick = async (): Promise => {
-            const copyCode = button.parentElement?.getElementsByTagName("code")[0];
-            const successful = copyCode?.textContent ? await copyPlaintext(copyCode.textContent) : false;
-
-            const buttonRect = button.getBoundingClientRect();
-            const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
-                ...toRightOf(buttonRect, 0),
-                chevronFace: ChevronFace.None,
-                message: successful ? _t("common|copied") : _t("error|failed_copy"),
-            });
-            button.onmouseleave = close;
-        };
-
-        div.appendChild(button);
-    }
-
-    private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
-        const div = document.createElement("div");
-        div.className = "mx_EventTile_pre_container";
+    private wrapPreInReact(pre: HTMLPreElement): void {
+        const root = document.createElement("div");
+        root.className = "mx_EventTile_pre_container";
+        this.reactRoots.push(root);
 
         // Insert containing div in place of 
 block
-        pre.parentNode?.replaceChild(div, pre);
-        // Append 
 block and copy button to container
-        div.appendChild(pre);
+        pre.parentNode?.replaceChild(root, pre);
 
-        return div;
-    }
-
-    private handleCodeBlockExpansion(pre: HTMLPreElement): void {
-        if (!SettingsStore.getValue("expandCodeByDefault")) {
-            pre.className = "mx_EventTile_collapsedCodeBlock";
-        }
-    }
-
-    private addLineNumbers(pre: HTMLPreElement): void {
-        // Calculate number of lines in pre
-        const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
-        const lineNumbers = document.createElement("span");
-        lineNumbers.className = "mx_EventTile_lineNumbers";
-        // Iterate through lines starting with 1 (number of the first line is 1)
-        for (let i = 1; i <= number; i++) {
-            const s = document.createElement("span");
-            s.textContent = i.toString();
-            lineNumbers.appendChild(s);
-        }
-        pre.prepend(lineNumbers);
-        pre.append(document.createElement("span"));
-    }
-
-    private async highlightCode(code: HTMLElement): Promise {
-        const { default: highlight } = await import("highlight.js");
-
-        if (code.textContent && code.textContent.length > MAX_HIGHLIGHT_LENGTH) {
-            console.log(
-                "Code block is bigger than highlight limit (" +
-                    code.textContent.length +
-                    " > " +
-                    MAX_HIGHLIGHT_LENGTH +
-                    "): not highlighting",
-            );
-            return;
-        }
-
-        let advertisedLang;
-        for (const cl of code.className.split(/\s+/)) {
-            if (cl.startsWith("language-")) {
-                const maybeLang = cl.split("-", 2)[1];
-                if (highlight.getLanguage(maybeLang)) {
-                    advertisedLang = maybeLang;
-                    break;
-                }
-            }
-        }
-
-        if (advertisedLang) {
-            // If the code says what language it is, highlight it in that language
-            // We don't use highlightElement here because we can't force language detection
-            // off. It should use the one we've found in the CSS class but we'd rather pass
-            // it in explicitly to make sure.
-            code.innerHTML = highlight.highlight(code.textContent ?? "", { language: advertisedLang }).value;
-        } else if (
-            SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") &&
-            code.parentElement instanceof HTMLPreElement
-        ) {
-            // User has language detection enabled and the code is within a pre
-            // we only auto-highlight if the code block is in a pre), so highlight
-            // the block with auto-highlighting enabled.
-            // We pass highlightjs the text to highlight rather than letting it
-            // work on the DOM with highlightElement because that also adds CSS
-            // classes to the pre/code element that we don't want (the CSS
-            // conflicts with our own).
-            code.innerHTML = highlight.highlightAuto(code.textContent ?? "").value;
-        }
+        ReactDOM.render({pre}, root);
     }
 
     public componentDidUpdate(prevProps: Readonly): void {
@@ -281,12 +130,16 @@ export default class TextualBody extends React.Component {
     }
 
     public componentWillUnmount(): void {
-        this.unmounted = true;
         unmountPills(this.pills);
         unmountTooltips(this.tooltips);
 
+        for (const root of this.reactRoots) {
+            ReactDOM.unmountComponentAtNode(root);
+        }
+
         this.pills = [];
         this.tooltips = [];
+        this.reactRoots = [];
     }
 
     public shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean {
diff --git a/src/components/views/settings/SetIdServer.tsx b/src/components/views/settings/SetIdServer.tsx
index 7a766cb5fb..e7a55f1133 100644
--- a/src/components/views/settings/SetIdServer.tsx
+++ b/src/components/views/settings/SetIdServer.tsx
@@ -120,7 +120,7 @@ export default class SetIdServer extends React.Component {
         this.setState({ idServer: u });
     };
 
-    private getTooltip = (): ReactNode => {
+    private getTooltip = (): JSX.Element | undefined => {
         if (this.state.checking) {
             return (
                 
@@ -131,7 +131,7 @@ export default class SetIdServer extends React.Component { } else if (this.state.error) { return {this.state.error}; } else { - return null; + return undefined; } }; diff --git a/test/unit-tests/components/views/elements/Field-test.tsx b/test/unit-tests/components/views/elements/Field-test.tsx index c75a9a5e12..9544559587 100644 --- a/test/unit-tests/components/views/elements/Field-test.tsx +++ b/test/unit-tests/components/views/elements/Field-test.tsx @@ -57,12 +57,12 @@ describe("Field", () => { // When invalid fireEvent.focus(screen.getByRole("textbox")); - // Expect 'alert' role - await expect(screen.findByRole("alert")).resolves.toBeInTheDocument(); + // Expect 'aria-live=assertive' + await expect(screen.findByRole("tooltip")).resolves.toHaveAttribute("aria-live", "assertive"); // Close the feedback is Escape is pressed fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); - expect(screen.queryByRole("alert")).toBeNull(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); }); it("Should mark the feedback as status if valid", async () => { @@ -77,12 +77,12 @@ describe("Field", () => { // When valid fireEvent.focus(screen.getByRole("textbox")); - // Expect 'status' role - await expect(screen.findByRole("status")).resolves.toBeInTheDocument(); + // Expect 'aria-live=polite' role + await expect(screen.findByRole("tooltip")).resolves.toHaveAttribute("aria-live", "polite"); // Close the feedback is Escape is pressed fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); - expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); }); it("Should mark the feedback as tooltip if custom tooltip set", async () => { diff --git a/test/unit-tests/components/views/messages/TextualBody-test.tsx b/test/unit-tests/components/views/messages/TextualBody-test.tsx index f27b765743..c7ffc4ed93 100644 --- a/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { mocked, MockedObject } from "jest-mock"; -import { render } from "jest-matrix-react"; +import { render, waitFor } from "jest-matrix-react"; import { getMockClientWithEventEmitter, mkEvent, mkMessage, mkStubRoom } from "../../../../test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; @@ -279,6 +279,17 @@ describe("", () => { expect(content).toMatchSnapshot(); }); + it("should syntax highlight code blocks", async () => { + const ev = mkFormattedMessage( + "```py\n# Python Program to calculate the square root\n\n# Note: change this value for a different result\nnum = 8 \n\n# To take the input from the user\n#num = float(input('Enter a number: '))\n\nnum_sqrt = num ** 0.5\nprint('The square root of %0.3f is %0.3f'%(num ,num_sqrt))", + "
# Python Program to calculate the square root\n\n# Note: change this value for a different result\nnum = 8 \n\n# To take the input from the user\n#num = float(input('Enter a number: '))\n\nnum_sqrt = num ** 0.5\nprint('The square root of %0.3f is %0.3f'%(num ,num_sqrt))\n
\n", + ); + const { container } = getComponent({ mxEvent: ev }, matrixClient); + await waitFor(() => expect(container.querySelector(".hljs-built_in")).toBeInTheDocument()); + const content = container.querySelector(".mx_EventTile_body"); + expect(content).toMatchSnapshot(); + }); + // If pills were rendered within a Portal/same shadow DOM then it'd be easier to test it("pills get injected correctly into the DOM", () => { const ev = mkFormattedMessage("Hey User", 'Hey Member'); diff --git a/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap index 56c6f9f81e..1ea245acf0 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/TextualBody-test.tsx.snap @@ -50,14 +50,20 @@ exports[` renders formatted m.text correctly linkification is not 1 - - https://matrix.org/ +
+ + https://matrix.org/ - - + +
-
@@ -254,14 +260,20 @@ exports[` renders formatted m.text correctly pills do not appear 1 - - @room +
+ + @room - - + +
-
@@ -321,6 +333,133 @@ exports[` renders formatted m.text correctly renders formatted bo
`; +exports[` renders formatted m.text correctly should syntax highlight code blocks 1`] = ` +
+
+
+      
+        
+          1
+        
+        
+          2
+        
+        
+          3
+        
+        
+          4
+        
+        
+          5
+        
+        
+          6
+        
+        
+          7
+        
+        
+          8
+        
+        
+          9
+        
+        
+          10
+        
+      
+      
+ + + # Python Program to calculate the square root + + + + + + # Note: change this value for a different result + + +num = + + 8 + + + + + + # To take the input from the user + + + + + #num = float(input('Enter a number: ')) + + + +num_sqrt = num ** + + 0.5 + + + + + print + + ( + + 'The square root of %0.3f is %0.3f' + + %(num ,num_sqrt)) + + +
+
+ +
+
+ + +
+`; + exports[` renders formatted m.text correctly spoilers get injected properly into the DOM 1`] = `