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: Example, + }, +}; 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`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a banner with an avatar iamge 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a critical banner 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a default banner 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a info banner 1`] = ` +
+ +
+`; + +exports[`AvatarWithDetails renders a success banner 1`] = ` +
+ +
+`; 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==