Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/shared-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
}
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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%
);
}
Comment on lines +8 to +18
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compound doesn't have an existing gradient for the critical colour palette, so I made one with identical colour stops to the existing info gradient.


.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;
}
Original file line number Diff line number Diff line change
@@ -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: <p>Hello! This is a status banner.</p>,
onClose: fn(),
},
} satisfies Meta<typeof Banner>;

export default meta;
type Story = StoryObj<typeof meta>;

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: (
<p>
{_t(
"encryption|pinned_identity_changed",
{ displayName: "Alice", userId: "@alice:example.org" },
{
a: (sub) => <a href="https://example.org">{sub}</a>,
b: (sub) => <b>{sub}</b>,
},
)}
</p>
),
actions: <Button kind="primary">{_t("encryption|withdraw_verification_action")}</Button>,
},
};

export const WithAvatarImage: Story = {
args: {
avatar: <img alt="Example" src="https://picsum.photos/32/32" />,
},
};
41 changes: 41 additions & 0 deletions packages/shared-components/src/composer/Banner/Banner.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Default />);
expect(container).toMatchSnapshot();
});
it("renders a info banner", () => {
const { container } = render(<Info />);
expect(container).toMatchSnapshot();
});
it("renders a success banner", () => {
const { container } = render(<Success />);
expect(container).toMatchSnapshot();
});
it("renders a critical banner", () => {
const { container } = render(<Critical />);
expect(container).toMatchSnapshot();
});
it("renders a banner with an action", () => {
const { container } = render(<WithAction />);
expect(container).toMatchSnapshot();
});
it("renders a banner with an avatar iamge", () => {
const { container } = render(<WithAvatarImage />);
expect(container).toMatchSnapshot();
});
});
91 changes: 91 additions & 0 deletions packages/shared-components/src/composer/Banner/Banner.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>;
}

/**
* A banner component used for displaying user-facing information above the message composer.
*
* @example
* ```tsx
* <Banner onClose={onCloseHandler} />
* ```
*/
export function Banner({
type,
children,
avatar,
className,
actions,
onClose,
...props
}: PropsWithChildren<BannerProps>): ReactElement {
const classes = classNames(styles.banner, className);

const icon = useMemo(() => {
switch (type) {
case "critical":
return <ErrorIcon fontSize={24} {...props} />;
case "info":
return <InfoIcon fontSize={24} {...props} />;
case "success":
return <CheckCircleIcon fontSize={24} {...props} />;
default:
return <InfoIcon fontSize={24} {...props} />;
}
}, [type, props]);

return (
<div {...props} className={classes} data-type={type}>
<div className={styles.icon}>{avatar ?? icon}</div>
<span className={styles.content}>{children}</span>
<div className={styles.actions}>
{actions}
<Button kind="secondary" size="sm" onClick={onClose}>
{_t("action|dismiss")}
</Button>
</div>
</div>
);
}
Loading
Loading