Refactor UnreadNotificationBadge to shared MVVM view (#33672)

* Refactor notification badge to shared MVVM view

* Bage icons Images

* Narrow unread notification badge refactor scope

* Align unread badge MVVM pattern

* Reduce unread badge viewmodel duplication

* Document unread badge viewmodel props

* Remove legacy badge classes from shared view

* Update css for knocked door

* Update thread badge e2e selector

* Update read receipt badge e2e selectors
This commit is contained in:
Zack
2026-06-15 10:20:27 +00:00
committed by GitHub
parent 5bcec9112e
commit 0f0f8c6ba2
19 changed files with 655 additions and 24 deletions
+1
View File
@@ -16,6 +16,7 @@ export * from "./room/composer/Banner";
export * from "./room/composer/UploadButton";
export * from "./crypto/SasEmoji";
export * from "./menus/UserMenu";
export * from "./notifications/NotificationBadgeView";
export * from "./room/timeline/ReadMarker";
export * from "./room/timeline/EventPresentation";
export * from "./room/timeline/event-tile/body/EventContentBodyView";
@@ -0,0 +1,62 @@
/*
* 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.
*/
.notificationBadge:not(.visible) {
display: none;
}
.notificationBadge.visible {
background-color: var(--roomtile-default-badge-bg-color, var(--cpd-color-icon-secondary));
color: var(--cpd-color-text-on-solid-primary);
outline: 1px solid transparent;
display: flex;
align-items: center;
justify-content: center;
}
.notificationBadge.visible.dot {
width: 8px;
height: 8px;
border-radius: 8px;
background-color: var(--cpd-color-icon-primary);
}
.notificationBadge.visible.dot .count {
display: none;
}
.notificationBadge.visible.dot.notification {
background-color: var(--cpd-color-icon-success-primary);
}
.notificationBadge.visible.highlight {
background-color: var(--cpd-color-icon-critical-primary);
}
.notificationBadge.visible > svg {
width: 16px;
height: 16px;
}
.notificationBadge.visible.badge2Char {
width: 1rem;
height: 1rem;
border-radius: 1rem;
}
.notificationBadge.visible.badge3Char {
width: 1.625rem;
height: 1rem;
border-radius: 1rem;
}
.notificationBadge.visible .count {
font-size: 0.625rem;
line-height: 0.875rem;
font-weight: var(--cpd-font-weight-semibold);
color: var(--cpd-color-text-on-solid-primary);
}
@@ -0,0 +1,72 @@
/*
* 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 type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../core/viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { NotificationBadgeView, type NotificationBadgeViewSnapshot } from "./NotificationBadgeView";
type WrapperProps = NotificationBadgeViewSnapshot;
const NotificationBadgeViewWrapperImpl = ({ ...snapshotProps }: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {});
return <NotificationBadgeView vm={vm} />;
};
const NotificationBadgeViewWrapper = withViewDocs(NotificationBadgeViewWrapperImpl, NotificationBadgeView);
const meta = {
title: "Notifications/NotificationBadgeView",
component: NotificationBadgeViewWrapper,
tags: ["autodocs"],
args: {
shouldRender: true,
isVisible: true,
isNotification: false,
isHighlight: false,
isKnocked: false,
badgeType: "badge_2char",
symbol: "3",
knockLabel: "Request to join sent",
},
} satisfies Meta<typeof NotificationBadgeViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Notification: Story = {};
export const Dot: Story = {
args: {
badgeType: "dot",
symbol: null,
isNotification: true,
},
};
export const Highlight: Story = {
args: {
isHighlight: true,
symbol: "!",
},
};
export const Knocked: Story = {
args: {
isKnocked: true,
symbol: "!",
},
};
export const Hidden: Story = {
args: {
shouldRender: false,
},
};
@@ -0,0 +1,41 @@
/*
* 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 { render, screen } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import React from "react";
import { describe, expect, it } from "vitest";
import * as stories from "./NotificationBadgeView.stories";
const { Notification, Dot, Knocked, Hidden } = composeStories(stories);
describe("NotificationBadgeView", () => {
it("renders the default notification badge", () => {
const { container } = render(<Notification />);
expect(container).toMatchSnapshot();
});
it("renders a dot badge", () => {
render(<Dot />);
expect(screen.getByTestId("notification-badge")).toHaveAttribute("data-badge-type", "dot");
});
it("renders a knocked badge icon", () => {
render(<Knocked />);
expect(screen.getByLabelText("Request to join sent")).toBeInTheDocument();
});
it("does not render when hidden", () => {
const { container } = render(<Hidden />);
expect(container.firstChild).toBeNull();
});
});
@@ -0,0 +1,95 @@
/*
* 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 classNames from "classnames";
import { AskToJoinIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type ViewModel, useViewModel } from "../../core/viewmodel";
import styles from "./NotificationBadgeView.module.css";
export type NotificationBadgeType = "dot" | "badge_2char" | "badge_3char";
export interface NotificationBadgeViewSnapshot {
/**
* Controls whether the badge root should render.
*/
shouldRender: boolean;
/**
* Controls whether the badge receives the visible styling class.
*/
isVisible: boolean;
/**
* Marks the badge as a regular notification.
*/
isNotification: boolean;
/**
* Marks the badge as a highlight notification.
*/
isHighlight: boolean;
/**
* Marks the badge as representing a knock request.
*/
isKnocked: boolean;
/**
* Controls the visual badge shape.
*/
badgeType: NotificationBadgeType;
/**
* Display text for non-knock badges.
*/
symbol: string | null;
/**
* Accessible label for the knock icon.
*/
knockLabel?: string;
}
export type NotificationBadgeViewModel = ViewModel<NotificationBadgeViewSnapshot>;
interface NotificationBadgeViewProps {
vm: NotificationBadgeViewModel;
}
export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProps>): JSX.Element {
const { shouldRender, isVisible, isNotification, isHighlight, isKnocked, badgeType, symbol, knockLabel } =
useViewModel(vm);
if (!shouldRender) {
return <></>;
}
const classes = classNames(styles.notificationBadge, {
[styles.visible]: isVisible,
[styles.notification]: isNotification,
[styles.highlight]: isHighlight,
[styles.dot]: badgeType === "dot",
[styles.badge2Char]: badgeType === "badge_2char",
[styles.badge3Char]: badgeType === "badge_3char",
// Badges with text should always use light colors.
"cpd-theme-light": badgeType !== "dot",
});
const notificationLevel = isHighlight ? "highlight" : isNotification ? "notification" : undefined;
const content =
isKnocked && knockLabel ? (
<AskToJoinIcon aria-label={knockLabel} />
) : (
<span className={styles.count}>{symbol}</span>
);
return (
<div
data-testid="notification-badge"
data-badge-type={badgeType}
data-notification-level={notificationLevel}
className={classes}
>
{content}
</div>
);
}
@@ -0,0 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`NotificationBadgeView > renders the default notification badge 1`] = `
<div>
<div
class="NotificationBadgeView-module_notificationBadge NotificationBadgeView-module_visible NotificationBadgeView-module_badge2Char cpd-theme-light"
data-badge-type="badge_2char"
data-testid="notification-badge"
>
<span
class="NotificationBadgeView-module_count"
>
3
</span>
</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 {
NotificationBadgeView,
type NotificationBadgeType,
type NotificationBadgeViewModel,
type NotificationBadgeViewSnapshot,
} from "./NotificationBadgeView";