Refactor className and children to component properties instead och view model snapshots in shared components (#32711)
* Refactor className? to component property in EncryptionEventView * Refactor extraClassNames to default react className as component property for DecryptionFailureBodyView * Refactor className to component property for MessageTimestampView * Refactor className and children to component properties for ReactionsRowButton * Refactor className to component property for DisambiguatedProfile * Refactor className to a component property in DateSeparatorView * Fix for lint errors and EncryptionEventView unsupported icon color * EncryptionEventView fix for icon color css specificity/order
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 7.8 KiB |
@@ -5,6 +5,10 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.error {
|
||||
.content svg[data-state="supported"] {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.content svg[data-state="unsupported"] {
|
||||
color: var(--cpd-color-icon-critical-primary);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type EncryptionEventProps = EncryptionEventViewSnapshot;
|
||||
|
||||
const EncryptionEventViewWrapperImpl = ({ ...rest }: EncryptionEventProps): JSX.Element => {
|
||||
const EncryptionEventViewWrapperImpl = ({
|
||||
className,
|
||||
...rest
|
||||
}: EncryptionEventProps & { className?: string }): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {});
|
||||
|
||||
return <EncryptionEventView vm={vm} />;
|
||||
return <EncryptionEventView vm={vm} className={className} />;
|
||||
};
|
||||
const EncryptionEventViewWrapper = withViewDocs(EncryptionEventViewWrapperImpl, EncryptionEventView);
|
||||
|
||||
|
||||
@@ -36,9 +36,8 @@ describe("EncryptionEventView", () => {
|
||||
state,
|
||||
encryptedStateEvents,
|
||||
userName,
|
||||
className,
|
||||
});
|
||||
render(<EncryptionEventView vm={vm} />);
|
||||
render(<EncryptionEventView vm={vm} className={className} />);
|
||||
};
|
||||
|
||||
it("renders Default story", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import { LockSolidIcon, ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { type ViewModel, useViewModel } from "../../viewmodel";
|
||||
import styles from "./EncryptionEventView.module.css";
|
||||
@@ -35,8 +36,6 @@ export type EncryptionEventViewSnapshot = {
|
||||
encryptedStateEvents?: boolean;
|
||||
/** Display name for DM partner, used by ENABLED_DM subtitle text. */
|
||||
userName?: string;
|
||||
/** Optional CSS classes passed through to EventTileBubble. */
|
||||
className?: string;
|
||||
/** Optional timestamp element rendered in the EventTileBubble footer slot. */
|
||||
timestamp?: JSX.Element;
|
||||
};
|
||||
@@ -52,16 +51,35 @@ export interface EncryptionEventViewProps {
|
||||
*/
|
||||
vm: ViewModel<EncryptionEventViewSnapshot>;
|
||||
/**
|
||||
* Ref forwarded to the root DOM element.
|
||||
* Optional CSS classes passed through to EventTileBubble.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional Ref forwarded to the root DOM element.
|
||||
*/
|
||||
ref?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function EncryptionEventView({ vm, ref }: Readonly<EncryptionEventViewProps>): JSX.Element {
|
||||
/**
|
||||
* Renders a timeline bubble describing an encryption-related room event.
|
||||
*
|
||||
* Text and icon are selected from `snapshot.state` with optional context:
|
||||
* - `encryptedStateEvents` switches to state-event specific wording.
|
||||
* - `userName` is used for DM-specific subtitle text.
|
||||
* - `timestamp` renders in the bubble footer slot.
|
||||
*
|
||||
* Use `className` for host-level styling, following the default React pattern.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <EncryptionEventView vm={encryptionEventVm} className="mx_EncryptionEvent" />
|
||||
* ```
|
||||
*/
|
||||
export function EncryptionEventView({ vm, ref, className }: Readonly<EncryptionEventViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const { state, encryptedStateEvents, userName, className, timestamp } = useViewModel(vm);
|
||||
const { state, encryptedStateEvents, userName, timestamp } = useViewModel(vm);
|
||||
|
||||
let icon = <LockSolidIcon />;
|
||||
let icon = <LockSolidIcon data-state="supported" />;
|
||||
let title = encryptedStateEvents ? _t("common|state_encryption_enabled") : _t("common|encryption_enabled");
|
||||
let subtitle = "";
|
||||
|
||||
@@ -86,14 +104,20 @@ export function EncryptionEventView({ vm, ref }: Readonly<EncryptionEventViewPro
|
||||
break;
|
||||
case EncryptionEventState.UNSUPPORTED:
|
||||
default:
|
||||
icon = <ErrorSolidIcon className={styles.error} />;
|
||||
icon = <ErrorSolidIcon data-state="unsupported" />;
|
||||
title = _t("timeline|m.room.encryption|disabled");
|
||||
subtitle = _t("timeline|m.room.encryption|unsupported");
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<EventTileBubble icon={icon} className={className} title={title} subtitle={subtitle} ref={ref}>
|
||||
<EventTileBubble
|
||||
icon={icon}
|
||||
className={classNames(className, styles.content)}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
ref={ref}
|
||||
>
|
||||
{timestamp}
|
||||
</EventTileBubble>
|
||||
);
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
exports[`EncryptionEventView > renders Default story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -33,9 +34,10 @@ exports[`EncryptionEventView > renders Default story 1`] = `
|
||||
exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -63,9 +65,10 @@ exports[`EncryptionEventView > renders DisableAttempt story 1`] = `
|
||||
exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -93,9 +96,10 @@ exports[`EncryptionEventView > renders EnabledDirectMessage story 1`] = `
|
||||
exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -123,9 +127,10 @@ exports[`EncryptionEventView > renders EnabledLocalRoom story 1`] = `
|
||||
exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -153,9 +158,10 @@ exports[`EncryptionEventView > renders ParametersChanged story 1`] = `
|
||||
exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -183,10 +189,10 @@ exports[`EncryptionEventView > renders StateEncryptionEnabled story 1`] = `
|
||||
exports[`EncryptionEventView > renders Unsupported story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
class="error"
|
||||
data-state="unsupported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -214,9 +220,10 @@ exports[`EncryptionEventView > renders Unsupported story 1`] = `
|
||||
exports[`EncryptionEventView > renders WithTimestamp story 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
class="container content"
|
||||
>
|
||||
<svg
|
||||
data-state="supported"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -21,7 +21,7 @@ export * from "./message-body/MessageTimestampView";
|
||||
export * from "./message-body/DecryptionFailureBodyView";
|
||||
export * from "./message-body/ReactionsRowButtonTooltip";
|
||||
export * from "./message-body/ReactionsRowButton";
|
||||
export * from "./message-body/ReactionRow";
|
||||
export * from "./message-body/ReactionsRow";
|
||||
export * from "./message-body/TimelineSeparator/";
|
||||
export * from "./pill-input/Pill";
|
||||
export * from "./pill-input/PillInput";
|
||||
|
||||
@@ -18,10 +18,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type DecryptionFailureBodyProps = DecryptionFailureBodyViewSnapshot;
|
||||
|
||||
const DecryptionFailureBodyViewWrapperImpl = ({ ...rest }: DecryptionFailureBodyProps): JSX.Element => {
|
||||
const DecryptionFailureBodyViewWrapperImpl = ({
|
||||
className,
|
||||
...rest
|
||||
}: DecryptionFailureBodyProps & { className?: string }): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {});
|
||||
|
||||
return <DecryptionFailureBodyView vm={vm} />;
|
||||
return <DecryptionFailureBodyView vm={vm} className={className} />;
|
||||
};
|
||||
const DecryptionFailureBodyViewWrapper = withViewDocs(DecryptionFailureBodyViewWrapperImpl, DecryptionFailureBodyView);
|
||||
|
||||
@@ -40,7 +43,7 @@ const meta = {
|
||||
args: {
|
||||
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
isLocalDeviceVerified: true,
|
||||
extraClassNames: ["extra_class"],
|
||||
className: "extra_class",
|
||||
},
|
||||
} satisfies Meta<typeof DecryptionFailureBodyViewWrapper>;
|
||||
|
||||
@@ -49,24 +52,17 @@ type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const HasExtraClassNames: Story = {
|
||||
args: {
|
||||
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
extraClassNames: ["extra_class_1", "extra_class_2"],
|
||||
},
|
||||
};
|
||||
|
||||
export const HasErrorClassName: Story = {
|
||||
args: {
|
||||
decryptionFailureReason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE,
|
||||
extraClassNames: undefined,
|
||||
className: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
export const HasErrorBlockIcon: Story = {
|
||||
args: {
|
||||
decryptionFailureReason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
|
||||
extraClassNames: undefined,
|
||||
className: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -74,7 +70,7 @@ export const HasBackupConfiguredVerifiedFalse: Story = {
|
||||
args: {
|
||||
decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||
isLocalDeviceVerified: false,
|
||||
extraClassNames: undefined,
|
||||
className: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -82,6 +78,6 @@ export const HasBackupConfiguredVerifiedTrue: Story = {
|
||||
args: {
|
||||
decryptionFailureReason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||
isLocalDeviceVerified: true,
|
||||
extraClassNames: undefined,
|
||||
className: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,26 +5,23 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { render } from "@test-utils";
|
||||
import React from "react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { DecryptionFailureBodyView, DecryptionFailureReason } from "./DecryptionFailureBodyView";
|
||||
import { MockViewModel } from "../../viewmodel";
|
||||
import * as stories from "./DecryptionFailureBodyView.stories";
|
||||
|
||||
const { HasExtraClassNames } = composeStories(stories);
|
||||
|
||||
describe("DecryptionFailureBodyView", () => {
|
||||
function customRender(
|
||||
decryptionFailureReason: DecryptionFailureReason,
|
||||
isLocalDeviceVerified: boolean = false,
|
||||
extraClassNames: string[] | undefined = undefined,
|
||||
className: string | undefined = undefined,
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<DecryptionFailureBodyView
|
||||
vm={new MockViewModel({ decryptionFailureReason, isLocalDeviceVerified, extraClassNames })}
|
||||
vm={new MockViewModel({ decryptionFailureReason, isLocalDeviceVerified })}
|
||||
className={className}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
@@ -38,12 +35,14 @@ describe("DecryptionFailureBodyView", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("Should display with extra class names", () => {
|
||||
// When
|
||||
const { container } = render(<HasExtraClassNames />);
|
||||
it("applies custom className to the root element", () => {
|
||||
const { container } = customRender(
|
||||
DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
false,
|
||||
"extra_class_1 extra_class_2",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(container).toMatchSnapshot();
|
||||
expect(container.firstChild).toHaveClass("extra_class_1", "extra_class_2");
|
||||
});
|
||||
|
||||
it.each([true, false])(`Should display "Unable to decrypt message and device verification is %s"`, (verified) => {
|
||||
|
||||
@@ -65,14 +65,14 @@ export interface DecryptionFailureBodyViewSnapshot {
|
||||
* The local device verification state.
|
||||
*/
|
||||
isLocalDeviceVerified?: boolean;
|
||||
/**
|
||||
* Extra CSS classes to apply to the component
|
||||
*/
|
||||
extraClassNames?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the component.
|
||||
*
|
||||
* Snapshot data is intentionally content-focused (`decryptionFailureReason`
|
||||
* plus optional `isLocalDeviceVerified`). Container styling is supplied
|
||||
* via component props.
|
||||
*/
|
||||
export type DecryptionFailureBodyViewModel = ViewModel<DecryptionFailureBodyViewSnapshot>;
|
||||
|
||||
@@ -81,6 +81,10 @@ interface DecryptionFailureBodyViewProps {
|
||||
* The view model for the component.
|
||||
*/
|
||||
vm: DecryptionFailureBodyViewModel;
|
||||
/**
|
||||
* Optional CSS class names to apply to the component container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* React ref to attach to any React components returned
|
||||
*/
|
||||
@@ -156,17 +160,27 @@ function errorClassName(decryptionFailureReason: DecryptionFailureReason): strin
|
||||
}
|
||||
|
||||
/**
|
||||
* A placeholder element for messages that could not be decrypted
|
||||
* Renders a message-body placeholder for events that could not be decrypted.
|
||||
*
|
||||
* Message copy and warning styling are derived from snapshot values:
|
||||
* - `decryptionFailureReason` selects the base text/variant.
|
||||
* - `isLocalDeviceVerified` influences historical-backup messaging.
|
||||
*
|
||||
* Use `className` for host-level container styling, following standard React patterns.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <DecryptionFailureBodyView vm={DecryptionFailureBodyViewModel} />
|
||||
* <DecryptionFailureBodyView vm={decryptionFailureBodyVm} className="mx_DecryptionFailureBody" />
|
||||
* ```
|
||||
*/
|
||||
export function DecryptionFailureBodyView({ vm, ref }: Readonly<DecryptionFailureBodyViewProps>): JSX.Element {
|
||||
export function DecryptionFailureBodyView({
|
||||
vm,
|
||||
ref,
|
||||
className,
|
||||
}: Readonly<DecryptionFailureBodyViewProps>): JSX.Element {
|
||||
const i18nApi = useI18n();
|
||||
const { decryptionFailureReason, isLocalDeviceVerified, extraClassNames } = useViewModel(vm);
|
||||
const classes = classNames(styles.content, errorClassName(decryptionFailureReason), extraClassNames);
|
||||
const { decryptionFailureReason, isLocalDeviceVerified } = useViewModel(vm);
|
||||
const classes = classNames(styles.content, errorClassName(decryptionFailureReason), className);
|
||||
|
||||
return (
|
||||
<div className={classes} ref={ref}>
|
||||
|
||||
@@ -40,16 +40,6 @@ exports[`DecryptionFailureBodyView > Should display "Unable to decrypt message a
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DecryptionFailureBodyView > Should display with extra class names 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="content extra_class_1 extra_class_2"
|
||||
>
|
||||
Unable to decrypt message
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`DecryptionFailureBodyView > should handle historical messages with no key backup and device verification is false 1`] = `
|
||||
<div>
|
||||
<div
|
||||
|
||||
@@ -18,12 +18,17 @@ import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type MessageTimestampProps = MessageTimestampViewSnapshot & MessageTimestampViewActions;
|
||||
const MessageTimestampWrapperImpl = ({ onClick, onContextMenu, ...rest }: MessageTimestampProps): ReactNode => {
|
||||
const MessageTimestampWrapperImpl = ({
|
||||
onClick,
|
||||
onContextMenu,
|
||||
className,
|
||||
...rest
|
||||
}: MessageTimestampProps & { className?: string }): ReactNode => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onClick,
|
||||
onContextMenu,
|
||||
});
|
||||
return <MessageTimestampView vm={vm} />;
|
||||
return <MessageTimestampView vm={vm} className={className} />;
|
||||
};
|
||||
const MessageTimestampWrapper = withViewDocs(MessageTimestampWrapperImpl, MessageTimestampView);
|
||||
|
||||
@@ -69,12 +74,6 @@ export const HasInhibitTooltip: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const HasExtraClassNames: Story = {
|
||||
args: {
|
||||
className: "extra_class_1 extra_class_2",
|
||||
},
|
||||
};
|
||||
|
||||
export const HasHref: Story = {
|
||||
args: {
|
||||
href: "~",
|
||||
|
||||
@@ -21,7 +21,7 @@ import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
|
||||
import { I18nContext } from "../../utils/i18nContext.ts";
|
||||
import { I18nApi } from "../../index.ts";
|
||||
|
||||
const { Default, HasHref, HasExtraClassNames } = composeStories(stories);
|
||||
const { Default, HasHref } = composeStories(stories);
|
||||
|
||||
const renderWithI18n = (ui: React.ReactElement): ReturnType<typeof render> =>
|
||||
render(ui, {
|
||||
@@ -38,9 +38,16 @@ describe("MessageTimestampView", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders the message timestamp with extra class names", async () => {
|
||||
const { container } = render(<HasExtraClassNames />);
|
||||
expect(container).toMatchSnapshot();
|
||||
it("applies custom className to the timestamp element", async () => {
|
||||
const vm = new MockViewModel<MessageTimestampViewSnapshot>({
|
||||
ts: "04:58",
|
||||
tsSentAt: "Thu, 17 Nov 2022, 4:58:32 pm",
|
||||
});
|
||||
|
||||
renderWithI18n(<MessageTimestampView vm={vm} className="extra_class_1 extra_class_2" />);
|
||||
|
||||
const target = screen.getByText("04:58");
|
||||
expect(target).toHaveClass("extra_class_1", "extra_class_2");
|
||||
});
|
||||
|
||||
it("renders the message timestamp with href", async () => {
|
||||
|
||||
@@ -32,10 +32,6 @@ export interface MessageTimestampViewSnapshot {
|
||||
* If set to true then no tooltip will be shown
|
||||
*/
|
||||
inhibitTooltip?: boolean;
|
||||
/**
|
||||
* Extra class name to apply to the component
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* If specified, will be rendered as an anchor bearing the href, a `span` element will be used otherwise
|
||||
*/
|
||||
@@ -55,6 +51,9 @@ export interface MessageTimestampViewActions {
|
||||
|
||||
/**
|
||||
* The view model for the message timestamp.
|
||||
*
|
||||
* Snapshot data describes timestamp content and rendering behavior, while
|
||||
* container styling is supplied via component props.
|
||||
*/
|
||||
export type MessageTimestampViewModel = ViewModel<MessageTimestampViewSnapshot> & MessageTimestampViewActions;
|
||||
|
||||
@@ -63,6 +62,10 @@ interface MessageTimestampViewProps {
|
||||
* The view model for the message timestamp.
|
||||
*/
|
||||
vm: MessageTimestampViewModel;
|
||||
/**
|
||||
* Optional CSS class name to apply to the component.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,17 +73,18 @@ interface MessageTimestampViewProps {
|
||||
*
|
||||
* The view model provides the timestamp values and display options. The component
|
||||
* can render as a link when `href` is set, and can show both sent-at and received-at
|
||||
* times in the tooltip when `tsReceivedAt` is provided.
|
||||
* times in the tooltip when `tsReceivedAt` is provided. Use `className` for
|
||||
* host-level styling.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <MessageTimestampView vm={messageTimestampViewModel} />
|
||||
* <MessageTimestampView vm={messageTimestampViewModel} className="mx_MessageTimestamp" />
|
||||
* ```
|
||||
*/
|
||||
export function MessageTimestampView({ vm }: Readonly<MessageTimestampViewProps>): JSX.Element {
|
||||
export function MessageTimestampView({ vm, className }: Readonly<MessageTimestampViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
|
||||
const { ts, tsSentAt, tsReceivedAt, inhibitTooltip, className, href } = useViewModel(vm);
|
||||
const { ts, tsSentAt, tsReceivedAt, inhibitTooltip, href } = useViewModel(vm);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
|
||||
if (vm.onClick) {
|
||||
|
||||
@@ -12,18 +12,6 @@ exports[`MessageTimestampView > renders the message timestamp in default state 1
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MessageTimestampView > renders the message timestamp with extra class names 1`] = `
|
||||
<div>
|
||||
<span
|
||||
aria-live="off"
|
||||
class="extra_class_1 extra_class_2 content"
|
||||
tabindex="0"
|
||||
>
|
||||
04:58
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`MessageTimestampView > renders the message timestamp with href 1`] = `
|
||||
<div>
|
||||
<a
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
import React, { type JSX, type ReactNode } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
@@ -60,15 +60,21 @@ const ReactionsRowViewWrapper = ({
|
||||
onShowAllClick,
|
||||
onAddReactionClick,
|
||||
onAddReactionContextMenu,
|
||||
children,
|
||||
className,
|
||||
...snapshotProps
|
||||
}: WrapperProps): JSX.Element => {
|
||||
}: WrapperProps & { children?: ReactNode; className?: string }): JSX.Element => {
|
||||
const vm = useMockedViewModel(snapshotProps, {
|
||||
onShowAllClick: onShowAllClick ?? fn(),
|
||||
onAddReactionClick: onAddReactionClick ?? fn(),
|
||||
onAddReactionContextMenu: onAddReactionContextMenu ?? fn(),
|
||||
});
|
||||
|
||||
return <ReactionsRowView vm={vm} />;
|
||||
return (
|
||||
<ReactionsRowView vm={vm} className={className}>
|
||||
{children}
|
||||
</ReactionsRowView>
|
||||
);
|
||||
};
|
||||
|
||||
const meta = {
|
||||
@@ -68,7 +68,6 @@ describe("ReactionsRowView", () => {
|
||||
showAddReactionButton: true,
|
||||
addReactionButtonLabel: "Add reaction",
|
||||
addReactionButtonVisible: true,
|
||||
children: <span>👍</span>,
|
||||
},
|
||||
{
|
||||
onShowAllClick,
|
||||
@@ -77,7 +76,11 @@ describe("ReactionsRowView", () => {
|
||||
},
|
||||
) as ReactionsRowViewModel;
|
||||
|
||||
render(<ReactionsRowView vm={vm} />);
|
||||
render(
|
||||
<ReactionsRowView vm={vm}>
|
||||
<span>👍</span>
|
||||
</ReactionsRowView>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Show all" }));
|
||||
await user.click(screen.getByRole("button", { name: "Add reaction" }));
|
||||
@@ -87,4 +90,16 @@ describe("ReactionsRowView", () => {
|
||||
expect(onAddReactionClick).toHaveBeenCalledTimes(1);
|
||||
expect(onAddReactionContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies custom className to the toolbar container", () => {
|
||||
const vm = new MockViewModel<ReactionsRowViewSnapshot>({
|
||||
ariaLabel: "Reactions",
|
||||
isVisible: true,
|
||||
addReactionButtonLabel: "Add reaction",
|
||||
}) as ReactionsRowViewModel;
|
||||
|
||||
render(<ReactionsRowView vm={vm} className="custom-reactions-row another-class" />);
|
||||
|
||||
expect(screen.getByRole("toolbar", { name: "Reactions" })).toHaveClass("custom-reactions-row", "another-class");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type MouseEventHandler, type ReactNode } from "react";
|
||||
import React, { type JSX, type MouseEventHandler, type PropsWithChildren } from "react";
|
||||
import classNames from "classnames";
|
||||
import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
@@ -22,14 +22,6 @@ export interface ReactionsRowViewSnapshot {
|
||||
* Controls whether the row should render at all.
|
||||
*/
|
||||
isVisible: boolean;
|
||||
/**
|
||||
* Reaction button elements to render in the row.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
/**
|
||||
* Optional CSS className for the row container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Whether to render the "show all" button.
|
||||
*/
|
||||
@@ -79,14 +71,20 @@ export type ReactionsRowViewModel = ViewModel<ReactionsRowViewSnapshot, Reaction
|
||||
|
||||
interface ReactionsRowViewProps {
|
||||
vm: ReactionsRowViewModel;
|
||||
/**
|
||||
* Optional CSS className for the row container.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Reaction button elements to render in the row.
|
||||
*/
|
||||
children?: PropsWithChildren["children"];
|
||||
}
|
||||
|
||||
export function ReactionsRowView({ vm }: Readonly<ReactionsRowViewProps>): JSX.Element {
|
||||
export function ReactionsRowView({ vm, className, children }: Readonly<ReactionsRowViewProps>): JSX.Element {
|
||||
const {
|
||||
ariaLabel,
|
||||
isVisible,
|
||||
children,
|
||||
className,
|
||||
showAllButtonVisible,
|
||||
showAllButtonLabel,
|
||||
showAddReactionButton,
|
||||
@@ -19,9 +19,13 @@ import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type DisambiguatedProfileProps = DisambiguatedProfileViewSnapshot & DisambiguatedProfileViewActions;
|
||||
|
||||
const DisambiguatedProfileViewWrapperImpl = ({ onClick, ...rest }: DisambiguatedProfileProps): JSX.Element => {
|
||||
const DisambiguatedProfileViewWrapperImpl = ({
|
||||
onClick,
|
||||
className,
|
||||
...rest
|
||||
}: DisambiguatedProfileProps & { className?: string }): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, { onClick });
|
||||
return <DisambiguatedProfileView vm={vm} />;
|
||||
return <DisambiguatedProfileView vm={vm} className={className} />;
|
||||
};
|
||||
const DisambiguatedProfileViewWrapper = withViewDocs(DisambiguatedProfileViewWrapperImpl, DisambiguatedProfileView);
|
||||
|
||||
|
||||
@@ -209,4 +209,14 @@ describe("DisambiguatedProfileView", () => {
|
||||
const displayNameElement = screen.getByText("Emphasized User");
|
||||
expect(displayNameElement).toHaveClass("mx_DisambiguatedProfile_displayName");
|
||||
});
|
||||
|
||||
it("should apply custom className to the profile container", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
displayName: "Classed User",
|
||||
});
|
||||
|
||||
render(<DisambiguatedProfileView vm={vm} className="custom-profile another-class" />);
|
||||
const profileContainer = getProfileContainer("Classed User");
|
||||
expect(profileContainer).toHaveClass("custom-profile", "another-class");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,10 +24,6 @@ export interface DisambiguatedProfileViewSnapshot {
|
||||
* Undefined if coloring is not enabled.
|
||||
*/
|
||||
colorClass?: string;
|
||||
/**
|
||||
* The CSS class name.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* The formatted user identifier to display when disambiguation is needed.
|
||||
* Undefined if disambiguation is not required.
|
||||
@@ -67,6 +63,10 @@ interface DisambiguatedProfileViewProps {
|
||||
* The view model for the disambiguated profile.
|
||||
*/
|
||||
vm: DisambiguatedProfileViewModel;
|
||||
/**
|
||||
* Optional CSS class name applied to the profile container.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,8 +79,8 @@ interface DisambiguatedProfileViewProps {
|
||||
* <DisambiguatedProfileView vm={disambiguatedProfileViewModel} />
|
||||
* ```
|
||||
*/
|
||||
export function DisambiguatedProfileView({ vm }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
|
||||
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName, className } = useViewModel(vm);
|
||||
export function DisambiguatedProfileView({ vm, className }: Readonly<DisambiguatedProfileViewProps>): JSX.Element {
|
||||
const { displayName, colorClass, displayIdentifier, title, emphasizeDisplayName } = useViewModel(vm);
|
||||
|
||||
const displayNameClasses = classNames(colorClass, {
|
||||
[styles.disambiguatedProfile_displayName]: emphasizeDisplayName,
|
||||
|
||||
@@ -20,10 +20,11 @@ const DateSeparatorViewWrapperImpl = ({
|
||||
onLastMonthPicked,
|
||||
onBeginningPicked,
|
||||
onDatePicked,
|
||||
className,
|
||||
...rest
|
||||
}: DateSeparatorProps): JSX.Element => {
|
||||
}: DateSeparatorProps & { className?: string }): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked });
|
||||
return <DateSeparatorView vm={vm} />;
|
||||
return <DateSeparatorView vm={vm} className={className} />;
|
||||
};
|
||||
const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView);
|
||||
|
||||
|
||||
@@ -29,10 +29,6 @@ export interface DateSeparatorViewSnapshot {
|
||||
* Reference date as input format used to prefill the jump-to-date picker value.
|
||||
*/
|
||||
jumpFromDate?: string;
|
||||
/**
|
||||
* Extra CSS classes to apply to the component.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DateSeparatorViewActions {
|
||||
@@ -56,6 +52,10 @@ interface DateSeparatorViewProps {
|
||||
* The view model for the component.
|
||||
*/
|
||||
vm: DateSeparatorViewModel;
|
||||
/**
|
||||
* Extra CSS classes to apply to the component.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,8 +68,8 @@ interface DateSeparatorViewProps {
|
||||
* <DateSeparatorView vm={vm} />
|
||||
* ```
|
||||
*/
|
||||
export function DateSeparatorView({ vm }: Readonly<DateSeparatorViewProps>): JSX.Element {
|
||||
const { label, className, jumpToEnabled } = useViewModel(vm);
|
||||
export function DateSeparatorView({ vm, className }: Readonly<DateSeparatorViewProps>): JSX.Element {
|
||||
const { label, jumpToEnabled } = useViewModel(vm);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [isTriggerHovered, setIsTriggerHovered] = useState(false);
|
||||
const [isTriggerFocused, setIsTriggerFocused] = useState(false);
|
||||
|
||||