Move ReactionRow To Shared Components MVVM (#32634)

* Init shared component structure

* Storybook implementation

* Add snapshots of storybook examples

* ViewModel Creation + Implementation In EventTile.tsx

* Prettier

* Update HTML snapshot

* Add onhover pointer on bottons

* Added compound web tooltip

* Removed possible of undefined on label

* Update snapshots

* Update setters to use merge instead of updating full snapshot

* adapt view model test for setters change

* Actions should be passed to viewmodel fix

* replace ReactionsRowWrapper forceRender with explicit reaction state

* Update snapshot
This commit is contained in:
Zack
2026-03-02 14:59:04 +01:00
committed by GitHub
parent 11030ae68d
commit fe84501e95
21 changed files with 1175 additions and 290 deletions
+1
View File
@@ -21,6 +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/TimelineSeparator/";
export * from "./pill-input/Pill";
export * from "./pill-input/PillInput";
@@ -0,0 +1,70 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.reactionsRow {
color: var(--cpd-color-text-primary);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--cpd-space-1x);
}
.showAllButton {
all: unset;
color: var(--cpd-color-text-secondary);
font-size: var(--cpd-font-size-body-xs);
line-height: 20px;
cursor: pointer;
}
.showAllButton:hover,
.showAllButton:focus-visible {
color: var(--cpd-color-text-primary);
}
.addReactionButton {
all: unset;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: var(--cpd-space-1x);
margin-inline: var(--cpd-space-1x);
border-radius: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
visibility: hidden;
cursor: pointer;
}
.reactionsRow:hover .addReactionButton {
visibility: visible;
}
.addReactionButtonVisible {
visibility: visible;
}
.addReactionButtonActive {
visibility: visible;
}
.addReactionButtonDisabled {
cursor: not-allowed;
opacity: 0.6;
}
.addReactionButton:hover,
.addReactionButton:focus-visible,
.addReactionButtonActive {
color: var(--cpd-color-icon-primary);
}
.addReactionButton svg {
width: 16px;
height: 16px;
}
@@ -0,0 +1,119 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ReactionsRowButtonView } from "../ReactionsRowButton";
import { useMockedViewModel } from "../../viewmodel";
import { ReactionsRowView, type ReactionsRowViewActions, type ReactionsRowViewSnapshot } from "./ReactionsRowView";
interface MockReactionButtonProps {
content: string;
count: number;
isSelected?: boolean;
}
const MockReactionButton = ({ content, count, isSelected }: Readonly<MockReactionButtonProps>): JSX.Element => {
const tooltipVm = useMockedViewModel(
{
formattedSenders: "Alice and Bob",
caption: undefined,
tooltipOpen: false,
},
{},
);
const vm = useMockedViewModel(
{
content,
count,
"isSelected": !!isSelected,
"isDisabled": false,
tooltipVm,
"aria-label": `${count} reactions for ${content}`,
},
{
onClick: fn(),
},
);
return <ReactionsRowButtonView vm={vm} />;
};
const DefaultReactionButtons = (): JSX.Element => (
<>
<MockReactionButton content="👍" count={4} isSelected />
<MockReactionButton content="🎉" count={2} />
<MockReactionButton content="👀" count={1} />
</>
);
type WrapperProps = ReactionsRowViewSnapshot & Partial<ReactionsRowViewActions>;
const ReactionsRowViewWrapper = ({
onShowAllClick,
onAddReactionClick,
onAddReactionContextMenu,
...snapshotProps
}: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {
onShowAllClick: onShowAllClick ?? fn(),
onAddReactionClick: onAddReactionClick ?? fn(),
onAddReactionContextMenu: onAddReactionContextMenu ?? fn(),
});
return <ReactionsRowView vm={vm} />;
};
const meta = {
title: "MessageBody/ReactionsRow",
component: ReactionsRowViewWrapper,
tags: ["autodocs"],
args: {
ariaLabel: "Reactions",
isVisible: true,
children: <DefaultReactionButtons />,
showAllButtonVisible: false,
showAllButtonLabel: "Show all",
showAddReactionButton: true,
addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true,
addReactionButtonActive: false,
addReactionButtonDisabled: false,
},
} satisfies Meta<typeof ReactionsRowViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithShowAllButton: Story = {
args: {
showAllButtonVisible: true,
},
};
export const AddReactionButtonActive: Story = {
args: {
addReactionButtonActive: true,
},
};
export const AddReactionButtonHiddenUntilHover: Story = {
args: {
addReactionButtonVisible: false,
},
};
export const Hidden: Story = {
args: {
isVisible: false,
},
};
@@ -0,0 +1,90 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { composeStories } from "@storybook/react-vite";
import { render, screen } from "@test-utils";
import React, { type MouseEventHandler } from "react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../viewmodel";
import {
ReactionsRowView,
type ReactionsRowViewActions,
type ReactionsRowViewModel,
type ReactionsRowViewSnapshot,
} from "./ReactionsRowView";
import * as stories from "./ReactionsRow.stories";
const { Default, WithShowAllButton, Hidden } = composeStories(stories);
describe("ReactionsRowView", () => {
it("renders the default reactions row", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the row with a show-all button", () => {
const { container } = render(<WithShowAllButton />);
expect(container).toMatchSnapshot();
});
it("does not render the row when hidden", () => {
render(<Hidden />);
expect(screen.queryByRole("toolbar")).not.toBeInTheDocument();
});
it("invokes show-all and add-reaction actions", async () => {
const user = userEvent.setup();
const onShowAllClick = vi.fn();
const onAddReactionClick = vi.fn();
const onAddReactionContextMenu = vi.fn();
class TestReactionsRowViewModel
extends MockViewModel<ReactionsRowViewSnapshot>
implements ReactionsRowViewActions
{
public onShowAllClick?: () => void;
public onAddReactionClick?: MouseEventHandler<HTMLButtonElement>;
public onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
public constructor(snapshot: ReactionsRowViewSnapshot, actions: ReactionsRowViewActions) {
super(snapshot);
Object.assign(this, actions);
}
}
const vm = new TestReactionsRowViewModel(
{
ariaLabel: "Reactions",
isVisible: true,
showAllButtonVisible: true,
showAllButtonLabel: "Show all",
showAddReactionButton: true,
addReactionButtonLabel: "Add reaction",
addReactionButtonVisible: true,
children: <span>👍</span>,
},
{
onShowAllClick,
onAddReactionClick,
onAddReactionContextMenu,
},
) as ReactionsRowViewModel;
render(<ReactionsRowView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Show all" }));
await user.click(screen.getByRole("button", { name: "Add reaction" }));
await user.pointer({ target: screen.getByRole("button", { name: "Add reaction" }), keys: "[MouseRight]" });
expect(onShowAllClick).toHaveBeenCalledTimes(1);
expect(onAddReactionClick).toHaveBeenCalledTimes(1);
expect(onAddReactionContextMenu).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,144 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type MouseEventHandler, type ReactNode } from "react";
import classNames from "classnames";
import { ReactionAddIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../viewmodel";
import styles from "./ReactionsRow.module.css";
export interface ReactionsRowViewSnapshot {
/**
* Toolbar label announced by assistive technologies.
*/
ariaLabel: string;
/**
* 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.
*/
showAllButtonVisible?: boolean;
/**
* Label shown for the "show all" button.
*/
showAllButtonLabel?: string;
/**
* Whether to render the add-reaction button.
*/
showAddReactionButton?: boolean;
/**
* Accessible label for the add-reaction button.
*/
addReactionButtonLabel: string;
/**
* Force the add-reaction button to be visible.
*/
addReactionButtonVisible?: boolean;
/**
* Marks the add-reaction button as active.
*/
addReactionButtonActive?: boolean;
/**
* Disables the add-reaction button.
*/
addReactionButtonDisabled?: boolean;
}
export interface ReactionsRowViewActions {
/**
* Invoked when the user clicks the "show all" button.
*/
onShowAllClick?: () => void;
/**
* Invoked when the user clicks the add-reaction button.
*/
onAddReactionClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked on right-click/context-menu for the add-reaction button.
*/
onAddReactionContextMenu?: MouseEventHandler<HTMLButtonElement>;
}
export type ReactionsRowViewModel = ViewModel<ReactionsRowViewSnapshot, ReactionsRowViewActions>;
interface ReactionsRowViewProps {
vm: ReactionsRowViewModel;
}
export function ReactionsRowView({ vm }: Readonly<ReactionsRowViewProps>): JSX.Element {
const {
ariaLabel,
isVisible,
children,
className,
showAllButtonVisible,
showAllButtonLabel,
showAddReactionButton,
addReactionButtonLabel,
addReactionButtonVisible,
addReactionButtonActive,
addReactionButtonDisabled,
} = useViewModel(vm);
if (!isVisible) {
return <></>;
}
const addReactionButtonClasses = classNames(styles.addReactionButton, {
[styles.addReactionButtonVisible]: addReactionButtonVisible,
[styles.addReactionButtonActive]: addReactionButtonActive,
[styles.addReactionButtonDisabled]: addReactionButtonDisabled,
});
const onAddReactionContextMenu: MouseEventHandler<HTMLButtonElement> | undefined = vm.onAddReactionContextMenu
? (event): void => {
event.preventDefault();
vm.onAddReactionContextMenu?.(event);
}
: undefined;
const addReactionButton = (
<button
type="button"
className={addReactionButtonClasses}
aria-label={addReactionButtonLabel}
disabled={addReactionButtonDisabled}
onClick={vm.onAddReactionClick}
onContextMenu={onAddReactionContextMenu}
>
<ReactionAddIcon />
</button>
);
return (
<div className={classNames(className, styles.reactionsRow)} role="toolbar" aria-label={ariaLabel}>
{children}
{showAllButtonVisible && (
<button type="button" className={styles.showAllButton} onClick={vm.onShowAllClick}>
{showAllButtonLabel}
</button>
)}
{showAddReactionButton && (
<Tooltip description={addReactionButtonLabel} placement="right">
{addReactionButton}
</Tooltip>
)}
</div>
);
}
@@ -0,0 +1,183 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ReactionsRowView > renders the default reactions row 1`] = `
<div>
<div
aria-label="Reactions"
class="reactionsRow"
role="toolbar"
>
<button
aria-label="4 reactions for 👍"
class="reactionsRowButton reactionsRowButtonSelected"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👍
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
4
</span>
</button>
<button
aria-label="2 reactions for 🎉"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
🎉
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
2
</span>
</button>
<button
aria-label="1 reactions for 👀"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👀
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
1
</span>
</button>
<button
aria-label="Add reaction"
class="addReactionButton addReactionButtonVisible"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
</div>
</div>
`;
exports[`ReactionsRowView > renders the row with a show-all button 1`] = `
<div>
<div
aria-label="Reactions"
class="reactionsRow"
role="toolbar"
>
<button
aria-label="4 reactions for 👍"
class="reactionsRowButton reactionsRowButtonSelected"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👍
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
4
</span>
</button>
<button
aria-label="2 reactions for 🎉"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
🎉
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
2
</span>
</button>
<button
aria-label="1 reactions for 👀"
class="reactionsRowButton"
tabindex="0"
type="button"
>
<span
aria-hidden="true"
class="reactionsRowButtonContent"
>
👀
</span>
<span
aria-hidden="true"
class="reactionsRowButtonCount"
>
1
</span>
</button>
<button
class="showAllButton"
type="button"
>
Show all
</button>
<button
aria-label="Add reaction"
class="addReactionButton addReactionButtonVisible"
type="button"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M14.74 2.38C13.87 2.133 12.95 2 12 2 6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10c0-.95-.133-1.87-.38-2.74a5 5 0 0 1-1.886.687 8 8 0 1 1-5.68-5.68c.1-.684.339-1.323.687-1.887"
/>
<path
d="M15.536 14.121a1 1 0 0 1 0 1.415A5 5 0 0 1 12 17c-1.38 0-2.632-.56-3.535-1.464a1 1 0 1 1 1.414-1.415A3 3 0 0 0 12 15c.829 0 1.577-.335 2.121-.879a1 1 0 0 1 1.415 0M8.5 12a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m8.5-1.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0M18 6h-1a.97.97 0 0 1-.712-.287A.97.97 0 0 1 16 5q0-.424.288-.713A.97.97 0 0 1 17 4h1V3q0-.424.288-.712A.97.97 0 0 1 19 2q.424 0 .712.288Q20 2.575 20 3v1h1q.424 0 .712.287Q22 4.576 22 5t-.288.713A.97.97 0 0 1 21 6h-1v1q0 .424-.288.713A.97.97 0 0 1 19 8a.97.97 0 0 1-.712-.287A.97.97 0 0 1 18 7z"
/>
</svg>
</button>
</div>
</div>
`;
@@ -0,0 +1,13 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
ReactionsRowView,
type ReactionsRowViewSnapshot,
type ReactionsRowViewModel,
type ReactionsRowViewActions,
} from "./ReactionsRowView";
@@ -8,6 +8,7 @@
.reactionsRowButton {
display: inline-flex;
all: unset;
cursor: pointer;
line-height: var(--cpd-font-size-heading-sm);
padding: 1px var(--cpd-space-1-5x);
border: 1px solid var(--cpd-color-gray-400);