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>
This commit is contained in:
Michael Telatynski 2024-10-18 15:57:39 +01:00 committed by GitHub
parent fad457362d
commit 26430a3a6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 410 additions and 670 deletions

View file

@ -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",

View file

@ -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}`);

View file

@ -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();
});
});

View file

@ -345,8 +345,7 @@ export const expect = baseExpect.extend({
if (!options?.showTooltips) {
css += `
[role="tooltip"],
.mx_Tooltip_visible {
[role="tooltip"] {
visibility: hidden !important;
}
`;

View file

@ -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";

View file

@ -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;

View file

@ -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 {

View file

@ -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;

View file

@ -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;
}

View file

@ -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 {

View file

@ -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 = <T extends any = HTMLElement>(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<string, any>,
): { close: (...args: any[]) => void } {
const onFinished = function (...args: any[]): void {
ReactDOM.unmountComponentAtNode(getOrCreateContainer());
props?.onFinished?.apply(null, args);
};
const menu = (
<TooltipProvider>
<ContextMenu
{...props}
mountAsChild={true}
hasBackground={false}
onFinished={onFinished} // eslint-disable-line react/jsx-no-bind
windowResize={onFinished} // eslint-disable-line react/jsx-no-bind
>
<ElementClass {...props} onFinished={onFinished} />
</ContextMenu>
</TooltipProvider>
);
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";

View file

@ -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 = () => {
<div>
<MiniAvatarUploader
hasAvatar={!!ownProfile.avatarUrl}
hasAvatarLabel={_tDom("onboarding|has_avatar_label")}
noAvatarLabel={_tDom("onboarding|no_avatar_label")}
hasAvatarLabel={_t("onboarding|has_avatar_label")}
noAvatarLabel={_t("onboarding|no_avatar_label")}
setAvatarUrl={(url) => cli.setAvatarUrl(url)}
isUserAvatar
onClick={(ev) => PosthogTrackers.trackInteraction("WebHomeMiniAvatarUploadButton", ev)}

View file

@ -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<IInputProps, "onValidate" | "element"> {
id?: string;
@ -23,7 +22,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
label: TranslationKey;
labelRequired: TranslationKey;
labelInvalid: TranslationKey;
tooltipAlignment?: Alignment;
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
// When present, completely overrides the default validation rules.
validationRules?: (fieldState: IFieldState) => Promise<IValidationResult>;

View file

@ -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<IInputProps, "onValidate" | "label" | "element"> {
id?: string;
@ -23,7 +22,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "label" | "element"> {
label: TranslationKey;
labelRequired: TranslationKey;
labelInvalid: TranslationKey;
tooltipAlignment?: Alignment;
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;
}

View file

@ -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<IInputProps, "onValidate" | "element"> {
autoFocus?: boolean;
@ -31,7 +30,7 @@ interface IProps extends Omit<IInputProps, "onValidate" | "element"> {
labelEnterPassword: TranslationKey;
labelStrongPassword: TranslationKey;
labelAllowedButUnsafe: TranslationKey;
tooltipAlignment?: Alignment;
tooltipAlignment?: ComponentProps<typeof Field>["tooltipAlignment"];
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;

View file

@ -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<IProps, IState
return true;
}
private tooltipAlignment(): Alignment | undefined {
private tooltipAlignment(): ComponentProps<typeof EmailField>["tooltipAlignment"] | undefined {
if (this.props.mobileRegister) {
return Alignment.Bottom;
return "bottom";
}
return undefined;
}

View file

@ -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<IProps> {
public render(): React.ReactNode {
return (
<div className="mx_Tooltip mx_Tooltip_visible" style={{ display: "block" }}>
{this.props.message}
</div>
);
}
}

View file

@ -21,7 +21,7 @@ interface IProps extends React.HTMLAttributes<HTMLDivElement> {
className?: string;
}
const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true, className, ...props }) => {
export const CopyTextButton: React.FC<Pick<IProps, "getTextToCopy" | "className">> = ({ getTextToCopy, className }) => {
const [tooltip, setTooltip] = useState<string | undefined>(undefined);
const onCopyClickInternal = async (e: ButtonEvent): Promise<void> => {
@ -37,6 +37,19 @@ const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true
}
};
return (
<AccessibleButton
title={tooltip ?? _t("action|copy")}
onClick={onCopyClickInternal}
className={className}
onTooltipOpenChange={(open) => {
if (!open) onHideTooltip();
}}
/>
);
};
const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true, className, ...props }) => {
const combinedClassName = classNames("mx_CopyableText", className, {
mx_CopyableText_border: border,
});
@ -44,14 +57,7 @@ const CopyableText: React.FC<IProps> = ({ children, getTextToCopy, border = true
return (
<div className={combinedClassName} {...props}>
{children}
<AccessibleButton
title={tooltip ?? _t("action|copy")}
onClick={onCopyClickInternal}
className="mx_CopyableText_copyButton"
onTooltipOpenChange={(open) => {
if (!open) onHideTooltip();
}}
/>
<CopyTextButton getTextToCopy={getTextToCopy} className="mx_CopyableText_copyButton" />
</div>
);
};

View file

@ -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<typeof Tooltip>["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<PropShapes, IState> {
validateOnFocus: true,
validateOnBlur: true,
validateOnChange: true,
tooltipAlignment: "right",
};
/*
@ -233,16 +233,10 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
return this.props.inputRef ?? this._inputRef;
}
private onKeyDown = (evt: KeyboardEvent<HTMLDivElement>): 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<PropShapes, IState> {
} = this.props;
// Handle displaying feedback on validity
let fieldTooltip: JSX.Element | undefined;
const tooltipProps: Pick<React.ComponentProps<typeof Tooltip>, "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 = (
<Tooltip
id={tooltipId}
tooltipClassName={classNames("mx_Field_tooltip", "mx_Tooltip_noMargin", tooltipClassName)}
visible={visible}
label={tooltipContent || this.state.feedback}
alignment={tooltipAlignment || Alignment.Right}
role={role}
/>
);
}
inputProps.placeholder = inputProps.placeholder ?? inputProps.label;
@ -332,12 +310,20 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
});
return (
<div className={fieldClasses} onKeyDown={this.onKeyDown}>
<div className={fieldClasses}>
{prefixContainer}
{fieldInput}
<Tooltip
{...tooltipProps}
placement={tooltipAlignment}
description=""
caption={tooltipContent || this.state.feedback}
open={tooltipOpen}
onOpenChange={this.onTooltipOpenChange}
>
{fieldInput}
</Tooltip>
<label htmlFor={this.id}>{this.props.label}</label>
{postfixContainer}
{fieldTooltip}
</div>
);
}

View file

@ -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<unknown>;
isUserAvatar?: boolean;
onClick?(ev: MouseEvent<HTMLInputElement>): void;
@ -82,34 +82,24 @@ const MiniAvatarUploader: React.FC<IProps> = ({
accept="image/*"
/>
<AccessibleButton
className={classNames("mx_MiniAvatarUploader", {
mx_MiniAvatarUploader_busy: busy,
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
})}
disabled={busy}
onClick={() => {
uploadRef.current?.click();
}}
onMouseOver={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{children}
<div className="mx_MiniAvatarUploader_indicator">
{busy ? <Spinner w={20} h={20} /> : <div className="mx_MiniAvatarUploader_cameraIcon" />}
</div>
<div
className={classNames("mx_Tooltip", {
mx_Tooltip_visible: visible,
mx_Tooltip_invisible: !visible,
<Tooltip label={label!} open={visible} onOpenChange={setHover}>
<AccessibleButton
className={classNames("mx_MiniAvatarUploader", {
mx_MiniAvatarUploader_busy: busy,
mx_MiniAvatarUploader_hasAvatar: hasAvatar,
})}
disabled={busy}
onClick={() => {
uploadRef.current?.click();
}}
>
<div className="mx_Tooltip_chevron" />
{label}
</div>
</AccessibleButton>
{children}
<div className="mx_MiniAvatarUploader_indicator">
{busy ? <Spinner w={20} h={20} /> : <div className="mx_MiniAvatarUploader_cameraIcon" />}
</div>
</AccessibleButton>
</Tooltip>
</React.Fragment>
);
};

View file

@ -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<Pick<CSSProperties, "display" | "right" | "top" | "transform" | "left">>;
/**
* @deprecated Use [compound tooltip](https://element-hq.github.io/compound-web/?path=/docs/tooltip--docs) instead
*/
export default class Tooltip extends React.PureComponent<ITooltipProps, State> {
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 = (
<div id={this.props.id} role={this.props.role || "tooltip"} className={tooltipClasses} style={style}>
<div className="mx_Tooltip_chevron" />
{this.props.label}
</div>
);
return <div className={this.props.className}>{ReactDOM.createPortal(tooltip, Tooltip.container)}</div>;
}
}

View file

@ -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<T = void, D = void>({
summary = content ? <div className="mx_Validation_description">{content}</div> : undefined;
}
let feedback: ReactChild | undefined;
let feedback: JSX.Element | undefined;
if (summary || details) {
feedback = (
<div className="mx_Validation">

View file

@ -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 (
<span
className={classNames("mx_EventTile_button", {
mx_EventTile_expandButton: !expanded,
mx_EventTile_collapseButton: expanded,
})}
onClick={onClick}
/>
);
};
const CodeBlock: React.FC<Props> = ({ children, onHeightChanged }) => {
const enableSyntaxHighlightLanguageDetection = useSettingValue<boolean>("enableSyntaxHighlightLanguageDetection");
const showCodeLineNumbers = useSettingValue<boolean>("showCodeLineNumbers");
const expandCodeByDefault = useSettingValue<boolean>("expandCodeByDefault");
const [expanded, setExpanded] = useState(expandCodeByDefault);
let expandCollapseButton: JSX.Element | undefined;
if (children.textContent && children.textContent.split("\n").length >= MAX_LINES_BEFORE_COLLAPSE) {
expandCollapseButton = (
<ExpandCollapseButton
expanded={expanded}
onClick={() => {
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 = (
<span className="mx_EventTile_lineNumbers">
{Array.from({ length: number }, (_, i) => i + 1).map((i) => (
<span key={i}>{i}</span>
))}
</span>
);
}
async function highlightCode(div: HTMLElement | null): Promise<void> {
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 (
<TooltipProvider>
<pre
className={classNames({
mx_EventTile_collapsedCodeBlock: !expanded,
})}
>
{lineNumbers}
<div
style={{ display: "contents" }}
dangerouslySetInnerHTML={{ __html: children.innerHTML }}
ref={highlightCode}
/>
</pre>
{expandCollapseButton}
<CopyTextButton
getTextToCopy={() => children.getElementsByTagName("code")[0]?.textContent ?? null}
className={classNames("mx_EventTile_button mx_EventTile_copyButton", {
mx_EventTile_buttonBottom: !!expandCollapseButton,
})}
/>
</TooltipProvider>
);
};
export default CodeBlock;

View file

@ -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<IBodyProps, IState> {
private readonly contentRef = createRef<HTMLDivElement>();
private unmounted = false;
private pills: Element[] = [];
private tooltips: Element[] = [];
private reactRoots: Element[] = [];
public static contextType = RoomContext;
public declare context: React.ContextType<typeof RoomContext>;
@ -76,7 +70,6 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
// 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<IBodyProps, IState> {
}
// Wrap a div around <pre> so that the copy button can be correctly positioned
// when the <pre> 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<IBodyProps, IState> {
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<void> => {
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<void> => {
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 <pre> block
pre.parentNode?.replaceChild(div, pre);
// Append <pre> 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<void> {
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(<CodeBlock onHeightChanged={this.props.onHeightChanged}>{pre}</CodeBlock>, root);
}
public componentDidUpdate(prevProps: Readonly<IBodyProps>): void {
@ -281,12 +130,16 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
}
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<IBodyProps>, nextState: Readonly<IState>): boolean {

View file

@ -120,7 +120,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
this.setState({ idServer: u });
};
private getTooltip = (): ReactNode => {
private getTooltip = (): JSX.Element | undefined => {
if (this.state.checking) {
return (
<div>
@ -131,7 +131,7 @@ export default class SetIdServer extends React.Component<IProps, IState> {
} else if (this.state.error) {
return <strong className="warning">{this.state.error}</strong>;
} else {
return null;
return undefined;
}
};

View file

@ -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 () => {

View file

@ -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("<TextualBody />", () => {
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))",
"<pre><code class=\"language-py\"># 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</code></pre>\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 <a href="https://matrix.to/#/@user:server">Member</a>');

View file

@ -50,14 +50,20 @@ exports[`<TextualBody /> renders formatted m.text correctly linkification is not
1
</span>
</span>
<code>
https://matrix.org/
<div
style="display: contents;"
>
<code>
https://matrix.org/
</code>
<span />
</code>
</div>
</pre>
<span
class="mx_EventTile_button mx_EventTile_copyButton "
<div
aria-label="Copy"
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton"
role="button"
tabindex="0"
/>
</div>
@ -254,14 +260,20 @@ exports[`<TextualBody /> renders formatted m.text correctly pills do not appear
1
</span>
</span>
<code>
@room
<div
style="display: contents;"
>
<code>
@room
</code>
<span />
</code>
</div>
</pre>
<span
class="mx_EventTile_button mx_EventTile_copyButton "
<div
aria-label="Copy"
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton"
role="button"
tabindex="0"
/>
</div>
@ -321,6 +333,133 @@ exports[`<TextualBody /> renders formatted m.text correctly renders formatted bo
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly should syntax highlight code blocks 1`] = `
<div
class="mx_EventTile_body markdown-body translate"
dir="auto"
>
<div
class="mx_EventTile_pre_container"
>
<pre
class="mx_EventTile_collapsedCodeBlock"
>
<span
class="mx_EventTile_lineNumbers"
>
<span>
1
</span>
<span>
2
</span>
<span>
3
</span>
<span>
4
</span>
<span>
5
</span>
<span>
6
</span>
<span>
7
</span>
<span>
8
</span>
<span>
9
</span>
<span>
10
</span>
</span>
<div
style="display: contents;"
>
<code
class="language-py"
>
<span
class="hljs-comment"
>
# Python Program to calculate the square root
</span>
<span
class="hljs-comment"
>
# Note: change this value for a different result
</span>
num =
<span
class="hljs-number"
>
8
</span>
<span
class="hljs-comment"
>
# To take the input from the user
</span>
<span
class="hljs-comment"
>
#num = float(input('Enter a number: '))
</span>
num_sqrt = num **
<span
class="hljs-number"
>
0.5
</span>
<span
class="hljs-built_in"
>
print
</span>
(
<span
class="hljs-string"
>
'The square root of %0.3f is %0.3f'
</span>
%(num ,num_sqrt))
</code>
</div>
</pre>
<span
class="mx_EventTile_button mx_EventTile_expandButton"
/>
<div
aria-label="Copy"
class="mx_AccessibleButton mx_EventTile_button mx_EventTile_copyButton mx_EventTile_buttonBottom"
role="button"
tabindex="0"
/>
</div>
</div>
`;
exports[`<TextualBody /> renders formatted m.text correctly spoilers get injected properly into the DOM 1`] = `
<div
class="mx_EventTile_body markdown-body translate"

View file

@ -3490,15 +3490,15 @@
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
"@vector-im/compound-design-tokens@1.8.0", "@vector-im/compound-design-tokens@^1.8.0":
"@vector-im/compound-design-tokens@^1.8.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.8.0.tgz#bc844cb6b9842c1eb8e5c42f5cedcaf51a49b86f"
integrity sha512-PtQMG7kDzwtjw/fLKD63uWP5rJ8cgWc/aXarfEzxYUf9ceWxBajnYOBI2jDqtE3WIUe9uTVBzNEvmOBG/VIgTA==
"@vector-im/compound-web@7.0.0", "@vector-im/compound-web@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.0.0.tgz#2e31711ad6407a667b08ebf67c54f643902d47eb"
integrity sha512-ctK+SQdGyaPeylxC2rVePkVfQZK1ftjWc9XbzYoIbZyu4mihgjHgLhd1i02QsNGIAvpxMDxqHjVD8SsrOB2/0g==
"@vector-im/compound-web@^7.1.0":
version "7.1.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.1.0.tgz#d1e2ef9bd7e08e8ac165aabcc40bca528cb5e80d"
integrity sha512-b2q5lSemKnWCA0rHTWyfw5I+ZsQzAJCTL6Zya79vArptaQBxLksjAubErsOG80uRqwAiNUZUx+13eaxKXZI1Sw==
dependencies:
"@floating-ui/react" "^0.26.24"
"@radix-ui/react-context-menu" "^2.2.1"