Refactor NotificationBadge to shared MVVM (#33843)

* Refactor NotificationBadge to shared MVVM

* Added snapshot images

* Replace legacy NotificationBadge with adapter

* Fix Eslint

* Use regular NotificationBadge prop names

* Remove legacy NotificationBadge selectors

* Update TAC badge Playwright selectors

* Rename NotificationBadgeAdapter to NotificationBadge

Addresses review feedback: the component is the app-facing badge, not an
adapter, so name it NotificationBadge.

* Make NotificationBadgeView isClickable a required boolean

Addresses review feedback: there is no semantic difference between
`isClickable: false` and `isClickable: undefined` (both render the
non-interactive variant), so the field should be a plain boolean. The
view model always provides it.

* Drop redundant comparison guards in NotificationBadge view model setters

Addresses review feedback: Snapshot.merge already compares each field
with Object.is and only emits when something actually changed, so the
manual equality guards in each setter were redundant. Recompute the
snapshot unconditionally and let merge dedupe.

* Resolve notification badge labels inside NotificationBadgeView

Addresses review feedback: static UI labels belong in the view, not the
snapshot. The view now resolves the knock and unsent-message strings via
useI18n, and the snapshot exposes a showUnsentTooltip boolean instead of
a prebuilt tooltipLabel (and drops knockLabel entirely). Adds the
room|knock_sent and notifications|message_didnt_send keys to the
shared-components i18n catalogue so they resolve in tests and storybook.

* Remove unused translation because of shared components movement

* Fix typescript issue

* Fix lint issue

* Remove unused notification badge count setting

* Pass notification badge class names explicitly
This commit is contained in:
Zack
2026-08-10 08:32:23 +00:00
committed by GitHub
parent a73f8c389b
commit ce74b5f614
41 changed files with 668 additions and 398 deletions
@@ -82,6 +82,7 @@
"all_messages": "All messages",
"default_settings": "Match default settings",
"mentions_keywords": "Mentions and keywords",
"message_didnt_send": "Message didn't send. Click for info.",
"mute_room": "Mute room"
},
"release_announcement": {
@@ -101,6 +102,7 @@
"jump_to_date": "Jump to date",
"jump_to_date_beginning": "The beginning of the room",
"jump_to_date_prompt": "Pick a date to jump to",
"knock_sent": "Request to join sent",
"pinned_message_badge": "Pinned message",
"status_bar": {
"delete_all": "Delete all",
@@ -5,6 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
.notificationBadge {
box-sizing: border-box;
padding: 0;
border: 0;
font: inherit;
}
.notificationBadge:not(.visible) {
display: none;
}
@@ -18,6 +25,10 @@
justify-content: center;
}
button.notificationBadge {
cursor: pointer;
}
.notificationBadge.visible.dot {
width: 8px;
height: 8px;
@@ -6,16 +6,23 @@
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../core/viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs";
import { NotificationBadgeView, type NotificationBadgeViewSnapshot } from "./NotificationBadgeView";
import {
NotificationBadgeView,
type NotificationBadgeViewActions,
type NotificationBadgeViewSnapshot,
} from "./NotificationBadgeView";
type WrapperProps = NotificationBadgeViewSnapshot;
type WrapperProps = NotificationBadgeViewSnapshot & NotificationBadgeViewActions;
const NotificationBadgeViewWrapperImpl = ({ ...snapshotProps }: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {});
const NotificationBadgeViewWrapperImpl = ({ onClick, ...snapshotProps }: WrapperProps): JSX.Element => {
const vm = useMockedViewModel(snapshotProps, {
onClick: onClick ?? fn(),
});
return <NotificationBadgeView vm={vm} />;
};
@@ -34,7 +41,10 @@ const meta = {
isKnocked: false,
badgeType: "badge_2char",
symbol: "3",
knockLabel: "Request to join sent",
isClickable: false,
ariaLabel: undefined,
tabIndex: undefined,
showUnsentTooltip: false,
},
} satisfies Meta<typeof NotificationBadgeViewWrapper>;
@@ -65,6 +75,22 @@ export const Knocked: Story = {
},
};
export const Clickable: Story = {
args: {
isClickable: true,
ariaLabel: "Jump to first unread room",
tabIndex: 0,
},
};
export const WithTooltip: Story = {
args: {
isHighlight: true,
symbol: "!",
showUnsentTooltip: true,
},
};
export const Hidden: Story = {
args: {
shouldRender: false,
@@ -5,11 +5,13 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import React, { type JSX, type MouseEventHandler } from "react";
import classNames from "classnames";
import { AskToJoinIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { Tooltip } from "@vector-im/compound-web";
import { type ViewModel, useViewModel } from "../../core/viewmodel";
import { useI18n } from "../../core/i18n/i18nContext";
import styles from "./NotificationBadgeView.module.css";
export type NotificationBadgeType = "dot" | "badge_2char" | "badge_3char";
@@ -44,26 +46,58 @@ export interface NotificationBadgeViewSnapshot {
*/
symbol: string | null;
/**
* Accessible label for the knock icon.
* Whether to render the badge as an interactive control.
*/
knockLabel?: string;
isClickable: boolean;
/**
* Accessible label for clickable badges.
*/
ariaLabel?: string;
/**
* Tab index for clickable badges.
*/
tabIndex?: number;
/**
* Whether to show the unsent-message tooltip.
*/
showUnsentTooltip: boolean;
}
export type NotificationBadgeViewModel = ViewModel<NotificationBadgeViewSnapshot>;
export interface NotificationBadgeViewActions {
/**
* Called when an interactive badge is activated.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export type NotificationBadgeViewModel = ViewModel<NotificationBadgeViewSnapshot> & NotificationBadgeViewActions;
interface NotificationBadgeViewProps {
vm: NotificationBadgeViewModel;
className?: string;
}
export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProps>): JSX.Element {
const { shouldRender, isVisible, isNotification, isHighlight, isKnocked, badgeType, symbol, knockLabel } =
useViewModel(vm);
export function NotificationBadgeView({ vm, className }: Readonly<NotificationBadgeViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const {
shouldRender,
isVisible,
isNotification,
isHighlight,
isKnocked,
badgeType,
symbol,
isClickable,
ariaLabel,
tabIndex,
showUnsentTooltip,
} = useViewModel(vm);
if (!shouldRender) {
return <></>;
}
const classes = classNames(styles.notificationBadge, {
const classes = classNames(className, styles.notificationBadge, {
[styles.visible]: isVisible,
[styles.notification]: isNotification,
[styles.highlight]: isHighlight,
@@ -75,14 +109,26 @@ export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProp
});
const notificationLevel = isHighlight ? "highlight" : isNotification ? "notification" : undefined;
const content =
isKnocked && knockLabel ? (
<AskToJoinIcon aria-label={knockLabel} />
) : (
<span className={styles.count}>{symbol}</span>
);
const content = isKnocked ? (
<AskToJoinIcon aria-label={_t("room|knock_sent")} />
) : (
<span className={styles.count}>{symbol}</span>
);
return (
const badge = isClickable ? (
<button
type="button"
data-testid="notification-badge"
data-badge-type={badgeType}
data-notification-level={notificationLevel}
className={classes}
aria-label={ariaLabel}
tabIndex={tabIndex}
onClick={vm.onClick}
>
{content}
</button>
) : (
<div
data-testid="notification-badge"
data-badge-type={badgeType}
@@ -92,4 +138,14 @@ export function NotificationBadgeView({ vm }: Readonly<NotificationBadgeViewProp
{content}
</div>
);
if (showUnsentTooltip) {
return (
<Tooltip label={_t("notifications|message_didnt_send")} placement="right">
{badge}
</Tooltip>
);
}
return badge;
}
@@ -8,6 +8,7 @@
export {
NotificationBadgeView,
type NotificationBadgeType,
type NotificationBadgeViewActions,
type NotificationBadgeViewModel,
type NotificationBadgeViewSnapshot,
} from "./NotificationBadgeView";