diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json
index d6831474fa..8b35b17eef 100644
--- a/packages/shared-components/package.json
+++ b/packages/shared-components/package.json
@@ -46,6 +46,7 @@
"test:storybook:update": "playwright-screenshots --entrypoint yarn --with-node-modules && playwright-screenshots --entrypoint /work/node_modules/.bin/test-storybook --with-node-modules --url http://host.docker.internal:6007/ --updateSnapshot"
},
"dependencies": {
+ "@vector-im/compound-design-tokens": "^6.3.0",
"classnames": "^2.5.1",
"counterpart": "^0.18.6",
"lodash": "^4.17.21",
@@ -88,7 +89,6 @@
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
"peerDependencies": {
- "@vector-im/compound-design-tokens": "^6.0.0",
"@vector-im/compound-web": "^8.2.5"
}
}
diff --git a/packages/shared-components/playwright/snapshots/room-banner--critical-linux.png b/packages/shared-components/playwright/snapshots/room-banner--critical-linux.png
new file mode 100644
index 0000000000..d920c59664
Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--critical-linux.png differ
diff --git a/packages/shared-components/playwright/snapshots/room-banner--default-linux.png b/packages/shared-components/playwright/snapshots/room-banner--default-linux.png
new file mode 100644
index 0000000000..39079994d2
Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--default-linux.png differ
diff --git a/packages/shared-components/playwright/snapshots/room-banner--info-linux.png b/packages/shared-components/playwright/snapshots/room-banner--info-linux.png
new file mode 100644
index 0000000000..7d93288e42
Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--info-linux.png differ
diff --git a/packages/shared-components/playwright/snapshots/room-banner--success-linux.png b/packages/shared-components/playwright/snapshots/room-banner--success-linux.png
new file mode 100644
index 0000000000..8417f0aee3
Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--success-linux.png differ
diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png
new file mode 100644
index 0000000000..563cadf027
Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png differ
diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-avatar-image-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-avatar-image-linux.png
new file mode 100644
index 0000000000..d015a12a00
Binary files /dev/null and b/packages/shared-components/playwright/snapshots/room-banner--with-avatar-image-linux.png differ
diff --git a/packages/shared-components/src/composer/Banner/Banner.module.css b/packages/shared-components/src/composer/Banner/Banner.module.css
new file mode 100644
index 0000000000..077212354e
--- /dev/null
+++ b/packages/shared-components/src/composer/Banner/Banner.module.css
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2025 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.
+ */
+
+:root {
+ --cpd-color-gradient-critical-linear: linear-gradient(
+ 180deg,
+ var(--cpd-color-alpha-red-500) 0%,
+ var(--cpd-color-alpha-red-400) 20%,
+ var(--cpd-color-alpha-red-300) 40%,
+ var(--cpd-color-alpha-red-200) 60%,
+ var(--cpd-color-alpha-red-100) 80%,
+ var(--cpd-color-transparent) 100%
+ );
+}
+
+.banner {
+ container-type: inline-size;
+ container-name: banner;
+ display: flex;
+ align-items: center;
+ justify-content: start;
+ gap: var(--cpd-space-3x);
+ padding: var(--cpd-space-4x);
+
+ border-top: 1px solid var(--cpd-color-gray-400);
+
+ white-space: nowrap;
+}
+
+.banner[data-type="success"] {
+ background: var(--cpd-color-gradient-subtle-linear);
+ border-color: var(--cpd-color-green-900);
+}
+
+.banner[data-type="critical"] {
+ background: var(--cpd-color-gradient-critical-linear);
+ border-color: var(--cpd-color-border-critical-primary);
+}
+
+.banner[data-type="info"] {
+ background: var(--cpd-color-gradient-info-linear);
+ border-color: var(--cpd-color-blue-900);
+}
+
+.banner[data-type="info"] :is(svg) {
+ color: var(--cpd-color-blue-900);
+}
+
+.banner[data-type="success"] :is(.content, svg) {
+ color: var(--cpd-color-green-900);
+}
+
+.banner[data-type="critical"] :is(.content, svg) {
+ color: var(--cpd-color-red-900);
+}
+
+.banner p {
+ margin: 0;
+}
+
+.icon {
+ /* lock icon dimensions */
+ min-width: 32px;
+ min-height: 32px;
+ max-width: 32px;
+ max-height: 32px;
+
+ margin: 4px;
+
+ /* centre svg icons, as they are not full width */
+ flex: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.icon img {
+ border-radius: 50%;
+}
+
+.actions {
+ margin-left: auto;
+
+ flex: 0;
+ display: flex;
+ flex-direction: row;
+ gap: var(--cpd-space-1x);
+ align-self: center;
+}
diff --git a/packages/shared-components/src/composer/Banner/Banner.stories.tsx b/packages/shared-components/src/composer/Banner/Banner.stories.tsx
new file mode 100644
index 0000000000..b813eeb398
--- /dev/null
+++ b/packages/shared-components/src/composer/Banner/Banner.stories.tsx
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2025 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 from "react";
+import { fn } from "storybook/test";
+import { type Meta, type StoryObj } from "@storybook/react-vite";
+import { Button } from "@vector-im/compound-web";
+
+import { Banner } from "./Banner";
+import { _t } from "../../utils/i18n";
+
+const meta = {
+ title: "room/Banner",
+ component: Banner,
+ tags: ["autodocs"],
+ args: {
+ children:
Hello! This is a status banner.
,
+ onClose: fn(),
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+export const Info: Story = {
+ args: {
+ type: "info",
+ },
+};
+export const Success: Story = {
+ args: {
+ type: "success",
+ },
+};
+export const Critical: Story = {
+ args: {
+ type: "critical",
+ },
+};
+export const WithAction: Story = {
+ args: {
+ children: (
+
+ {_t(
+ "encryption|pinned_identity_changed",
+ { displayName: "Alice", userId: "@alice:example.org" },
+ {
+ a: (sub) => {sub},
+ b: (sub) => {sub},
+ },
+ )}
+
+ ),
+ actions: ,
+ },
+};
+
+export const WithAvatarImage: Story = {
+ args: {
+ avatar:
,
+ },
+};
diff --git a/packages/shared-components/src/composer/Banner/Banner.test.tsx b/packages/shared-components/src/composer/Banner/Banner.test.tsx
new file mode 100644
index 0000000000..0f33fb452b
--- /dev/null
+++ b/packages/shared-components/src/composer/Banner/Banner.test.tsx
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2025 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 from "react";
+import { render } from "jest-matrix-react";
+import { composeStories } from "@storybook/react-vite";
+
+import * as stories from "./Banner.stories.tsx";
+
+const { Default, Info, Success, WithAction, WithAvatarImage, Critical } = composeStories(stories);
+
+describe("AvatarWithDetails", () => {
+ it("renders a default banner", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("renders a info banner", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("renders a success banner", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("renders a critical banner", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("renders a banner with an action", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+ it("renders a banner with an avatar iamge", () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/packages/shared-components/src/composer/Banner/Banner.tsx b/packages/shared-components/src/composer/Banner/Banner.tsx
new file mode 100644
index 0000000000..a1622dd1dc
--- /dev/null
+++ b/packages/shared-components/src/composer/Banner/Banner.tsx
@@ -0,0 +1,91 @@
+/*
+ * Copyright (c) 2025 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 classNames from "classnames";
+import React, {
+ type MouseEventHandler,
+ type ReactElement,
+ type ReactNode,
+ type PropsWithChildren,
+ useMemo,
+} from "react";
+import { Button } from "@vector-im/compound-web";
+import CheckCircleIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle";
+import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
+import InfoIcon from "@vector-im/compound-design-tokens/assets/web/icons/info";
+
+import styles from "./Banner.module.css";
+import { _t } from "../../utils/i18n";
+
+interface BannerProps {
+ /**
+ * The type of the status banner.
+ */
+ type?: "success" | "info" | "critical";
+
+ /**
+ * The banner avatar.
+ */
+ avatar?: React.ReactNode;
+
+ className?: string;
+
+ /**
+ * Actions presented to the user in the right-hand side of the banner alongside the dismiss button.
+ */
+ actions?: ReactNode;
+ /**
+ * Called when the user presses the "dismiss" button.
+ */
+ onClose: MouseEventHandler;
+}
+
+/**
+ * A banner component used for displaying user-facing information above the message composer.
+ *
+ * @example
+ * ```tsx
+ *
+ * ```
+ */
+export function Banner({
+ type,
+ children,
+ avatar,
+ className,
+ actions,
+ onClose,
+ ...props
+}: PropsWithChildren): ReactElement {
+ const classes = classNames(styles.banner, className);
+
+ const icon = useMemo(() => {
+ switch (type) {
+ case "critical":
+ return ;
+ case "info":
+ return ;
+ case "success":
+ return ;
+ default:
+ return ;
+ }
+ }, [type, props]);
+
+ return (
+
+
{avatar ?? icon}
+
{children}
+
+ {actions}
+
+
+
+ );
+}
diff --git a/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap
new file mode 100644
index 0000000000..d8b7f11f74
--- /dev/null
+++ b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap
@@ -0,0 +1,290 @@
+// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
+
+exports[`AvatarWithDetails renders a banner with an action 1`] = `
+
+
+
+
+
+ encryption|pinned_identity_changed
+
+
+
+
+
+
+
+
+`;
+
+exports[`AvatarWithDetails renders a banner with an avatar iamge 1`] = `
+
+
+
+

+
+
+
+ Hello! This is a status banner.
+
+
+
+
+
+
+
+`;
+
+exports[`AvatarWithDetails renders a critical banner 1`] = `
+
+
+
+
+
+ Hello! This is a status banner.
+
+
+
+
+
+
+
+`;
+
+exports[`AvatarWithDetails renders a default banner 1`] = `
+
+
+
+
+
+ Hello! This is a status banner.
+
+
+
+
+
+
+
+`;
+
+exports[`AvatarWithDetails renders a info banner 1`] = `
+
+
+
+
+
+ Hello! This is a status banner.
+
+
+
+
+
+
+
+`;
+
+exports[`AvatarWithDetails renders a success banner 1`] = `
+
+
+
+
+
+ Hello! This is a status banner.
+
+
+
+
+
+
+
+`;
diff --git a/packages/shared-components/src/composer/Banner/index.ts b/packages/shared-components/src/composer/Banner/index.ts
new file mode 100644
index 0000000000..5945ff8fd1
--- /dev/null
+++ b/packages/shared-components/src/composer/Banner/index.ts
@@ -0,0 +1,8 @@
+/*
+ * Copyright (c) 2025 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 * from "./Banner";
diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts
index 68935afd3f..565f7013d3 100644
--- a/packages/shared-components/src/index.ts
+++ b/packages/shared-components/src/index.ts
@@ -11,6 +11,7 @@ export * from "./audio/Clock";
export * from "./audio/PlayPauseButton";
export * from "./audio/SeekBar";
export * from "./avatar/AvatarWithDetails";
+export * from "./composer/Banner";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./pill-input/Pill";
diff --git a/packages/shared-components/yarn.lock b/packages/shared-components/yarn.lock
index f95db8b969..45381afdb2 100644
--- a/packages/shared-components/yarn.lock
+++ b/packages/shared-components/yarn.lock
@@ -1959,6 +1959,11 @@
resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777"
integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==
+"@vector-im/compound-design-tokens@^6.3.0":
+ version "6.4.0"
+ resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-6.4.0.tgz#2e51f39f79ebda985a2f6cf80d567b9307aff03a"
+ integrity sha512-93nYQZMgUt6apjCwwnMhMxN8VYQXN3GYOnwovwJjavImwsCGwI/e853BV/DstrWumYh6k5pZsP9e6AF+nz3SIQ==
+
"@vitest/expect@3.2.4":
version "3.2.4"
resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433"
diff --git a/yarn.lock b/yarn.lock
index 96c27256a2..da31ea56b8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1579,15 +1579,8 @@
yaml "^2.7.0"
"@element-hq/web-shared-components@link:packages/shared-components":
- version "0.0.0-test.8"
- dependencies:
- classnames "^2.5.1"
- counterpart "^0.18.6"
- lodash "^4.17.21"
- matrix-web-i18n "^3.4.0"
- patch-package "^8.0.1"
- react-merge-refs "^3.0.2"
- temporal-polyfill "^0.3.0"
+ version "0.0.0"
+ uid ""
"@emnapi/core@^1.4.3", "@emnapi/core@^1.5.0":
version "1.7.0"
@@ -4143,6 +4136,11 @@
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-6.0.0.tgz#a07975ee46307fc31c2ec64a216b6be2b3b27fb3"
integrity sha512-Jk0NsLPCvdcuZi6an1cfyf4MDcIuoPlvja5ZWgJcORyGQZV1eLMHPYKShq9gj+EYk/BXZoPvQ1d6/T+/LSCNPA==
+"@vector-im/compound-design-tokens@^6.3.0":
+ version "6.4.1"
+ resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-6.4.1.tgz#b3356300136b974104b4fb818969350c7686f5ae"
+ integrity sha512-JhrxnzohxGILrc+IZWoMXcpGHinnJlR2HSCKfypEjPDDF5TOB8HQYTqd5ALAPlob8QZU3N2ghnCF7d0f2LmTxg==
+
"@vector-im/compound-web@^8.1.2":
version "8.2.4"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-8.2.4.tgz#1109537365fe49368b13e05c5a32f23956e71fe8"
@@ -4160,13 +4158,14 @@
"@vector-im/matrix-wysiwyg-wasm@link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
+ uid ""
"@vector-im/matrix-wysiwyg@2.40.0":
version "2.40.0"
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c"
integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug==
dependencies:
- "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm"
+ "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm"
"@vitest/expect@3.2.4":
version "3.2.4"
@@ -9603,7 +9602,7 @@ matrix-events-sdk@0.0.1:
jwt-decode "^4.0.0"
loglevel "^1.9.2"
matrix-events-sdk "0.0.1"
- matrix-widget-api "^1.10.0"
+ matrix-widget-api "^1.14.0"
oidc-client-ts "^3.0.1"
p-retry "7"
sdp-transform "^3.0.0"
@@ -12499,16 +12498,7 @@ string-length@^4.0.2:
char-regex "^1.0.2"
strip-ansi "^6.0.0"
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -12616,14 +12606,7 @@ string_decoder@~1.1.1:
dependencies:
safe-buffer "~5.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -13917,16 +13900,7 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
-wrap-ansi@^6.2.0, wrap-ansi@^7.0.0, wrap-ansi@^8.1.0, wrap-ansi@^9.0.0, "wrap-ansi@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^6.2.0, wrap-ansi@^7.0.0, wrap-ansi@^8.1.0, wrap-ansi@^9.0.0, "wrap-ansi@npm:wrap-ansi@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==