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:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -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",
|
||||
|
||||
+11
@@ -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;
|
||||
|
||||
+31
-5
@@ -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,
|
||||
|
||||
+71
-15
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user